From ee362f7785504f31fc315685f0d77e62d97c6fdf Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:50:13 -0500 Subject: [PATCH] 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 --- .gitignore | 1 + README.md | 2 +- doc/pending.txt | 57 +++++------- lua/pending/config.lua | 1 - lua/pending/health.lua | 9 -- lua/pending/init.lua | 26 +++++- lua/pending/sync/gcal.lua | 181 ++++++++++++++++++++++-------------- lua/pending/sync/gtasks.lua | 126 +++++++++++++++---------- lua/pending/sync/oauth.lua | 113 +++++++++++++--------- plugin/pending.lua | 2 +- scripts/demo-init.lua | 30 ------ scripts/demo.tape | 28 ------ spec/sync_spec.lua | 34 +++---- 13 files changed, 319 insertions(+), 291 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/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 f4f7264..a83692d 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,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 diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 9158ca1..44f7742 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,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 diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index f31de99..a046a51 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,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 - ---@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 diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 7dc5ede..c53e3b1 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 { 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) 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() 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()