pending.nvim/lua/pending/sync/gcal.lua
Barrett Ruth cd1cd1afd4 feat: statusline API, counts, and PendingStatusChanged event (#40)
Problem: no way to know about overdue or due-today tasks without
opening :Pending. No ambient awareness for statusline plugins.

Solution: add counts(), statusline(), and has_due() public API
functions backed by a module-local cache that recomputes after every
store.save() and store.load(). Fire a User PendingStatusChanged event
on every recompute. Extract is_overdue() and is_today() from duplicate
locals into parse.lua as public functions. Refactor views.lua and
init.lua to use the shared date logic. Add vimdoc API section and
integration recipes for lualine, heirline, manual statusline, startup
notification, and event-driven refresh.
2026-02-26 16:30:06 -05:00

517 lines
13 KiB
Lua

local config = require('pending.config')
local store = require('pending.store')
local M = {}
local BASE_URL = 'https://www.googleapis.com/calendar/v3'
local TOKEN_URL = 'https://oauth2.googleapis.com/token'
local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
local SCOPE = 'https://www.googleapis.com/auth/calendar'
---@class pending.GcalCredentials
---@field client_id string
---@field client_secret string
---@field redirect_uris? string[]
---@class pending.GcalTokens
---@field access_token string
---@field refresh_token string
---@field expires_in? integer
---@field obtained_at? integer
---@return table<string, any>
local function gcal_config()
local cfg = config.get()
return cfg.gcal or {}
end
---@return string
local function token_path()
return vim.fn.stdpath('data') .. '/pending/gcal_tokens.json'
end
---@return string
local function credentials_path()
local gc = gcal_config()
return gc.credentials_path or (vim.fn.stdpath('data') .. '/pending/gcal_credentials.json')
end
---@param path string
---@return table?
local function load_json_file(path)
local f = io.open(path, 'r')
if not f then
return nil
end
local content = f:read('*a')
f:close()
if content == '' then
return nil
end
local ok, decoded = pcall(vim.json.decode, content)
if not ok then
return nil
end
return decoded
end
---@param path string
---@param data table
---@return boolean
local function save_json_file(path, data)
local dir = vim.fn.fnamemodify(path, ':h')
if vim.fn.isdirectory(dir) == 0 then
vim.fn.mkdir(dir, 'p')
end
local f = io.open(path, 'w')
if not f then
return false
end
f:write(vim.json.encode(data))
f:close()
vim.fn.setfperm(path, 'rw-------')
return true
end
---@return pending.GcalCredentials?
local function load_credentials()
local creds = load_json_file(credentials_path())
if not creds then
return nil
end
if creds.installed then
return creds.installed --[[@as pending.GcalCredentials]]
end
return creds --[[@as pending.GcalCredentials]]
end
---@return pending.GcalTokens?
local function load_tokens()
return load_json_file(token_path()) --[[@as pending.GcalTokens?]]
end
---@param tokens pending.GcalTokens
---@return boolean
local function save_tokens(tokens)
return save_json_file(token_path(), tokens)
end
---@param str string
---@return string
local function url_encode(str)
return (
str:gsub('([^%w%-%.%_%~])', function(c)
return string.format('%%%02X', string.byte(c))
end)
)
end
---@param method string
---@param url string
---@param headers? string[]
---@param body? string
---@return table? result
---@return string? err
local function curl_request(method, url, headers, body)
local args = { 'curl', '-s', '-X', method }
for _, h in ipairs(headers or {}) do
table.insert(args, '-H')
table.insert(args, h)
end
if body then
table.insert(args, '-d')
table.insert(args, body)
end
table.insert(args, url)
local result = vim.system(args, { text = true }):wait()
if result.code ~= 0 then
return nil, 'curl failed: ' .. (result.stderr or '')
end
if not result.stdout or result.stdout == '' then
return {}, nil
end
local ok, decoded = pcall(vim.json.decode, result.stdout)
if not ok then
return nil, 'failed to parse response: ' .. result.stdout
end
if decoded.error then
return nil, 'API error: ' .. (decoded.error.message or vim.json.encode(decoded.error))
end
return decoded, nil
end
---@param access_token string
---@return string[]
local function auth_headers(access_token)
return {
'Authorization: Bearer ' .. access_token,
'Content-Type: application/json',
}
end
---@param creds pending.GcalCredentials
---@param tokens pending.GcalTokens
---@return pending.GcalTokens?
local function refresh_access_token(creds, tokens)
local body = 'client_id='
.. url_encode(creds.client_id)
.. '&client_secret='
.. url_encode(creds.client_secret)
.. '&grant_type=refresh_token'
.. '&refresh_token='
.. 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()
if result.code ~= 0 then
return nil
end
local ok, decoded = pcall(vim.json.decode, result.stdout or '')
if not ok or not decoded.access_token then
return nil
end
tokens.access_token = decoded.access_token --[[@as string]]
tokens.expires_in = decoded.expires_in --[[@as integer?]]
tokens.obtained_at = os.time()
save_tokens(tokens)
return tokens
end
---@return string?
local function get_access_token()
local creds = load_credentials()
if not creds then
vim.notify(
'pending.nvim: No Google Calendar credentials found at ' .. credentials_path(),
vim.log.levels.ERROR
)
return nil
end
local tokens = load_tokens()
if not tokens or not tokens.refresh_token then
M.authorize()
tokens = load_tokens()
if not tokens then
return nil
end
end
local now = os.time()
local obtained = tokens.obtained_at or 0
local expires = tokens.expires_in or 3600
if now - obtained > expires - 60 then
tokens = refresh_access_token(creds, tokens)
if not tokens then
vim.notify('pending.nvim: Failed to refresh access token.', vim.log.levels.ERROR)
return nil
end
end
return tokens.access_token
end
function M.authorize()
local creds = load_credentials()
if not creds then
vim.notify(
'pending.nvim: No Google Calendar credentials found at ' .. credentials_path(),
vim.log.levels.ERROR
)
return
end
local port = 18392
local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
local verifier = {}
math.randomseed(os.time())
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 auth_url = AUTH_URL
.. '?client_id='
.. url_encode(creds.client_id)
.. '&redirect_uri='
.. url_encode('http://127.0.0.1:' .. port)
.. '&response_type=code'
.. '&scope='
.. url_encode(SCOPE)
.. '&access_type=offline'
.. '&prompt=consent'
.. '&code_challenge='
.. url_encode(code_challenge)
.. '&code_challenge_method=S256'
vim.ui.open(auth_url)
vim.notify('pending.nvim: Opening browser for Google authorization...')
local server = vim.uv.new_tcp()
server:bind('127.0.0.1', port)
server:listen(1, function(err)
if err then
return
end
local client = vim.uv.new_tcp()
server:accept(client)
client:read_start(function(read_err, data)
if read_err or not data then
return
end
local code = data:match('[?&]code=([^&%s]+)')
local response_body = code
and '<html><body><h1>Authorization successful</h1><p>You can close this tab.</p></body></html>'
or '<html><body><h1>Authorization failed</h1></body></html>'
local http_response = 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n'
.. response_body
client:write(http_response, function()
client:shutdown(function()
client:close()
end)
end)
server:close()
if code then
vim.schedule(function()
M._exchange_code(creds, code, code_verifier, port)
end)
end
end)
end)
end
---@param creds pending.GcalCredentials
---@param code string
---@param code_verifier string
---@param port integer
function M._exchange_code(creds, code, code_verifier, port)
local body = 'client_id='
.. url_encode(creds.client_id)
.. '&client_secret='
.. url_encode(creds.client_secret)
.. '&code='
.. url_encode(code)
.. '&code_verifier='
.. url_encode(code_verifier)
.. '&grant_type=authorization_code'
.. '&redirect_uri='
.. 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()
if result.code ~= 0 then
vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR)
return
end
local ok, decoded = pcall(vim.json.decode, result.stdout or '')
if not ok or not decoded.access_token then
vim.notify('pending.nvim: Invalid token response.', vim.log.levels.ERROR)
return
end
decoded.obtained_at = os.time()
save_tokens(decoded)
vim.notify('pending.nvim: Google Calendar authorized successfully.')
end
---@param access_token string
---@return string? calendar_id
---@return string? err
local function find_or_create_calendar(access_token)
local gc = gcal_config()
local cal_name = gc.calendar or 'Pendings'
local data, err =
curl_request('GET', BASE_URL .. '/users/me/calendarList', auth_headers(access_token))
if err then
return nil, err
end
for _, item in ipairs(data and data.items or {}) do
if item.summary == cal_name then
return item.id, nil
end
end
local body = vim.json.encode({ summary = cal_name })
local created, create_err =
curl_request('POST', BASE_URL .. '/calendars', auth_headers(access_token), body)
if create_err then
return nil, create_err
end
return created and created.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 = curl_request(
'POST',
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events',
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 = curl_request(
'PATCH',
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id),
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 = curl_request(
'DELETE',
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id),
auth_headers(access_token)
)
return err
end
function M.sync()
local access_token = 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 tasks = store.tasks()
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?]]
local should_delete = event_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
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 = {}
end
task._extra['_gcal_event_id'] = new_id
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
created = created + 1
end
end
end
end
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
)
)
end
return M