diff --git a/doc/pending.txt b/doc/pending.txt index fd73e30..be369b5 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -47,7 +47,7 @@ REQUIREMENTS *pending-requirements* - Neovim 0.10+ - No external dependencies for local use -- `curl` and `openssl` are required for Google Calendar sync +- `curl` and `openssl` are required for the `gcal` sync backend ============================================================================== INSTALL *pending-install* @@ -250,10 +250,23 @@ COMMANDS *pending-commands* Open the list with |:copen| to navigate to each task's category. *:Pending-sync* -:Pending sync - Push pending tasks that have a due date to Google Calendar as all-day - events. Requires |pending-gcal| to be configured. See |pending-gcal| for - full details on what gets created, updated, and deleted. +:Pending sync {backend} [{action}] + Run a sync action against a named backend. {backend} is required — bare + `:Pending sync` prints a usage message. {action} defaults to `sync` + when omitted. Each backend lives at `lua/pending/sync/.lua`. + + Examples: >vim + :Pending sync gcal " runs gcal.sync() + :Pending sync gcal auth " runs gcal.auth() + :Pending sync gcal sync " explicit sync (same as bare) +< + + Tab completion after `:Pending sync ` lists discovered backends. + Tab completion after `:Pending sync gcal ` lists available actions. + + Built-in backends: ~ + + `gcal` Google Calendar one-way push. See |pending-gcal|. *:Pending-undo* :Pending undo @@ -440,9 +453,11 @@ loads: >lua next_task = ']t', prev_task = '[t', }, - gcal = { - calendar = 'Tasks', - credentials_path = '/path/to/client_secret.json', + sync = { + gcal = { + calendar = 'Tasks', + credentials_path = '/path/to/client_secret.json', + }, }, } < @@ -508,10 +523,16 @@ Fields: ~ vim.g.pending = { debug = true } < + {sync} (table, default: {}) *pending.SyncConfig* + Sync backend configuration. Each key is a backend + name and the value is the backend-specific config + table. Currently only `gcal` is built-in. + {gcal} (table, default: nil) - Google Calendar sync configuration. See - |pending.GcalConfig|. Omit this field entirely to - disable Google Calendar sync. + Legacy shorthand for `sync.gcal`. If `gcal` is set + but `sync.gcal` is not, the value is migrated + automatically. New configs should use `sync.gcal` + instead. See |pending.GcalConfig|. ============================================================================== LUA API *pending-api* @@ -632,13 +653,18 @@ not pulled back into pending.nvim. Configuration: >lua vim.g.pending = { - gcal = { - calendar = 'Tasks', - credentials_path = '/path/to/client_secret.json', + sync = { + gcal = { + calendar = 'Tasks', + credentials_path = '/path/to/client_secret.json', + }, }, } < +The legacy `gcal` top-level key is still accepted and migrated automatically. +New configurations should use `sync.gcal`. + *pending.GcalConfig* Fields: ~ {calendar} (string, default: 'Pendings') @@ -654,7 +680,7 @@ Fields: ~ that Google provides or as a bare credentials object. OAuth flow: ~ -On the first `:Pending sync` call the plugin detects that no refresh token +On the first `:Pending sync gcal` call the plugin detects that no refresh token exists and opens the Google authorization URL in the browser using |vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used — @@ -664,7 +690,7 @@ authorization code is exchanged for tokens and the refresh token is stored at use the stored refresh token and refresh the access token automatically when it is about to expire. -`:Pending sync` behavior: ~ +`:Pending sync gcal` behavior: ~ For each task in the store: - A pending task with a due date and no existing event: a new all-day event is created and the event ID is stored in the task's `_extra` table. @@ -677,6 +703,30 @@ For each task in the store: A summary notification is shown after sync: `created: N, updated: N, deleted: N`. +============================================================================== +SYNC BACKENDS *pending-sync-backend* + +Sync backends are Lua modules under `lua/pending/sync/.lua`. Each +module returns a table conforming to the backend interface: >lua + + ---@class pending.SyncBackend + ---@field name string + ---@field auth fun(): nil + ---@field sync fun(): nil + ---@field health? fun(): nil +< + +Required fields: ~ + {name} Backend identifier (matches the filename). + {sync} Main sync action. Called by `:Pending sync `. + {auth} Authorization flow. Called by `:Pending sync auth`. + +Optional fields: ~ + {health} Called by `:checkhealth pending` to report backend-specific + diagnostics (e.g. checking for external tools). + +Backend-specific configuration goes under `sync.` in |pending-config|. + ============================================================================== HIGHLIGHT GROUPS *pending-highlights* @@ -728,8 +778,8 @@ Checks performed: ~ - Whether the data directory exists (warning if not yet created) - Whether the data file exists and can be parsed; reports total task count - Validates recurrence specs on stored tasks -- Whether `curl` is available (required for Google Calendar sync) -- Whether `openssl` is available (required for OAuth PKCE) +- Discovers sync backends under `lua/pending/sync/` and runs each backend's + `health()` function if it exists (e.g. gcal checks for `curl` and `openssl`) ============================================================================== DATA FORMAT *pending-data* diff --git a/lua/pending/config.lua b/lua/pending/config.lua index ac98b64..a1767db 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -2,6 +2,9 @@ ---@field calendar? string ---@field credentials_path? string +---@class pending.SyncConfig +---@field gcal? pending.GcalConfig + ---@class pending.Keymaps ---@field close? string|false ---@field toggle? string|false @@ -32,6 +35,7 @@ ---@field drawer_height? integer ---@field debug? boolean ---@field keymaps pending.Keymaps +---@field sync? pending.SyncConfig ---@field gcal? pending.GcalConfig ---@class pending.config @@ -65,6 +69,7 @@ local defaults = { next_task = ']t', prev_task = '[t', }, + sync = {}, } ---@type pending.Config? @@ -77,6 +82,10 @@ function M.get() end local user = vim.g.pending or {} _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 end diff --git a/lua/pending/health.lua b/lua/pending/health.lua index cc285e0..93f7c72 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -47,16 +47,18 @@ function M.check() vim.health.info('No data file yet (will be created on first save)') end - if vim.fn.executable('curl') == 1 then - vim.health.ok('curl found (required for Google Calendar sync)') + local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true) + if #sync_paths == 0 then + vim.health.info('No sync backends found') else - vim.health.warn('curl not found (needed for Google Calendar sync)') - end - - if vim.fn.executable('openssl') == 1 then - vim.health.ok('openssl found (required for OAuth PKCE)') - else - vim.health.warn('openssl not found (needed for Google Calendar OAuth)') + for _, path in ipairs(sync_paths) do + local name = vim.fn.fnamemodify(path, ':t:r') + local bok, backend = pcall(require, 'pending.sync.' .. name) + if bok and type(backend.health) == 'function' then + vim.health.start('pending.nvim: sync/' .. name) + backend.health() + end + end end end diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 0fcd564..cae13a9 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -414,14 +414,25 @@ function M.add(text) vim.notify('Pending added: ' .. description) end +---@param backend_name string +---@param action? string ---@return nil -function M.sync() - local ok, gcal = pcall(require, 'pending.sync.gcal') - if not ok then - vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR) +function M.sync(backend_name, action) + if not backend_name or backend_name == '' then + vim.notify('Usage: :Pending sync [action]', vim.log.levels.ERROR) return 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 ---@param days? integer @@ -700,7 +711,8 @@ function M.command(args) local id_str, edit_rest = rest:match('^(%S+)%s*(.*)') M.edit(id_str, edit_rest) elseif cmd == 'sync' then - M.sync() + local backend, action = rest:match('^(%S+)%s*(.*)') + M.sync(backend, action) elseif cmd == 'archive' then local d = rest ~= '' and tonumber(rest) or nil M.archive(d) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 3b29b33..843f310 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -3,6 +3,8 @@ local store = require('pending.store') local M = {} +M.name = 'gcal' + local BASE_URL = 'https://www.googleapis.com/calendar/v3' local TOKEN_URL = 'https://oauth2.googleapis.com/token' 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 local function gcal_config() local cfg = config.get() - return cfg.gcal or {} + return (cfg.sync and cfg.sync.gcal) or cfg.gcal or {} end ---@return string @@ -199,7 +201,7 @@ local function get_access_token() end local tokens = load_tokens() if not tokens or not tokens.refresh_token then - M.authorize() + M.auth() tokens = load_tokens() if not tokens then return nil @@ -218,7 +220,7 @@ local function get_access_token() return tokens.access_token end -function M.authorize() +function M.auth() local creds = load_credentials() if not creds then vim.notify( @@ -514,4 +516,18 @@ function M.sync() ) 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 diff --git a/plugin/pending.lua b/plugin/pending.lua index f9a8df1..839b351 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -171,6 +171,34 @@ end, { if cmd_line:match('^Pending%s+edit') then return complete_edit(arg_lead, cmd_line) 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 {} end, }) diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua new file mode 100644 index 0000000..4d8a3dc --- /dev/null +++ b/spec/sync_spec.lua @@ -0,0 +1,174 @@ +require('spec.helpers') + +local config = require('pending.config') +local store = require('pending.store') + +describe('sync', function() + local tmpdir + local pending + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + config.reset() + store.unload() + package.loaded['pending'] = nil + pending = require('pending') + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + store.unload() + package.loaded['pending'] = nil + end) + + describe('dispatch', function() + it('errors on bare :Pending sync with no backend', function() + local msg + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + pending.sync(nil) + vim.notify = orig + assert.are.equal('Usage: :Pending sync [action]', msg) + end) + + it('errors on empty backend string', function() + local msg + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + pending.sync('') + vim.notify = orig + assert.are.equal('Usage: :Pending sync [action]', msg) + end) + + it('errors on unknown backend', function() + local msg + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + pending.sync('notreal') + vim.notify = orig + assert.are.equal('Unknown sync backend: notreal', msg) + end) + + it('errors on unknown action for valid backend', function() + local msg + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + pending.sync('gcal', 'notreal') + vim.notify = orig + assert.are.equal("gcal backend has no 'notreal' action", msg) + end) + + it('defaults to sync action when action is omitted', function() + local called = false + local gcal = require('pending.sync.gcal') + local orig_sync = gcal.sync + gcal.sync = function() + called = true + end + pending.sync('gcal') + gcal.sync = orig_sync + assert.is_true(called) + end) + + it('routes explicit sync action', function() + local called = false + local gcal = require('pending.sync.gcal') + local orig_sync = gcal.sync + gcal.sync = function() + called = true + end + pending.sync('gcal', 'sync') + gcal.sync = orig_sync + assert.is_true(called) + end) + + it('routes auth action', function() + local called = false + local gcal = require('pending.sync.gcal') + local orig_auth = gcal.auth + gcal.auth = function() + called = true + end + pending.sync('gcal', 'auth') + gcal.auth = orig_auth + assert.is_true(called) + end) + end) + + describe('config migration', function() + it('migrates legacy gcal to sync.gcal', function() + config.reset() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + gcal = { calendar = 'MyCalendar' }, + } + local cfg = config.get() + assert.is_not_nil(cfg.sync) + assert.is_not_nil(cfg.sync.gcal) + assert.are.equal('MyCalendar', cfg.sync.gcal.calendar) + end) + + it('does not overwrite explicit sync.gcal with legacy gcal', function() + config.reset() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + gcal = { calendar = 'Legacy' }, + sync = { gcal = { calendar = 'Explicit' } }, + } + local cfg = config.get() + assert.are.equal('Explicit', cfg.sync.gcal.calendar) + end) + + it('works with sync.gcal and no legacy gcal', function() + config.reset() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + sync = { gcal = { calendar = 'NewStyle' } }, + } + local cfg = config.get() + assert.are.equal('NewStyle', cfg.sync.gcal.calendar) + end) + end) + + describe('gcal module', function() + it('has name field', function() + local gcal = require('pending.sync.gcal') + assert.are.equal('gcal', gcal.name) + end) + + it('has auth function', function() + local gcal = require('pending.sync.gcal') + assert.are.equal('function', type(gcal.auth)) + end) + + it('has sync function', function() + local gcal = require('pending.sync.gcal') + assert.are.equal('function', type(gcal.sync)) + end) + + it('has health function', function() + local gcal = require('pending.sync.gcal') + assert.are.equal('function', type(gcal.health)) + end) + end) +end)