refactor(sync): extract backend interface, adapt gcal module

Problem: :Pending sync hardcodes Google Calendar — M.sync() does
pcall(require, 'pending.sync.gcal') and calls gcal.sync() directly.
The config has a flat gcal field. This prevents adding new sync backends
without modifying init.lua.

Solution: Define a backend interface contract (name, auth, sync, health
fields), refactor :Pending sync to dispatch via require('pending.sync.'
.. backend_name), add sync table to config with legacy gcal migration,
rename gcal.authorize to gcal.auth, add gcal.health for checkhealth,
and add tab completion for backend names and actions.
This commit is contained in:
Barrett Ruth 2026-02-26 16:44:20 -05:00
parent 8d3d21b330
commit 7de9c23ec1
5 changed files with 85 additions and 18 deletions

View file

@ -2,6 +2,9 @@
---@field calendar? string ---@field calendar? string
---@field credentials_path? string ---@field credentials_path? string
---@class pending.SyncConfig
---@field gcal? pending.GcalConfig
---@class pending.Keymaps ---@class pending.Keymaps
---@field close? string|false ---@field close? string|false
---@field toggle? string|false ---@field toggle? string|false
@ -32,6 +35,7 @@
---@field drawer_height? integer ---@field drawer_height? integer
---@field debug? boolean ---@field debug? boolean
---@field keymaps pending.Keymaps ---@field keymaps pending.Keymaps
---@field sync? pending.SyncConfig
---@field gcal? pending.GcalConfig ---@field gcal? pending.GcalConfig
---@class pending.config ---@class pending.config
@ -65,6 +69,7 @@ local defaults = {
next_task = ']t', next_task = ']t',
prev_task = '[t', prev_task = '[t',
}, },
sync = {},
} }
---@type pending.Config? ---@type pending.Config?
@ -77,6 +82,10 @@ function M.get()
end end
local user = vim.g.pending or {} local user = vim.g.pending or {}
_resolved = vim.tbl_deep_extend('force', defaults, user) _resolved = vim.tbl_deep_extend('force', defaults, user)
if _resolved.gcal and not (_resolved.sync and _resolved.sync.gcal) then
_resolved.sync = _resolved.sync or {}
_resolved.sync.gcal = _resolved.gcal
end
return _resolved return _resolved
end end

View file

@ -47,16 +47,18 @@ function M.check()
vim.health.info('No data file yet (will be created on first save)') vim.health.info('No data file yet (will be created on first save)')
end end
if vim.fn.executable('curl') == 1 then local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
vim.health.ok('curl found (required for Google Calendar sync)') if #sync_paths == 0 then
vim.health.info('No sync backends found')
else else
vim.health.warn('curl not found (needed for Google Calendar sync)') for _, path in ipairs(sync_paths) do
end local name = vim.fn.fnamemodify(path, ':t:r')
local bok, backend = pcall(require, 'pending.sync.' .. name)
if vim.fn.executable('openssl') == 1 then if bok and type(backend.health) == 'function' then
vim.health.ok('openssl found (required for OAuth PKCE)') vim.health.start('pending.nvim: sync/' .. name)
else backend.health()
vim.health.warn('openssl not found (needed for Google Calendar OAuth)') end
end
end end
end end

View file

