Problem: :Pending sync hardcodes Google Calendar — M.sync() does
pcall(require, 'pending.sync.gcal') and calls gcal.sync() directly.
The config has a flat gcal field. This prevents adding new sync backends
without modifying init.lua.
Solution: Define a backend interface contract (name, auth, sync, health
fields), refactor :Pending sync to dispatch via require('pending.sync.'
.. backend_name), add sync table to config with legacy gcal migration,
rename gcal.authorize to gcal.auth, add gcal.health for checkhealth,
and add tab completion for backend names and actions.
533 lines
14 KiB
Lua
533 lines
14 KiB
Lua
local config = require('pending.config')
|
|
local store = require('pending.store')
|
|
|
|
local M = {}
|
|
|
|
M.name = 'gcal'
|
|
|
|
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.sync and cfg.sync.gcal) or 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.auth()
|
|
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.auth()
|
|
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 nil
|
|
function M.health()
|
|
if vim.fn.executable('curl') == 1 then
|
|
vim.health.ok('curl found (required for gcal sync)')
|
|
else
|
|
vim.health.warn('curl not found (needed for gcal sync)')
|
|
end
|
|
if vim.fn.executable('openssl') == 1 then
|
|
vim.health.ok('openssl found (required for gcal OAuth PKCE)')
|
|
else
|
|
vim.health.warn('openssl not found (needed for gcal OAuth)')
|
|
end
|
|
end
|
|
|
|
return M
|