Compare commits

..

No commits in common. "0e2c5ef11cc6c26b2ff800ed0bc2399fd5252fd8" and "8d3d21b3309f06888cc207f11916f82696933de1" have entirely different histories.

7 changed files with 35 additions and 326 deletions

View file

@ -47,7 +47,7 @@ REQUIREMENTS *pending-requirements*
- Neovim 0.10+ - Neovim 0.10+
- No external dependencies for local use - No external dependencies for local use
- `curl` and `openssl` are required for the `gcal` sync backend - `curl` and `openssl` are required for Google Calendar sync
============================================================================== ==============================================================================
INSTALL *pending-install* INSTALL *pending-install*
@ -250,23 +250,10 @@ COMMANDS *pending-commands*
Open the list with |:copen| to navigate to each task's category. Open the list with |:copen| to navigate to each task's category.
*:Pending-sync* *:Pending-sync*
:Pending sync {backend} [{action}] :Pending sync
Run a sync action against a named backend. {backend} is required — bare Push pending tasks that have a due date to Google Calendar as all-day
`:Pending sync` prints a usage message. {action} defaults to `sync` events. Requires |pending-gcal| to be configured. See |pending-gcal| for
when omitted. Each backend lives at `lua/pending/sync/<name>.lua`. full details on what gets created, updated, and deleted.
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*
:Pending undo :Pending undo
@ -453,11 +440,9 @@ loads: >lua
next_task = ']t', next_task = ']t',
prev_task = '[t', prev_task = '[t',
}, },
sync = { gcal = {
gcal = { calendar = 'Tasks',
calendar = 'Tasks', credentials_path = '/path/to/client_secret.json',
credentials_path = '/path/to/client_secret.json',
},
}, },
} }
< <
@ -523,16 +508,10 @@ Fields: ~
vim.g.pending = { debug = true } 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) {gcal} (table, default: nil)
Legacy shorthand for `sync.gcal`. If `gcal` is set Google Calendar sync configuration. See
but `sync.gcal` is not, the value is migrated |pending.GcalConfig|. Omit this field entirely to
automatically. New configs should use `sync.gcal` disable Google Calendar sync.
instead. See |pending.GcalConfig|.
============================================================================== ==============================================================================
LUA API *pending-api* LUA API *pending-api*
@ -653,18 +632,13 @@ not pulled back into pending.nvim.
Configuration: >lua Configuration: >lua
vim.g.pending = { vim.g.pending = {
sync = { gcal = {
gcal = { calendar = 'Tasks',
calendar = 'Tasks', credentials_path = '/path/to/client_secret.json',
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* *pending.GcalConfig*
Fields: ~ Fields: ~
{calendar} (string, default: 'Pendings') {calendar} (string, default: 'Pendings')
@ -680,7 +654,7 @@ Fields: ~
that Google provides or as a bare credentials object. that Google provides or as a bare credentials object.
OAuth flow: ~ OAuth flow: ~
On the first `:Pending sync gcal` call the plugin detects that no refresh token On the first `:Pending sync` call the plugin detects that no refresh token
exists and opens the Google authorization URL in the browser using 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 |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 — OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used —
@ -690,7 +664,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 use the stored refresh token and refresh the access token automatically when
it is about to expire. it is about to expire.
`:Pending sync gcal` behavior: ~ `:Pending sync` behavior: ~
For each task in the store: For each task in the store:
- A pending task with a due date and no existing event: a new all-day event is - 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. created and the event ID is stored in the task's `_extra` table.
@ -703,30 +677,6 @@ For each task in the store:
A summary notification is shown after sync: `created: N, updated: N, A summary notification is shown after sync: `created: N, updated: N,
deleted: N`. deleted: N`.
==============================================================================
SYNC BACKENDS *pending-sync-backend*
Sync backends are Lua modules under `lua/pending/sync/<name>.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 <name>`.
{auth} Authorization flow. Called by `:Pending sync <name> 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.<name>` in |pending-config|.
============================================================================== ==============================================================================
HIGHLIGHT GROUPS *pending-highlights* HIGHLIGHT GROUPS *pending-highlights*
@ -778,8 +728,8 @@ Checks performed: ~
- Whether the data directory exists (warning if not yet created) - Whether the data directory exists (warning if not yet created)
- Whether the data file exists and can be parsed; reports total task count - Whether the data file exists and can be parsed; reports total task count
- Validates recurrence specs on stored tasks - Validates recurrence specs on stored tasks
- Discovers sync backends under `lua/pending/sync/` and runs each backend's - Whether `curl` is available (required for Google Calendar sync)
`health()` function if it exists (e.g. gcal checks for `curl` and `openssl`) - Whether `openssl` is available (required for OAuth PKCE)
============================================================================== ==============================================================================
DATA FORMAT *pending-data* DATA FORMAT *pending-data*

View file

@ -2,9 +2,6 @@
---@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
@ -35,7 +32,6 @@
---@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
@ -69,7 +65,6 @@ local defaults = {
next_task = ']t', next_task = ']t',
prev_task = '[t', prev_task = '[t',
}, },
sync = {},
} }
---@type pending.Config? ---@type pending.Config?
@ -82,10 +77,6 @@ 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,18 +47,16 @@ 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
local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true) if vim.fn.executable('curl') == 1 then
if #sync_paths == 0 then vim.health.ok('curl found (required for Google Calendar sync)')
vim.health.info('No sync backends found')
else else
for _, path in ipairs(sync_paths) do vim.health.warn('curl not found (needed for Google Calendar sync)')
local name = vim.fn.fnamemodify(path, ':t:r') end
local bok, backend = pcall(require, 'pending.sync.' .. name)
if bok and type(backend.health) == 'function' then if vim.fn.executable('openssl') == 1 then
vim.health.start('pending.nvim: sync/' .. name) vim.health.ok('openssl found (required for OAuth PKCE)')
backend.health() else
end vim.health.warn('openssl not found (needed for Google Calendar OAuth)')
end
end end
end end

View file

@ -414,25 +414,14 @@ 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(backend_name, action) function M.sync()
if not backend_name or backend_name == '' then local ok, gcal = pcall(require, 'pending.sync.gcal')
vim.notify('Usage: :Pending sync <backend> [action]', vim.log.levels.ERROR)
return
end
action = (action and action ~= '') and action or 'sync'
local ok, backend = pcall(require, 'pending.sync.' .. backend_name)
if not ok then if not ok then
vim.notify('Unknown sync backend: ' .. backend_name, vim.log.levels.ERROR) vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR)
return return
end end
if type(backend[action]) ~= 'function' then gcal.sync()
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
@ -711,8 +700,7 @@ 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
local backend, action = rest:match('^(%S+)%s*(.*)') M.sync()
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,8 +3,6 @@ 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'
@ -24,7 +22,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.sync and cfg.sync.gcal) or cfg.gcal or {} return cfg.gcal or {}
end end
---@return string ---@return string
@ -201,7 +199,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.auth() M.authorize()
tokens = load_tokens() tokens = load_tokens()
if not tokens then if not tokens then
return nil return nil
@ -220,7 +218,7 @@ local function get_access_token()
return tokens.access_token return tokens.access_token
end end
function M.auth() function M.authorize()
local creds = load_credentials() local creds = load_credentials()
if not creds then if not creds then
vim.notify( vim.notify(
@ -516,18 +514,4 @@ 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,34 +171,6 @@ 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,
}) })

View file

@ -1,174 +0,0 @@
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 <backend> [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 <backend> [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)