pending.nvim/plugin/pending.lua
Barrett Ruth d12838abbf feat: auth backend (#111)
* refactor(types): extract inline anonymous types into named classes

Problem: several functions used inline `{...}` table types in their
`@param` and `@return` annotations, making them hard to read and
impossible to reference from other modules.

Solution: extract each into a named `---@class`: `pending.Metadata`,
`pending.TaskFields`, `pending.CompletionItem`, `pending.SystemResult`,
and `pending.OAuthClientOpts`.

* refactor(sync): extract shared utilities into `sync/util.lua`

Problem: sync epilogue code (`s:save()`, `_recompute_counts()`,
`buffer.render()`) and `fmt_counts` were duplicated across `gcal.lua`
and `gtasks.lua`. The concurrency guard lived in `oauth.lua`, coupling
non-OAuth backends to the OAuth module.

Solution: create `sync/util.lua` with `async`, `system`, `with_guard`,
`finish`, and `fmt_counts`. Delegate from `oauth.lua` and replace
duplicated code in both backends. Add per-backend `auth()` and
`auth_complete()` methods to `gcal.lua` and `gtasks.lua`.

* feat(sync): auto-discover backends, per-backend auth, S3 backend

Problem: sync backends were hardcoded in `SYNC_BACKENDS` list in
`init.lua`, auth routed directly through `oauth.google_client`, and
adding a non-OAuth backend required editing multiple files.

Solution: replace hardcoded list with `discover_backends()` that globs
`lua/pending/sync/*.lua` at runtime. Rewrite `M.auth()` to dispatch
to per-backend `auth()` methods with `vim.ui.select` fallback. Add
`lua/pending/sync/s3.lua` with push/pull/sync via AWS CLI, per-task
merge by `_s3_sync_id` (UUID), and `pending.S3Config` type.
2026-03-08 19:53:42 -04:00

384 lines
10 KiB
Lua

if vim.g.loaded_pending then
return
end
vim.g.loaded_pending = true
---@return string[]
local function edit_field_candidates()
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
return {
dk .. ':',
'cat:',
rk .. ':',
'+!',
'+!!',
'+!!!',
'-!',
'-' .. dk,
'-cat',
'-' .. rk,
}
end
---@return string[]
local function edit_date_values()
return {
'today',
'tomorrow',
'yesterday',
'+1d',
'+2d',
'+3d',
'+1w',
'+2w',
'+1m',
'mon',
'tue',
'wed',
'thu',
'fri',
'sat',
'sun',
'eod',
'eow',
'eom',
'eoq',
'eoy',
'sow',
'som',
'soq',
'soy',
'later',
}
end
---@return string[]
local function edit_recur_values()
local ok, recur = pcall(require, 'pending.recur')
if not ok then
return {}
end
local result = {}
for _, s in ipairs(recur.shorthand_list()) do
table.insert(result, s)
end
for _, s in ipairs(recur.shorthand_list()) do
table.insert(result, '!' .. s)
end
return result
end
---@param lead string
---@param candidates string[]
---@return string[]
local function filter_candidates(lead, candidates)
return vim.tbl_filter(function(s)
return s:find(lead, 1, true) == 1
end, candidates)
end
---@param arg_lead string
---@param cmd_line string
---@return string[]
local function complete_edit(arg_lead, cmd_line)
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
local after_edit = cmd_line:match('^Pending%s+edit%s+(.*)')
if not after_edit then
return {}
end
local parts = {}
for part in after_edit:gmatch('%S+') do
table.insert(parts, part)
end
local trailing_space = after_edit:match('%s$')
if #parts == 0 or (#parts == 1 and not trailing_space) 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
local prefix = arg_lead:match('^(' .. vim.pesc(dk) .. ':)(.*)$')
if prefix then
local after_colon = arg_lead:sub(#prefix + 1)
local dates = edit_date_values()
local result = {}
for _, d in ipairs(dates) do
if d:find(after_colon, 1, true) == 1 then
table.insert(result, prefix .. d)
end
end
return result
end
local rec_prefix = arg_lead:match('^(' .. vim.pesc(rk) .. ':)(.*)$')
if rec_prefix then
local after_colon = arg_lead:sub(#rec_prefix + 1)
local pats = edit_recur_values()
local result = {}
for _, p in ipairs(pats) do
if p:find(after_colon, 1, true) == 1 then
table.insert(result, rec_prefix .. p)
end
end
return result
end
local cat_prefix = arg_lead:match('^(cat:)(.*)$')
if cat_prefix then
local after_colon = arg_lead:sub(#cat_prefix + 1)
local store = require('pending.store')
local s = store.new(store.resolve_path())
s:load()
local seen = {}
local cats = {}
for _, task in ipairs(s:active_tasks()) do
if task.category and not seen[task.category] then
seen[task.category] = true
table.insert(cats, task.category)
end
end
table.sort(cats)
local result = {}
for _, c in ipairs(cats) do
if c:find(after_colon, 1, true) == 1 then
table.insert(result, cat_prefix .. c)
end
end
return result
end
return filter_candidates(arg_lead, edit_field_candidates())
end
vim.api.nvim_create_user_command('Pending', function(opts)
require('pending').command(opts.args)
end, {
bar = true,
nargs = '*',
complete = function(arg_lead, cmd_line)
local pending = require('pending')
local subcmds = { 'add', 'archive', 'auth', 'done', 'due', 'edit', 'filter', 'undo' }
for _, b in ipairs(pending.sync_backends()) do
table.insert(subcmds, b)
end
table.sort(subcmds)
if not cmd_line:match('^Pending%s+%S') then
return filter_candidates(arg_lead, subcmds)
end
if cmd_line:match('^Pending%s+filter') then
local after_filter = cmd_line:match('^Pending%s+filter%s+(.*)') or ''
local used = {}
for word in after_filter:gmatch('%S+') do
used[word] = true
end
local candidates =
{ 'clear', 'overdue', 'today', 'priority', 'done', 'pending', 'wip', 'blocked' }
local store = require('pending.store')
local s = store.new(store.resolve_path())
s:load()
local seen = {}
for _, task in ipairs(s:active_tasks()) do
if task.category and not seen[task.category] then
seen[task.category] = true
table.insert(candidates, 'cat:' .. task.category)
end
end
local filtered = {}
for _, c in ipairs(candidates) do
if not used[c] and (arg_lead == '' or c:find(arg_lead, 1, true) == 1) then
table.insert(filtered, c)
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
if cmd_line:match('^Pending%s+auth') then
local after_auth = cmd_line:match('^Pending%s+auth%s+(.*)') or ''
local parts = {}
for w in after_auth:gmatch('%S+') do
table.insert(parts, w)
end
local trailing = after_auth:match('%s$')
if #parts == 0 or (#parts == 1 and not trailing) then
local auth_names = {}
for _, b in ipairs(pending.sync_backends()) do
local ok, mod = pcall(require, 'pending.sync.' .. b)
if ok and type(mod.auth) == 'function' then
table.insert(auth_names, b)
end
end
return filter_candidates(arg_lead, auth_names)
end
local backend_name = parts[1]
if #parts == 1 or (#parts == 2 and not trailing) then
local ok, mod = pcall(require, 'pending.sync.' .. backend_name)
if ok and type(mod.auth_complete) == 'function' then
return filter_candidates(arg_lead, mod.auth_complete())
end
return {}
end
return {}
end
local backend_set = pending.sync_backend_set()
local matched_backend = cmd_line:match('^Pending%s+(%S+)')
if matched_backend and backend_set[matched_backend] then
local after_backend = cmd_line:match('^Pending%s+%S+%s+(.*)')
if not after_backend then
return {}
end
local ok, mod = pcall(require, 'pending.sync.' .. matched_backend)
if not ok then
return {}
end
local actions = {}
for k, v in pairs(mod) do
if
type(v) == 'function'
and k:sub(1, 1) ~= '_'
and k ~= 'health'
and k ~= 'auth'
and k ~= 'auth_complete'
then
table.insert(actions, k)
end
end
table.sort(actions)
return filter_candidates(arg_lead, actions)
end
return {}
end,
})
vim.keymap.set('n', '<Plug>(pending-open)', function()
require('pending').open()
end)
vim.keymap.set('n', '<Plug>(pending-close)', function()
require('pending.buffer').close()
end)
vim.keymap.set('n', '<Plug>(pending-toggle)', function()
require('pending').toggle_complete()
end)
vim.keymap.set('n', '<Plug>(pending-view)', function()
require('pending.buffer').toggle_view()
end)
vim.keymap.set('n', '<Plug>(pending-priority)', function()
require('pending').toggle_priority()
end)
vim.keymap.set('n', '<Plug>(pending-date)', function()
require('pending').prompt_date()
end)
vim.keymap.set('n', '<Plug>(pending-undo)', function()
require('pending').undo_write()
end)
vim.keymap.set('n', '<Plug>(pending-category)', function()
require('pending').prompt_category()
end)
vim.keymap.set('n', '<Plug>(pending-recur)', function()
require('pending').prompt_recur()
end)
vim.keymap.set('n', '<Plug>(pending-move-down)', function()
require('pending').move_task('down')
end)
vim.keymap.set('n', '<Plug>(pending-move-up)', function()
require('pending').move_task('up')
end)
vim.keymap.set('n', '<Plug>(pending-wip)', function()
require('pending').toggle_status('wip')
end)
vim.keymap.set('n', '<Plug>(pending-blocked)', function()
require('pending').toggle_status('blocked')
end)
vim.keymap.set('n', '<Plug>(pending-filter)', function()
vim.ui.input({ prompt = 'Filter: ' }, function(input)
if input then
require('pending').filter(input)
end
end)
end)
vim.keymap.set('n', '<Plug>(pending-open-line)', function()
require('pending.buffer').open_line(false)
end)
vim.keymap.set('n', '<Plug>(pending-open-line-above)', function()
require('pending.buffer').open_line(true)
end)
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-a-task)', function()
require('pending.textobj').a_task(vim.v.count1)
end)
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-i-task)', function()
require('pending.textobj').i_task(vim.v.count1)
end)
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-a-category)', function()
require('pending.textobj').a_category(vim.v.count1)
end)
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-i-category)', function()
require('pending.textobj').i_category(vim.v.count1)
end)
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-next-header)', function()
require('pending.textobj').next_header(vim.v.count1)
end)
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-prev-header)', function()
require('pending.textobj').prev_header(vim.v.count1)
end)
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-next-task)', function()
require('pending.textobj').next_task(vim.v.count1)
end)
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-prev-task)', function()
require('pending.textobj').prev_task(vim.v.count1)
end)
vim.keymap.set('n', '<Plug>(pending-tab)', function()
vim.cmd.tabnew()
require('pending').open()
end)
vim.api.nvim_create_user_command('PendingTab', function()
vim.cmd.tabnew()
require('pending').open()
end, {})