pending.nvim/lua/pending/sync/gcal.lua
Barrett Ruth f78f8e42fa
feat(sync): interactive setup, auth continuation, and credential resolution fixes (#70)
* feat(sync): add `setup` command to configure credentials interactively

Problem: users had to manually create a JSON credentials file at the
correct path before authenticating, with no guidance from the plugin.

Solution: add `OAuthClient:setup()` that prompts for client ID and
secret via `vim.ui.input`, writes to the shared
`google_credentials.json`, then immediately starts the OAuth flow.
Expose as `:Pending {gtasks,gcal} setup`. Also extend
`resolve_credentials()` to fall back to a shared `google_credentials.json`
so one file covers both backends.

* fix(sync): improve `setup` input loop with validation and masking

Problem: `setup()` used async `vim.ui.input` for both prompts, causing
newline and re-prompt issues when validation failed. The secret was also
echoed in plain text.

Solution: switch to synchronous `vim.fn.input` / `vim.fn.inputsecret`
loops with `vim.cmd.redraw()` + `nvim_echo` for inline error display and
re-prompting. Validate client ID format and `GOCSPX-` secret prefix
before saving.

* fix(oauth): fix `ipairs` nil truncation in `resolve_credentials` and add file-path setup option

Problem: `resolve_credentials` built `cred_paths` with a potentially nil
first element (`credentials_path`), causing `ipairs` to stop immediately
and always fall through to bundled placeholder credentials.

Solution: build `cred_paths` without nil entries using `table.insert`.
Also add a `2. Load from JSON file path` option to `setup()` via
`vim.fn.inputlist`, with `vim.fn.expand` for `~`/`$HOME` support and
the `installed` wrapper unwrap.

* doc: cleanup

* ci: format
2026-03-05 15:29:32 -05:00

252 lines
6.8 KiB
Lua

local config = require('pending.config')
local log = require('pending.log')
local oauth = require('pending.sync.oauth')
local M = {}
M.name = 'gcal'
local BASE_URL = 'https://www.googleapis.com/calendar/v3'
local SCOPE = 'https://www.googleapis.com/auth/calendar'
local client = oauth.new({
name = 'gcal',
scope = SCOPE,
port = 18392,
config_key = 'gcal',
})
---@param access_token string
---@return table<string, string>? name_to_id
---@return string? err
local function get_all_calendars(access_token)
local data, err = oauth.curl_request(
'GET',
BASE_URL .. '/users/me/calendarList',
oauth.auth_headers(access_token)
)
if err then
return nil, err
end
local result = {}
for _, item in ipairs(data and data.items or {}) do
if item.summary then
result[item.summary] = item.id
end
end
return result, nil
end
---@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
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 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]]
end
---@param access_token string
---@param calendar_id string
---@param task pending.Task
---@return string? event_id
---@return string? err
local function create_event(access_token, calendar_id, task)
local event = {
summary = task.description,
start = { date = task.due },
['end'] = { date = next_day(task.due or '') },
transparency = 'transparent',
extendedProperties = {
private = { taskId = tostring(task.id) },
},
}
local data, err = oauth.curl_request(
'POST',
BASE_URL .. '/calendars/' .. oauth.url_encode(calendar_id) .. '/events',
oauth.auth_headers(access_token),
vim.json.encode(event)
)
if err then
return nil, err
end
return data and data.id, nil
end
---@param access_token string
---@param calendar_id string
---@param event_id string
---@param task pending.Task
---@return string? err
local function update_event(access_token, calendar_id, event_id, task)
local event = {
summary = task.description,
start = { date = task.due },
['end'] = { date = next_day(task.due or '') },
}
local _, err = oauth.curl_request(
'PATCH',
BASE_URL
.. '/calendars/'
.. oauth.url_encode(calendar_id)
.. '/events/'
.. oauth.url_encode(event_id),
oauth.auth_headers(access_token),
vim.json.encode(event)
)
return err
end
---@param access_token string
---@param calendar_id string
---@param event_id string
---@return string? err
local function delete_event(access_token, calendar_id, event_id)
local _, err = oauth.curl_request(
'DELETE',
BASE_URL
.. '/calendars/'
.. oauth.url_encode(calendar_id)
.. '/events/'
.. oauth.url_encode(event_id),
oauth.auth_headers(access_token)
)
return err
end
---@param callback fun(access_token: string): nil
local function with_token(callback)
oauth.async(function()
local token = client:get_access_token()
if not token then
client:auth(function()
oauth.async(function()
local fresh = client:get_access_token()
if fresh then
callback(fresh)
end
end)
end)
return
end
callback(token)
end)
end
function M.setup()
client:setup()
end
function M.auth()
client:auth()
end
function M.push()
with_token(function(access_token)
local calendars, cal_err = get_all_calendars(access_token)
if cal_err or not calendars then
log.error(cal_err or 'failed to fetch calendars')
return
end
local s = require('pending').store()
local created, updated, deleted = 0, 0, 0
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 cal_id ~= nil
and (
task.status == 'done'
or task.status == 'deleted'
or (task.status == 'pending' and not task.due)
)
if should_delete then
local del_err =
delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]])
if del_err then
log.warn('gcal delete failed: ' .. del_err)
else
extra['_gcal_event_id'] = nil
extra['_gcal_calendar_id'] = nil
if next(extra) == nil then
task._extra = nil
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
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
log.warn('gcal update failed: ' .. upd_err)
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
log.warn('gcal calendar failed: ' .. (lid_err or 'unknown'))
else
local new_id, create_err = create_event(access_token, lid, task)
if create_err then
log.warn('gcal create failed: ' .. create_err)
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
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
log.info(string.format('Google Calendar pushed — +%d ~%d -%d', created, updated, deleted))
end)
end
---@return nil
function M.health()
oauth.health(M.name)
end
return M