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' ---@param access_token string ---@return table? 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 ---@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 '') }, transparency = 'transparent', } 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 = oauth.google_client:get_access_token() if not token then oauth.google_client:auth(function() oauth.async(function() local fresh = oauth.google_client:get_access_token() if fresh then callback(fresh) else log.error(oauth.google_client.name .. ': authorization failed or was cancelled') end end) end) return end callback(token) end) 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