fix: harden sync backends and fix edit recompute (#66)

* refactor(oauth): async coroutine support, pure-Lua PKCE, server hardening

Problem: OAuth module shelled out to openssl for PKCE, used blocking
`vim.system():wait()`, had a weak `os.time()` PRNG seed, and the TCP
callback server leaked on read errors with no timeout.

Solution: Add `M.system()` coroutine wrapper and `M.async()` helper,
replace openssl with `vim.fn.sha256` + `vim.base64.encode`, seed from
`vim.uv.hrtime()`, add `close_server()` guard with 120s timeout, and
close the server on read errors.

* fix(gtasks): async operations, error notifications, buffer refresh

Problem: Sync operations blocked the editor, `push_pass` silently
dropped delete/update/create API errors, and the buffer was not
re-rendered after push/pull/sync.

Solution: Wrap `push`, `pull`, `sync` in `oauth.async()`, add
`vim.notify` for all `push_pass` failure paths, and re-render the
pending buffer after each operation.

* fix(init): edit recompute, filter predicates, sync action listing

Problem: `M.edit()` skipped `_recompute_counts()` after saving,
`compute_hidden_ids` lacked `done`/`pending` predicates, and
`run_sync` defaulted to `sync` instead of listing available actions.

Solution: Replace `s:save()` with `_save_and_notify()` in `M.edit()`,
add `done` and `pending` filter predicates, and list backend actions
when no action is specified.

* refactor(gcal): per-category calendars, async push, error notifications

Problem: gcal used a single hardcoded calendar name, ran synchronously
blocking the editor, and silently dropped some API errors.

Solution: Fetch all calendars and map categories to calendars (creating
on demand), wrap push in `oauth.async()`, notify on individual API
failures, track `_gcal_calendar_id` in `_extra`, and remove the `$`
anchor from `next_day` pattern.

* refactor: formatting fixes, config cleanup, health simplification

Problem: Formatter disagreements in `init.lua` and `gtasks.lua`,
stale `calendar` field in gcal config, and redundant health checks
for data directory existence.

Solution: Apply stylua formatting, remove `calendar` field from
`pending.GcalConfig`, drop data-dir and no-file health messages,
add `done`/`pending` to filter tab-completion candidates.

* docs: update vimdoc for sync refactor, remove demo scripts

Problem: Docs still referenced openssl dependency, defaulting to `sync`
action, and the `calendar` config field. Demo scripts used the old
singleton `store` API.

Solution: Update vimdoc and README to reflect explicit actions, per-
category calendars, and pure-Lua PKCE. Remove stale demo scripts and
update sync specs to match new behavior.

* fix(types): correct LuaLS annotations in oauth and gcal
This commit is contained in:
Barrett Ruth 2026-03-05 11:50:13 -05:00
parent 6910bdb1be
commit ee362f7785
13 changed files with 319 additions and 291 deletions

View file

@ -7,7 +7,6 @@
---@field category string
---@class pending.GcalConfig
---@field calendar? string
---@field credentials_path? string
---@field client_id? string
---@field client_secret? string

View file

@ -25,13 +25,6 @@ function M.check()
vim.health.info('(project-local store; global path: ' .. cfg.data_path .. ')')
end
local data_dir = vim.fn.fnamemodify(resolved_path, ':h')
if vim.fn.isdirectory(data_dir) == 1 then
vim.health.ok('Data directory exists: ' .. data_dir)
else
vim.health.warn('Data directory does not exist yet: ' .. data_dir)
end
if vim.fn.filereadable(resolved_path) == 1 then
local s = store.new(resolved_path)
local load_ok, err = pcall(function()
@ -54,8 +47,6 @@ function M.check()
else
vim.health.error('Failed to load data file: ' .. tostring(err))
end
else
vim.health.info('No data file yet (will be created on first save)')
end
local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)

View file

@ -142,6 +142,16 @@ local function compute_hidden_ids(tasks, predicates)
visible = false
break
end
elseif pred == 'done' then
if task.status ~= 'done' then
visible = false
break
end
elseif pred == 'pending' then
if task.status ~= 'pending' then
visible = false
break
end
end
end
if not visible then
@ -536,12 +546,22 @@ end
---@param action? string
---@return nil
local function run_sync(backend_name, action)
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 not action or action == '' then
local actions = {}
for k, v in pairs(backend) do
if type(v) == 'function' and k:sub(1, 1) ~= '_' then
table.insert(actions, k)
end
end
table.sort(actions)
vim.notify(backend_name .. ' actions: ' .. table.concat(actions, ', '), vim.log.levels.INFO)
return
end
if type(backend[action]) ~= 'function' then
vim.notify(backend_name .. " backend has no '" .. action .. "' action", vim.log.levels.ERROR)
return
@ -804,7 +824,7 @@ function M.edit(id_str, rest)
s:update(id, updates)
s:save()
_save_and_notify()
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
@ -841,7 +861,7 @@ function M.command(args)
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
M.edit(id_str, edit_rest)
elseif SYNC_BACKEND_SET[cmd] then
local action = rest:match('^(%S+)') or 'sync'
local action = rest:match('^(%S+)')
run_sync(cmd, action)
elseif cmd == 'archive' then
local d = rest ~= '' and tonumber(rest) or nil

View file

@ -15,13 +15,10 @@ local client = oauth.new({
config_key = 'gcal',
})
---@return string? calendar_id
---@param access_token string
---@return table<string, string>? name_to_id
---@return string? err
local function find_or_create_calendar(access_token)
local cfg = config.get()
local gc = (cfg.sync and cfg.sync.gcal) or {}
local cal_name = gc.calendar or 'Pendings'
local function get_all_calendars(access_token)
local data, err = oauth.curl_request(
'GET',
BASE_URL .. '/users/me/calendarList',
@ -30,27 +27,41 @@ local function find_or_create_calendar(access_token)
if err then
return nil, err
end
local result = {}
for _, item in ipairs(data and data.items or {}) do
if item.summary == cal_name then
return item.id, nil
if item.summary then
result[item.summary] = item.id
end
end
return result, nil
end
local body = vim.json.encode({ summary = cal_name })
local created, create_err =
oauth.curl_request('POST', BASE_URL .. '/calendars', oauth.auth_headers(access_token), body)
if create_err then
return nil, create_err
---@param access_token string
---@param name string
---@param existing table<string, string>
---@return string? calendar_id
---@return string? err
local function find_or_create_calendar(access_token, name, existing)
if existing[name] then
return existing[name], nil
end
return created and created.id, nil
local body = vim.json.encode({ summary = name })
local created, err =
oauth.curl_request('POST', BASE_URL .. '/calendars', oauth.auth_headers(access_token), body)
if err then
return nil, err
end
local id = created and created.id
if id then
existing[name] = id
end
return id, nil
end
---@param date_str string
---@return string
local function next_day(date_str)
local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)')
local t = os.time({ year = tonumber(y) or 0, month = tonumber(m) or 0, day = tonumber(d) or 0 })
+ 86400
return os.date('%Y-%m-%d', t) --[[@as string]]
@ -128,74 +139,100 @@ function M.auth()
client:auth()
end
function M.sync()
local access_token = client:get_access_token()
if not access_token then
return
end
function M.push()
oauth.async(function()
local access_token = client:get_access_token()
if not access_token then
return
end
local calendar_id, err = find_or_create_calendar(access_token)
if err or not calendar_id then
vim.notify('pending.nvim: ' .. (err or 'calendar not found'), vim.log.levels.ERROR)
return
end
local calendars, cal_err = get_all_calendars(access_token)
if cal_err or not calendars then
vim.notify('pending.nvim: ' .. (cal_err or 'failed to fetch calendars'), vim.log.levels.ERROR)
return
end
local tasks = require('pending').store():tasks()
local created, updated, deleted = 0, 0, 0
local s = require('pending').store()
local created, updated, deleted = 0, 0, 0
for _, task in ipairs(tasks) do
local extra = task._extra or {}
local event_id = extra['_gcal_event_id'] --[[@as string?]]
for _, task in ipairs(s:tasks()) do
local extra = task._extra or {}
local event_id = extra['_gcal_event_id'] --[[@as string?]]
local cal_id = extra['_gcal_calendar_id'] --[[@as string?]]
local should_delete = event_id ~= nil
and (
task.status == 'done'
or task.status == 'deleted'
or (task.status == 'pending' and not task.due)
)
local should_delete = event_id ~= nil
and cal_id ~= nil
and (
task.status == 'done'
or task.status == 'deleted'
or (task.status == 'pending' and not task.due)
)
if should_delete and event_id then
local del_err = delete_event(access_token, calendar_id, event_id) --[[@as string]]
if not del_err then
extra['_gcal_event_id'] = nil
if next(extra) == nil then
task._extra = nil
if should_delete then
local del_err =
delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]])
if del_err then
vim.notify('pending.nvim: gcal delete failed: ' .. del_err, vim.log.levels.WARN)
else
task._extra = extra
end
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
deleted = deleted + 1
end
elseif task.status == 'pending' and task.due then
if event_id then
local upd_err = update_event(access_token, calendar_id, event_id, task)
if not upd_err then
updated = updated + 1
end
else
local new_id, create_err = create_event(access_token, calendar_id, task)
if not create_err and new_id then
if not task._extra then
task._extra = {}
extra['_gcal_event_id'] = nil
extra['_gcal_calendar_id'] = nil
if next(extra) == nil then
task._extra = nil
else
task._extra = extra
end
task._extra['_gcal_event_id'] = new_id
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
created = created + 1
deleted = deleted + 1
end
elseif task.status == 'pending' and task.due then
local cat = task.category or config.get().default_category
if event_id and cal_id then
local upd_err = update_event(access_token, cal_id, event_id, task)
if upd_err then
vim.notify('pending.nvim: gcal update failed: ' .. upd_err, vim.log.levels.WARN)
else
updated = updated + 1
end
else
local lid, lid_err = find_or_create_calendar(access_token, cat, calendars)
if lid_err or not lid then
vim.notify(
'pending.nvim: gcal calendar failed: ' .. (lid_err or 'unknown'),
vim.log.levels.WARN
)
else
local new_id, create_err = create_event(access_token, lid, task)
if create_err then
vim.notify('pending.nvim: gcal create failed: ' .. create_err, vim.log.levels.WARN)
elseif new_id then
if not task._extra then
task._extra = {}
end
task._extra['_gcal_event_id'] = new_id
task._extra['_gcal_calendar_id'] = lid
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
created = created + 1
end
end
end
end
end
end
require('pending').store():save()
require('pending')._recompute_counts()
vim.notify(
string.format(
'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)',
created,
updated,
deleted
s:save()
require('pending')._recompute_counts()
local buffer = require('pending.buffer')
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
buffer.render(buffer.bufnr())
end
vim.notify(
string.format(
'pending.nvim: Google Calendar pushed — +%d ~%d -%d',
created,
updated,
deleted
)
)
)
end)
end
---@return nil