@ -414,14 +414,25 @@ function M.add(text)
vim.notify('Pending added: ' .. description) vim.notify('Pending added: ' .. description)
end end
---@param backend_name string
---@param action? string
---@return nil ---@return nil
function M.sync() function M.sync(backend_name, action)
local ok, gcal = pcall(require, 'pending.sync.gcal') if not backend_name or backend_name == '' then
if not ok then vim.notify('Usage: :Pending sync <backend> [action]', vim.log.levels.ERROR)
vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR)
return return
end end
gcal.sync() action = (action and action ~= '') and action or 'sync'
local ok, backend = pcall(require, 'pending.sync.' .. backend_name)
if not ok then
vim.notify('Unknown sync backend: ' .. backend_name, vim.log.levels.ERROR)
return
end
if type(backend[action]) ~= 'function' then
vim.notify(backend_name .. " backend has no '" .. action .. "' action", vim.log.levels.ERROR)
return
end
backend[action]()
end end
---@param days? integer ---@param days? integer
@ -700,7 +711,8 @@ function M.command(args)
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)') local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
M.edit(id_str, edit_rest) M.edit(id_str, edit_rest)
elseif cmd == 'sync' then elseif cmd == 'sync' then
M.sync() local backend, action = rest:match('^(%S+)%s*(.*)')
M.sync(backend, action)
elseif cmd == 'archive' then elseif cmd == 'archive' then
local d = rest ~= '' and tonumber(rest) or nil local d = rest ~= '' and tonumber(rest) or nil
M.archive(d) M.archive(d)

View file

@ -3,6 +3,8 @@ local store = require('pending.store')
local M = {} local M = {}
M.name = 'gcal'
local BASE_URL = 'https://www.googleapis.com/calendar/v3' local BASE_URL = 'https://www.googleapis.com/calendar/v3'
local TOKEN_URL = 'https://oauth2.googleapis.com/token' local TOKEN_URL = 'https://oauth2.googleapis.com/token'
local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
@ -22,7 +24,7 @@ local SCOPE = 'https://www.googleapis.com/auth/calendar'
---@return table<string, any> ---@return table<string, any>
local function gcal_config() local function gcal_config()
local cfg = config.get() local cfg = config.get()
return cfg.gcal or {} return (cfg.sync and cfg.sync.gcal) or cfg.gcal or {}
end end
---@return string ---@return string
@ -199,7 +201,7 @@ local function get_access_token()
end end
local tokens = load_tokens() local tokens = load_tokens()
if not tokens or not tokens.refresh_token then if not tokens or not tokens.refresh_token then
M.authorize() M.auth()
tokens = load_tokens() tokens = load_tokens()
if not tokens then if not tokens then
return nil return nil
@ -218,7 +220,7 @@ local function get_access_token()
return tokens.access_token return tokens.access_token
end end
function M.authorize() function M.auth()
local creds = load_credentials() local creds = load_credentials()
if not creds then if not creds then
vim.notify( vim.notify(
@ -514,4 +516,18 @@ function M.sync()
) )
end end
---@return nil
function M.health()
if vim.fn.executable('curl') == 1 then
vim.health.ok('curl found (required for gcal sync)')
else
vim.health.warn('curl not found (needed for gcal sync)')
end
if vim.fn.executable('openssl') == 1 then
vim.health.ok('openssl found (required for gcal OAuth PKCE)')
else
vim.health.warn('openssl not found (needed for gcal OAuth)')
end
end
return M return M

View file

@ -171,6 +171,34 @@ end, {
if cmd_line:match('^Pending%s+edit') then if cmd_line:match('^Pending%s+edit') then
return complete_edit(arg_lead, cmd_line) return complete_edit(arg_lead, cmd_line)
end end
if cmd_line:match('^Pending%s+sync') then
local after_sync = cmd_line:match('^Pending%s+sync%s+(.*)')
if not after_sync then
return {}
end
local parts = {}
for part in after_sync:gmatch('%S+') do
table.insert(parts, part)
end
local trailing_space = after_sync:match('%s$')
if #parts == 0 or (#parts == 1 and not trailing_space) then
local backends = {}
local pattern = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
for _, path in ipairs(pattern) do
local name = vim.fn.fnamemodify(path, ':t:r')
table.insert(backends, name)
end
table.sort(backends)
return filter_candidates(arg_lead, backends)
end
if #parts == 1 and trailing_space then
return filter_candidates(arg_lead, { 'auth', 'sync' })
end
if #parts >= 2 and not trailing_space then
return filter_candidates(arg_lead, { 'auth', 'sync' })
end
return {}
end
return {} return {}
end, end,
}) })