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

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
doc/tags
*.log
minimal_init.lua
.*cache*
CLAUDE.md

View file

@ -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

View file

@ -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/<name>.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.<name>` 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*

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)

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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()