View file

@ -247,7 +247,9 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
if task.status == 'deleted' and gtid and list_id then
local err = delete_gtask(access_token, list_id, gtid)
if not err then
if err then
vim.notify('pending.nvim: gtasks delete failed: ' .. err, vim.log.levels.WARN)
else
if not task._extra then
task._extra = {}
end
@ -262,7 +264,9 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
elseif task.status ~= 'deleted' then
if gtid and list_id then
local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task))
if not err then
if err then
vim.notify('pending.nvim: gtasks update failed: ' .. err, vim.log.levels.WARN)
else
updated = updated + 1
end
elseif task.status == 'pending' then
@ -270,7 +274,9 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local lid, err = find_or_create_tasklist(access_token, cat, tasklists)
if not err and lid then
local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task))
if not create_err and new_id then
if create_err then
vim.notify('pending.nvim: gtasks create failed: ' .. create_err, vim.log.levels.WARN)
elseif new_id then
if not task._extra then
task._extra = {}
end
@ -357,61 +363,79 @@ function M.auth()
end
function M.push()
local access_token, tasklists, s, now_ts = sync_setup()
if not access_token then
return
end
---@cast tasklists table<string, string>
---@cast s pending.Store
---@cast now_ts string
local by_gtasks_id = build_id_index(s)
local created, updated, deleted = push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
s:save()
require('pending')._recompute_counts()
vim.notify(
string.format('pending.nvim: Google Tasks pushed — +%d ~%d -%d', created, updated, deleted)
)
oauth.async(function()
local access_token, tasklists, s, now_ts = sync_setup()
if not access_token then
return
end
---@cast tasklists table<string, string>
---@cast s pending.Store
---@cast now_ts string
local by_gtasks_id = build_id_index(s)
local created, updated, deleted = push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
s:save()
require('pending')._recompute_counts()
local buffer = require('pending.buffer')
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
buffer.render(buffer.bufnr())
end
vim.notify(
string.format('pending.nvim: Google Tasks pushed — +%d ~%d -%d', created, updated, deleted)
)
end)
end
function M.pull()
local access_token, tasklists, s, now_ts = sync_setup()
if not access_token then
return
end
---@cast tasklists table<string, string>
---@cast s pending.Store
---@cast now_ts string
local by_gtasks_id = build_id_index(s)
local created, updated = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
s:save()
require('pending')._recompute_counts()
vim.notify(string.format('pending.nvim: Google Tasks pulled — +%d ~%d', created, updated))
oauth.async(function()
local access_token, tasklists, s, now_ts = sync_setup()
if not access_token then
return
end
---@cast tasklists table<string, string>
---@cast s pending.Store
---@cast now_ts string
local by_gtasks_id = build_id_index(s)
local created, updated = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
s:save()
require('pending')._recompute_counts()
local buffer = require('pending.buffer')
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
buffer.render(buffer.bufnr())
end
vim.notify(string.format('pending.nvim: Google Tasks pulled — +%d ~%d', created, updated))
end)
end
function M.sync()
local access_token, tasklists, s, now_ts = sync_setup()
if not access_token then
return
end
---@cast tasklists table<string, string>
---@cast s pending.Store
---@cast now_ts string
local by_gtasks_id = build_id_index(s)
local pushed_create, pushed_update, pushed_delete =
push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local pulled_create, pulled_update = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
s:save()
require('pending')._recompute_counts()
vim.notify(
string.format(
'pending.nvim: Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d',
pushed_create,
pushed_update,
pushed_delete,
pulled_create,
pulled_update
oauth.async(function()
local access_token, tasklists, s, now_ts = sync_setup()
if not access_token then
return
end
---@cast tasklists table<string, string>
---@cast s pending.Store
---@cast now_ts string
local by_gtasks_id = build_id_index(s)
local pushed_create, pushed_update, pushed_delete =
push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local pulled_create, pulled_update = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
s:save()
require('pending')._recompute_counts()
local buffer = require('pending.buffer')
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
buffer.render(buffer.bufnr())
end
vim.notify(
string.format(
'pending.nvim: Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d',
pushed_create,
pushed_update,
pushed_delete,
pulled_create,
pulled_update
)
)
)
end)
end
M._due_to_rfc3339 = due_to_rfc3339

View file

@ -27,6 +27,27 @@ OAuthClient.__index = OAuthClient
---@class pending.oauth
local M = {}
---@param args string[]
---@param opts? table
---@return { code: integer, stdout: string, stderr: string }
function M.system(args, opts)
local co = coroutine.running()
if not co then
return vim.system(args, opts or {}):wait() --[[@as { code: integer, stdout: string, stderr: string }]]
end
vim.system(args, opts or {}, function(result)
vim.schedule(function()
coroutine.resume(co, result)
end)
end)
return coroutine.yield() --[[@as { code: integer, stdout: string, stderr: string }]]
end
---@param fn fun(): nil
function M.async(fn)
coroutine.resume(coroutine.create(fn))
end
---@param str string
---@return string
function M.url_encode(str)
@ -91,7 +112,7 @@ function M.curl_request(method, url, headers, body)
table.insert(args, body)
end
table.insert(args, url)
local result = vim.system(args, { text = true }):wait()
local result = M.system(args, { text = true })
if result.code ~= 0 then
return nil, 'curl failed: ' .. (result.stderr or '')
end
@ -125,11 +146,6 @@ function M.health(backend_name)
else
vim.health.warn('curl not found (needed for ' .. backend_name .. ' sync)')
end
if vim.fn.executable('openssl') == 1 then
vim.health.ok('openssl found (required for ' .. backend_name .. ' OAuth PKCE)')
else
vim.health.warn('openssl not found (needed for ' .. backend_name .. ' OAuth)')
end
end
---@return string
@ -189,19 +205,17 @@ function OAuthClient:refresh_access_token(creds, tokens)
.. '&grant_type=refresh_token'
.. '&refresh_token='
.. M.url_encode(tokens.refresh_token)
local result = vim
.system({
'curl',
'-s',
'-X',
'POST',
'-H',
'Content-Type: application/x-www-form-urlencoded',
'-d',
body,
TOKEN_URL,
}, { text = true })
:wait()
local result = M.system({
'curl',
'-s',
'-X',
'POST',
'-H',
'Content-Type: application/x-www-form-urlencoded',
'-d',
body,
TOKEN_URL,
}, { text = true })
if result.code ~= 0 then
return nil
end
@ -247,23 +261,18 @@ function OAuthClient:auth()
local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
local verifier = {}
math.randomseed(os.time())
math.randomseed(vim.uv.hrtime())
for _ = 1, 64 do
local idx = math.random(1, #verifier_chars)
table.insert(verifier, verifier_chars:sub(idx, idx))
end
local code_verifier = table.concat(verifier)
local sha_pipe = vim
.system({
'sh',
'-c',
'printf "%s" "'
.. code_verifier
.. '" | openssl dgst -sha256 -binary | openssl base64 -A | tr "+/" "-_" | tr -d "="',
}, { text = true })
:wait()
local code_challenge = sha_pipe.stdout or ''
local hex = vim.fn.sha256(code_verifier)
local binary = hex:gsub('..', function(h)
return string.char(tonumber(h, 16))
end)
local code_challenge = vim.base64.encode(binary):gsub('+', '-'):gsub('/', '_'):gsub('=', '')
local auth_url = AUTH_URL
.. '?client_id='
@ -283,6 +292,15 @@ function OAuthClient:auth()
vim.notify('pending.nvim: Opening browser for Google authorization...')
local server = vim.uv.new_tcp()
local server_closed = false
local function close_server()
if server_closed then
return
end
server_closed = true
server:close()
end
server:bind('127.0.0.1', port)
server:listen(1, function(err)
if err then
@ -292,6 +310,8 @@ function OAuthClient:auth()
server:accept(conn)
conn:read_start(function(read_err, data)
if read_err or not data then
conn:close()
close_server()
return
end
local code = data:match('[?&]code=([^&%s]+)')
@ -305,7 +325,7 @@ function OAuthClient:auth()
conn:close()
end)
end)
server:close()
close_server()
if code then
vim.schedule(function()
self:_exchange_code(creds, code, code_verifier, port)
@ -313,6 +333,13 @@ function OAuthClient:auth()
end
end)
end)
vim.defer_fn(function()
if not server_closed then
close_server()
vim.notify('pending.nvim: OAuth callback timed out (120s).', vim.log.levels.WARN)
end
end, 120000)
end
---@param creds pending.OAuthCredentials
@ -333,19 +360,17 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port)
.. '&redirect_uri='
.. M.url_encode('http://127.0.0.1:' .. port)
local result = vim
.system({
'curl',
'-s',
'-X',
'POST',
'-H',
'Content-Type: application/x-www-form-urlencoded',
'-d',
body,
TOKEN_URL,
}, { text = true })
:wait()
local result = M.system({
'curl',
'-s',
'-X',
'POST',
'-H',
'Content-Type: application/x-www-form-urlencoded',
'-d',
body,
TOKEN_URL,
}, { text = true })
if result.code ~= 0 then
vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR)