From 9eb29f8fe197bda79ef7b61ffd1fd06ec5ef21ce Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:50 -0500 Subject: [PATCH] feat(gcal): add one-way Google Calendar sync Problem: need to push tasks with due dates to Google Calendar as all-day events. Solution: add gcal module with OAuth 2.0 loopback flow, PKCE, token refresh, calendar creation, and event CRUD. Maps pending tasks to all-day events, deletes events on completion, and stores event IDs in task _extra for round-trip. --- lua/todo/sync/gcal.lua | 462 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 lua/todo/sync/gcal.lua diff --git a/lua/todo/sync/gcal.lua b/lua/todo/sync/gcal.lua new file mode 100644 index 0000000..215294c --- /dev/null +++ b/lua/todo/sync/gcal.lua @@ -0,0 +1,462 @@ +local config = require('todo.config') +local store = require('todo.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' + +local function gcal_config() + local cfg = config.get() + return cfg.gcal or {} +end + +local function token_path() + return vim.fn.stdpath('data') .. '/todo/gcal_tokens.json' +end + +local function credentials_path() + local gc = gcal_config() + return gc.credentials_path or (vim.fn.stdpath('data') .. '/todo/gcal_credentials.json') +end + +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 + +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 + +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 + end + return creds +end + +local function load_tokens() + return load_json_file(token_path()) +end + +local function save_tokens(tokens) + return save_json_file(token_path(), tokens) +end + +local function url_encode(str) + return str:gsub('([^%w%-%.%_%~])', function(c) + return string.format('%%%02X', string.byte(c)) + end) +end + +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 + +local function auth_headers(access_token) + return { + 'Authorization: Bearer ' .. access_token, + 'Content-Type: application/json', + } +end + +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 + tokens.expires_in = decoded.expires_in + tokens.obtained_at = os.time() + save_tokens(tokens) + return tokens +end + +local function get_access_token() + local creds = load_credentials() + if not creds then + vim.notify( + 'todo.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('todo.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( + 'todo.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_result = vim.system({ 'printf', '%s', code_verifier }, { text = true }):wait() + 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('todo.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 '

Authorization successful

You can close this tab.

' + or '

Authorization failed

' + 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 + +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('todo.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('todo.nvim: Invalid token response.', vim.log.levels.ERROR) + return + end + + decoded.obtained_at = os.time() + save_tokens(decoded) + vim.notify('todo.nvim: Google Calendar authorized successfully.') +end + +local function find_or_create_calendar(access_token) + local gc = gcal_config() + local cal_name = gc.calendar or 'Todos' + + 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.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.id, nil +end + +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), month = tonumber(m), day = tonumber(d) }) + 86400 + return os.date('%Y-%m-%d', t) +end + +local function create_event(access_token, calendar_id, task) + local event = { + summary = task.description, + start = { date = task.due }, + ['end'] = { date = next_day(task.due) }, + 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.id, nil +end + +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) }, + } + 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 + +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('todo.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 + + if (task.status == 'done' or task.status == 'deleted') and event_id then + local del_err = delete_event(access_token, calendar_id, event_id) + 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') + 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') + created = created + 1 + end + end + elseif task.status == 'pending' and not task.due and event_id then + local del_err = delete_event(access_token, calendar_id, event_id) + 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') + deleted = deleted + 1 + end + end + end + + store.save() + vim.notify( + string.format( + 'todo.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)', + created, + updated, + deleted + ) + ) +end + +return M