Problem: gcal.lua had ~10 LuaLS errors from untyped credential and token tables, string|osdate casts, and untyped _gcal_event_id field access. Solution: add pending.GcalCredentials and pending.GcalTokens class definitions, annotate all local functions with @param/@return, add --[[@as string]] casts on os.date returns, and fix _gcal_event_id access to use bracket notation with casts.
514 lines
13 KiB
Lua
514 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 then
|
|
vim.notify('pending.nvim: ' .. err, 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()
|
|
vim.notify(
|
|
string.format(
|
|
'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)',
|
|
created,
|
|
updated,
|
|
deleted
|
|
)
|
|
)
|
|
end
|
|
|
|
return M
|