From 84e4a45911cd69747d2ee6848a162c29d95c57d4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 11:18:36 -0500 Subject: [PATCH 1/8] 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. --- lua/pending/sync/oauth.lua | 113 ++++++++++++++++++++++--------------- 1 file changed, 69 insertions(+), 44 deletions(-) diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 7dc5ede..022a9c7 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -27,6 +27,27 @@ OAuthClient.__index = OAuthClient ---@class pending.oauth local M = {} +---@param args string[] +---@param opts? table +---@return vim.SystemCompleted +function M.system(args, opts) + local co = coroutine.running() + if not co then + return vim.system(args, opts or {}):wait() + end + vim.system(args, opts or {}, function(result) + vim.schedule(function() + coroutine.resume(co, result) + end) + end) + return coroutine.yield() +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) From 59d2950fda47f5d53ff6182b0b83b571d6d58e28 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 11:18:41 -0500 Subject: [PATCH 2/8] 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. --- lua/pending/sync/gtasks.lua | 128 ++++++++++++++++++++++-------------- 1 file changed, 77 insertions(+), 51 deletions(-) diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index f31de99..531ecb4 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -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,81 @@ 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 - ---@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 + ---@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 - ---@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 + ---@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 - ---@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 + ---@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 From ca61db712731b50bb5af331e7629209da230becf Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 11:18:46 -0500 Subject: [PATCH 3/8] 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. --- lua/pending/init.lua | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index f4f7264..983c9bf 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -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,25 @@ 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 +827,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 +864,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 From 765d7fa0b5b896726651ec87564abcf48a5a5c78 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 11:24:29 -0500 Subject: [PATCH 4/8] 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. --- lua/pending/sync/gcal.lua | 180 +++++++++++++++++++++++--------------- 1 file changed, 108 insertions(+), 72 deletions(-) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 9158ca1..2eabc3e 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -15,13 +15,10 @@ local client = oauth.new({ config_key = 'gcal', }) ----@return string? calendar_id +---@param access_token string +---@return table? 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 +---@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,99 @@ 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, event_id) + 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 From 9a762a5320eeca6aa344e6f5e76debf83a5da6de Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 11:24:36 -0500 Subject: [PATCH 5/8] 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. --- lua/pending/config.lua | 1 - lua/pending/health.lua | 9 --------- lua/pending/init.lua | 5 +---- lua/pending/sync/gtasks.lua | 6 ++---- plugin/pending.lua | 2 +- 5 files changed, 4 insertions(+), 19 deletions(-) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 263cc8c..f488e41 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -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 diff --git a/lua/pending/health.lua b/lua/pending/health.lua index 0f1bad8..d3dbe2c 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -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) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 983c9bf..a83692d 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -559,10 +559,7 @@ local function run_sync(backend_name, action) end end table.sort(actions) - vim.notify( - backend_name .. ' actions: ' .. table.concat(actions, ', '), - vim.log.levels.INFO - ) + vim.notify(backend_name .. ' actions: ' .. table.concat(actions, ', '), vim.log.levels.INFO) return end if type(backend[action]) ~= 'function' then diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index 531ecb4..a046a51 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -372,8 +372,7 @@ function M.push() ---@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) + 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') @@ -419,8 +418,7 @@ function M.sync() 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) + 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') diff --git a/plugin/pending.lua b/plugin/pending.lua index 13f16d3..162dfd7 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -181,7 +181,7 @@ end, { for word in after_filter:gmatch('%S+') do used[word] = true end - local candidates = { 'clear', 'overdue', 'today', 'priority' } + local candidates = { 'clear', 'overdue', 'today', 'priority', 'done', 'pending' } local store = require('pending.store') local s = store.new(store.resolve_path()) s:load() From bc2a10661783bbe38fe46c07559fc28bd5e94692 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 11:24:43 -0500 Subject: [PATCH 6/8] 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. --- .gitignore | 1 + README.md | 2 +- doc/pending.txt | 57 +++++++++++++++++-------------------------- scripts/demo-init.lua | 30 ----------------------- scripts/demo.tape | 28 --------------------- spec/sync_spec.lua | 34 +++++++++++++------------- 6 files changed, 42 insertions(+), 110 deletions(-) delete mode 100644 scripts/demo-init.lua delete mode 100644 scripts/demo.tape diff --git a/.gitignore b/.gitignore index 93ac2c5..7cdfb66 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ doc/tags *.log +minimal_init.lua .*cache* CLAUDE.md diff --git a/README.md b/README.md index cb3d3eb..43c8447 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Edit tasks like text. `:w` saves them. ## Requirements - Neovim 0.10+ -- (Optionally) `curl` and `openssl` for Google Calendar and Google Task sync +- (Optionally) `curl` for Google Calendar and Google Task sync ## Installation diff --git a/doc/pending.txt b/doc/pending.txt index d3eb03b..914644e 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -73,7 +73,7 @@ REQUIREMENTS *pending-requirements* - Neovim 0.10+ - No external dependencies for local use -- `curl` and `openssl` are required for the `gcal` and `gtasks` sync backends +- `curl` is required for the `gcal` and `gtasks` sync backends ============================================================================== INSTALL *pending-install* @@ -149,17 +149,17 @@ COMMANDS *pending-commands* Open the list with |:copen| to navigate to each task's category. *:Pending-gtasks* -:Pending gtasks [{action}] - Run a Google Tasks sync action. {action} defaults to `sync` when omitted. +:Pending gtasks {action} + Run a Google Tasks action. An explicit action is required. Actions: ~ - `sync` Push local changes then pull remote changes (default). + `sync` Push local changes then pull remote changes. `push` Push local changes to Google Tasks only. `pull` Pull remote changes from Google Tasks only. `auth` Run the OAuth authorization flow. Examples: >vim - :Pending gtasks " push then pull (default) + :Pending gtasks sync " push then pull :Pending gtasks push " push local → Google Tasks :Pending gtasks pull " pull Google Tasks → local :Pending gtasks auth " authorize @@ -169,16 +169,15 @@ COMMANDS *pending-commands* See |pending-gtasks| for full details. *:Pending-gcal* -:Pending gcal [{action}] - Run a Google Calendar sync action. {action} defaults to `sync` when - omitted. +:Pending gcal {action} + Run a Google Calendar action. An explicit action is required. Actions: ~ - `sync` Push tasks with due dates to Google Calendar (default). + `push` Push tasks with due dates to Google Calendar. `auth` Run the OAuth authorization flow. Examples: >vim - :Pending gcal " push to Google Calendar (default) + :Pending gcal push " push to Google Calendar :Pending gcal auth " authorize < @@ -606,9 +605,7 @@ loads: >lua prev_task = '[t', }, sync = { - gcal = { - calendar = 'Pendings', - }, + gcal = {}, gtasks = {}, }, } @@ -893,8 +890,8 @@ SYNC BACKENDS *pending-sync-backend* Sync backends are Lua modules under `lua/pending/sync/.lua`. Each backend is exposed as a top-level `:Pending` subcommand: >vim - :Pending gtasks [action] - :Pending gcal [action] + :Pending gtasks {action} + :Pending gcal {action} < Each module returns a table conforming to the backend interface: >lua @@ -902,9 +899,9 @@ 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 push? fun(): nil ---@field pull? fun(): nil + ---@field sync? fun(): nil ---@field health? fun(): nil < @@ -924,16 +921,15 @@ Backend-specific configuration goes under `sync.` in |pending-config|. ============================================================================== GOOGLE CALENDAR *pending-gcal* -pending.nvim can push tasks with due dates to a dedicated Google Calendar as -all-day events. This is a one-way push; changes made in Google Calendar are -not pulled back into pending.nvim. +pending.nvim can push tasks with due dates to Google Calendar as all-day +events. Each pending.nvim category maps to a Google Calendar of the same +name. Calendars are created automatically on first push. This is a one-way +push; changes made in Google Calendar are not pulled back. Configuration: >lua vim.g.pending = { sync = { - gcal = { - calendar = 'Pendings', - }, + gcal = {}, }, } < @@ -943,11 +939,6 @@ used by default. Run `:Pending gcal auth` and the browser opens immediately. *pending.GcalConfig* Fields: ~ - {calendar} (string, default: 'Pendings') - Name of the Google Calendar to sync to. If a calendar - with this name does not exist it is created - automatically on the first sync. - {client_id} (string, optional) OAuth client ID. When set together with {client_secret}, these take priority over the @@ -973,26 +964,24 @@ OAuth flow: ~ On the first `:Pending 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 — -`openssl` generates the code challenge. After the user grants consent, the +OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used. After +the user grants consent, the authorization code is exchanged for tokens and the refresh token is stored at `stdpath('data')/pending/gcal_tokens.json` with mode `600`. Subsequent syncs use the stored refresh token and refresh the access token automatically when it is about to expire. -`:Pending gcal` behavior: ~ +`:Pending gcal push` 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. + created in the calendar matching the task's category. The event ID and + calendar ID are stored in the task's `_extra` table. - A pending task with a due date and an existing event: the event summary and date are updated in place. - A done or deleted task with an existing event: the event is deleted. - A pending task with no due date that had an existing event: the event is deleted. -A summary notification is shown after sync: `created: N, updated: N, -deleted: N`. - ============================================================================== GOOGLE TASKS *pending-gtasks* diff --git a/scripts/demo-init.lua b/scripts/demo-init.lua deleted file mode 100644 index 57da080..0000000 --- a/scripts/demo-init.lua +++ /dev/null @@ -1,30 +0,0 @@ -vim.opt.runtimepath:prepend(vim.fn.getcwd()) -local tmpdir = vim.fn.tempname() -vim.fn.mkdir(tmpdir, 'p') - -vim.g.pending = { - data_path = tmpdir .. '/tasks.json', -} - -local store = require('pending.store') -store.load() - -local today = os.date('%Y-%m-%d') -local yesterday = os.date('%Y-%m-%d', os.time() - 86400) -local tomorrow = os.date('%Y-%m-%d', os.time() + 86400) - -store.add({ - description = 'Finish quarterly report', - category = 'Work', - due = tomorrow, - recur = 'monthly', - priority = 1, -}) -store.add({ description = 'Review pull requests', category = 'Work' }) -store.add({ description = 'Update deployment docs', category = 'Work', status = 'done' }) -store.add({ description = 'Buy groceries', category = 'Personal', due = today }) -store.add({ description = 'Call dentist', category = 'Personal', due = yesterday, priority = 1 }) -store.add({ description = 'Read chapter 5', category = 'Personal' }) -store.add({ description = 'Learn a new language', category = 'Someday' }) -store.add({ description = 'Plan hiking trip', category = 'Someday' }) -store.save() diff --git a/scripts/demo.tape b/scripts/demo.tape deleted file mode 100644 index 3a1eee5..0000000 --- a/scripts/demo.tape +++ /dev/null @@ -1,28 +0,0 @@ -Output assets/demo.gif - -Require nvim - -Set Shell "bash" -Set FontSize 14 -Set Width 900 -Set Height 450 - -Type "nvim -u scripts/demo-init.lua -c 'autocmd VimEnter * Pending'" -Enter - -Sleep 2s - -Down -Down -Sleep 300ms -Down -Sleep 300ms - -Enter -Sleep 500ms - -Tab -Sleep 1s - -Type "q" -Sleep 200ms diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua index ce38635..93d3e2c 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -49,27 +49,27 @@ describe('sync', function() 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 + it('lists actions when action is omitted', function() + local msg = nil + local orig = vim.notify + vim.notify = function(m) + msg = m end pending.command('gcal') - gcal.sync = orig_sync - assert.is_true(called) + vim.notify = orig + assert.is_not_nil(msg) + assert.is_truthy(msg:find('push')) end) - it('routes explicit sync action', function() + it('routes explicit push action', function() local called = false local gcal = require('pending.sync.gcal') - local orig_sync = gcal.sync - gcal.sync = function() + local orig_push = gcal.push + gcal.push = function() called = true end - pending.command('gcal sync') - gcal.sync = orig_sync + pending.command('gcal push') + gcal.push = orig_push assert.is_true(called) end) @@ -90,10 +90,10 @@ describe('sync', function() config.reset() vim.g.pending = { data_path = tmpdir .. '/tasks.json', - sync = { gcal = { calendar = 'NewStyle' } }, + sync = { gcal = { client_id = 'test-id' } }, } local cfg = config.get() - assert.are.equal('NewStyle', cfg.sync.gcal.calendar) + assert.are.equal('test-id', cfg.sync.gcal.client_id) end) describe('gcal module', function() @@ -107,9 +107,9 @@ describe('sync', function() assert.are.equal('function', type(gcal.auth)) end) - it('has sync function', function() + it('has push function', function() local gcal = require('pending.sync.gcal') - assert.are.equal('function', type(gcal.sync)) + assert.are.equal('function', type(gcal.push)) end) it('has health function', function() From 4187357a41a2430bb5b4c6ff0f0ddab52c0d4d5f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 11:28:29 -0500 Subject: [PATCH 7/8] fix(types): correct LuaLS annotations in oauth and gcal --- lua/pending/sync/gcal.lua | 3 ++- lua/pending/sync/oauth.lua | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 2eabc3e..44f7742 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -169,7 +169,8 @@ function M.push() ) if should_delete then - local del_err = delete_event(access_token, cal_id, event_id) + 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 diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 022a9c7..c53e3b1 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -29,18 +29,18 @@ local M = {} ---@param args string[] ---@param opts? table ----@return vim.SystemCompleted +---@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() + 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() + return coroutine.yield() --[[@as { code: integer, stdout: string, stderr: string }]] end ---@param fn fun(): nil From 87679e98571a8b125d7108201d436bcf7e97fb0a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 12:18:57 -0500 Subject: [PATCH 8/8] fix(diff): preserve due/rec when absent from buffer line Problem: `diff.apply` overwrites `task.due` and `task.recur` with `nil` whenever those fields aren't present as inline tokens in the buffer line. Because metadata is rendered as virtual text (never in the line text), every description edit silently clears due dates and recurrence rules. Solution: Only update `due`, `recur`, and `recur_mode` in the existing- task branch when the parsed entry actually contains them (non-nil). Users can still set/change these inline by typing `due:` or `rec:`; clearing them requires `:Pending edit -due`. --- lua/pending/diff.lua | 18 ++++++++++-------- spec/diff_spec.lua | 21 +++++++++++++++++---- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 7ebbfe1..5df332f 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -121,17 +121,19 @@ function M.apply(lines, s, hidden_ids) task.priority = entry.priority changed = true end - if task.due ~= entry.due then + if entry.due ~= nil and task.due ~= entry.due then task.due = entry.due changed = true end - if task.recur ~= entry.rec then - task.recur = entry.rec - changed = true - end - if task.recur_mode ~= entry.rec_mode then - task.recur_mode = entry.rec_mode - changed = true + if entry.rec ~= nil then + if task.recur ~= entry.rec then + task.recur = entry.rec + changed = true + end + if task.recur_mode ~= entry.rec_mode then + task.recur_mode = entry.rec_mode + changed = true + end end if entry.status and task.status ~= entry.status then task.status = entry.status diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index c2a0406..01d8aac 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -199,7 +199,7 @@ describe('diff', function() assert.are.equal(modified_after_first, task.modified) end) - it('clears due when removed from buffer line', function() + it('preserves due when not present in buffer line', function() s:add({ description = 'Pay bill', due = '2026-03-15' }) s:save() local lines = { @@ -209,7 +209,20 @@ describe('diff', function() diff.apply(lines, s) s:load() local task = s:get(1) - assert.is_nil(task.due) + assert.are.equal('2026-03-15', task.due) + end) + + it('updates due when inline token is present', function() + s:add({ description = 'Pay bill', due = '2026-03-15' }) + s:save() + local lines = { + '# Inbox', + '/1/- [ ] Pay bill due:2026-04-01', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('2026-04-01', task.due) end) it('stores recur field on new tasks from buffer', function() @@ -237,7 +250,7 @@ describe('diff', function() assert.are.equal('weekly', task.recur) end) - it('clears recur when token removed from line', function() + it('preserves recur when not present in buffer line', function() s:add({ description = 'Task', recur = 'daily' }) s:save() local lines = { @@ -247,7 +260,7 @@ describe('diff', function() diff.apply(lines, s) s:load() local task = s:get(1) - assert.is_nil(task.recur) + assert.are.equal('daily', task.recur) end) it('parses rec: with completion mode prefix', function()