local config = require('pending.config') local M = {} M.name = 'gtasks' local BASE_URL = 'https://tasks.googleapis.com/tasks/v1' 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/tasks' ---@class pending.GtasksCredentials ---@field client_id string ---@field client_secret string ---@field redirect_uris? string[] ---@class pending.GtasksTokens ---@field access_token string ---@field refresh_token string ---@field expires_in? integer ---@field obtained_at? integer ---@return table local function gtasks_config() local cfg = config.get() return (cfg.sync and cfg.sync.gtasks) or {} end ---@return string local function token_path() return vim.fn.stdpath('data') .. '/pending/gtasks_tokens.json' end ---@return string local function credentials_path() local gc = gtasks_config() return gc.credentials_path or (vim.fn.stdpath('data') .. '/pending/gtasks_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.GtasksCredentials? 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.GtasksCredentials]] end return creds --[[@as pending.GtasksCredentials]] end ---@return pending.GtasksTokens? local function load_tokens() return load_json_file(token_path()) --[[@as pending.GtasksTokens?]] end ---@param tokens pending.GtasksTokens ---@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.GtasksCredentials ---@param tokens pending.GtasksTokens ---@return pending.GtasksTokens? 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 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 credentials found at ' .. credentials_path(), vim.log.levels.ERROR ) return end local port = 18393 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 '

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 ---@param creds pending.GtasksCredentials ---@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 Tasks authorized successfully.') end ---@param access_token string ---@return table? name_to_id ---@return string? err local function get_all_tasklists(access_token) local data, err = curl_request('GET', BASE_URL .. '/users/@me/lists', auth_headers(access_token)) if err then return nil, err end local result = {} for _, item in ipairs(data and data.items or {}) do result[item.title] = item.id end return result, nil end ---@param access_token string ---@param name string ---@param existing table ---@return string? list_id ---@return string? err local function find_or_create_tasklist(access_token, name, existing) if existing[name] then return existing[name], nil end local body = vim.json.encode({ title = name }) local created, err = curl_request('POST', BASE_URL .. '/users/@me/lists', 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 access_token string ---@param list_id string ---@return table[]? items ---@return string? err local function list_gtasks(access_token, list_id) local url = BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks?showCompleted=true&showHidden=true' local data, err = curl_request('GET', url, auth_headers(access_token)) if err then return nil, err end return data and data.items or {}, nil end ---@param access_token string ---@param list_id string ---@param body table ---@return string? task_id ---@return string? err local function create_gtask(access_token, list_id, body) local data, err = curl_request( 'POST', BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks', auth_headers(access_token), vim.json.encode(body) ) if err then return nil, err end return data and data.id, nil end ---@param access_token string ---@param list_id string ---@param task_id string ---@param body table ---@return string? err local function update_gtask(access_token, list_id, task_id, body) local _, err = curl_request( 'PATCH', BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks/' .. url_encode(task_id), auth_headers(access_token), vim.json.encode(body) ) return err end ---@param access_token string ---@param list_id string ---@param task_id string ---@return string? err local function delete_gtask(access_token, list_id, task_id) local _, err = curl_request( 'DELETE', BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks/' .. url_encode(task_id), auth_headers(access_token) ) return err end ---@param due string YYYY-MM-DD or YYYY-MM-DDThh:mm ---@return string RFC 3339 local function due_to_rfc3339(due) local date = due:match('^(%d%d%d%d%-%d%d%-%d%d)') return (date or due) .. 'T00:00:00.000Z' end ---@param rfc string RFC 3339 from GTasks ---@return string YYYY-MM-DD local function rfc3339_to_date(rfc) return rfc:match('^(%d%d%d%d%-%d%d%-%d%d)') or rfc end ---@param task pending.Task ---@return string? local function build_notes(task) local parts = {} if task.priority and task.priority > 0 then table.insert(parts, 'pri:' .. task.priority) end if task.recur then local spec = task.recur if task.recur_mode == 'completion' then spec = '!' .. spec end table.insert(parts, 'rec:' .. spec) end if #parts == 0 then return nil end return table.concat(parts, ' ') end ---@param notes string? ---@return integer priority ---@return string? recur ---@return string? recur_mode local function parse_notes(notes) if not notes then return 0, nil, nil end local priority = 0 local recur = nil local recur_mode = nil local pri = notes:match('pri:(%d+)') if pri then priority = tonumber(pri) or 0 end local rec = notes:match('rec:(!?[%w]+)') if rec then if rec:sub(1, 1) == '!' then recur = rec:sub(2) recur_mode = 'completion' else recur = rec end end return priority, recur, recur_mode end ---@param task pending.Task ---@return table local function task_to_gtask(task) local body = { title = task.description, status = task.status == 'done' and 'completed' or 'needsAction', } if task.due then body.due = due_to_rfc3339(task.due) end local notes = build_notes(task) if notes then body.notes = notes end return body end ---@param gtask table ---@param category string ---@return table fields for store:add / store:update local function gtask_to_fields(gtask, category) local priority, recur, recur_mode = parse_notes(gtask.notes) local fields = { description = gtask.title or '', category = category, status = gtask.status == 'completed' and 'done' or 'pending', priority = priority, recur = recur, recur_mode = recur_mode, } if gtask.due then fields.due = rfc3339_to_date(gtask.due) end return fields end ---@param s pending.Store ---@return table local function build_id_index(s) ---@type table local index = {} for _, task in ipairs(s:tasks()) do local extra = task._extra or {} local gtid = extra['_gtasks_task_id'] --[[@as string?]] if gtid then index[gtid] = task end end return index end ---@param access_token string ---@param tasklists table ---@param s pending.Store ---@param now_ts string ---@param by_gtasks_id table ---@return integer created ---@return integer updated ---@return integer deleted local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) local created, updated, deleted = 0, 0, 0 for _, task in ipairs(s:tasks()) do local extra = task._extra or {} local gtid = extra['_gtasks_task_id'] --[[@as string?]] local list_id = extra['_gtasks_list_id'] --[[@as string?]] if task.status == 'deleted' and gtid and list_id then local err = delete_gtask(access_token, list_id, gtid) if not err then if not task._extra then task._extra = {} end task._extra['_gtasks_task_id'] = nil task._extra['_gtasks_list_id'] = nil if next(task._extra) == nil then task._extra = nil end task.modified = now_ts deleted = deleted + 1 end elseif task.status ~= 'deleted' then if gtid and list_id then local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task)) if not err then updated = updated + 1 end elseif task.status == 'pending' then local cat = task.category or config.get().default_category local lid, err = find_or_create_tasklist(access_token, cat, tasklists) if not err and lid then local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task)) if not create_err and new_id then if not task._extra then task._extra = {} end task._extra['_gtasks_task_id'] = new_id task._extra['_gtasks_list_id'] = lid task.modified = now_ts by_gtasks_id[new_id] = task created = created + 1 end end end end end return created, updated, deleted end ---@param access_token string ---@param tasklists table ---@param s pending.Store ---@param now_ts string ---@param by_gtasks_id table ---@return integer created ---@return integer updated local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) local created, updated = 0, 0 for list_name, list_id in pairs(tasklists) do local items, err = list_gtasks(access_token, list_id) if err then vim.notify( 'pending.nvim: error fetching list ' .. list_name .. ': ' .. err, vim.log.levels.WARN ) else for _, gtask in ipairs(items or {}) do local local_task = by_gtasks_id[gtask.id] if local_task then local gtask_updated = gtask.updated or '' local local_modified = local_task.modified or '' if gtask_updated > local_modified then local fields = gtask_to_fields(gtask, list_name) for k, v in pairs(fields) do local_task[k] = v end local_task.modified = now_ts updated = updated + 1 end else local fields = gtask_to_fields(gtask, list_name) fields._extra = { _gtasks_task_id = gtask.id, _gtasks_list_id = list_id, } local new_task = s:add(fields) by_gtasks_id[gtask.id] = new_task created = created + 1 end end end end return created, updated end ---@return string? access_token ---@return table? tasklists ---@return pending.Store? store ---@return string? now_ts local function sync_setup() local access_token = get_access_token() if not access_token then return nil end local tasklists, tl_err = get_all_tasklists(access_token) if tl_err or not tasklists then vim.notify('pending.nvim: ' .. (tl_err or 'failed to fetch task lists'), vim.log.levels.ERROR) return nil end local s = require('pending').store() local now_ts = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] return access_token, tasklists, s, now_ts end function M.push() local access_token, tasklists, s, now_ts = sync_setup() if not access_token then return end ---@cast tasklists table ---@cast s pending.Store ---@cast now_ts string local by_gtasks_id = build_id_index(s) local created, updated, deleted = push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) s:save() require('pending')._recompute_counts() vim.notify( string.format('pending.nvim: Google Tasks pushed — +%d ~%d -%d', created, updated, deleted) ) end function M.pull() local access_token, tasklists, s, now_ts = sync_setup() if not access_token then return end ---@cast tasklists table ---@cast s pending.Store ---@cast now_ts string local by_gtasks_id = build_id_index(s) local created, updated = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) s:save() require('pending')._recompute_counts() vim.notify(string.format('pending.nvim: Google Tasks pulled — +%d ~%d', created, updated)) end function M.sync() local access_token, tasklists, s, now_ts = sync_setup() if not access_token then return end ---@cast tasklists table ---@cast s pending.Store ---@cast now_ts string local by_gtasks_id = build_id_index(s) local pushed_create, pushed_update, pushed_delete = push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) local pulled_create, pulled_update = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) s:save() require('pending')._recompute_counts() vim.notify( string.format( 'pending.nvim: Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d', pushed_create, pushed_update, pushed_delete, pulled_create, pulled_update ) ) end M._due_to_rfc3339 = due_to_rfc3339 M._rfc3339_to_date = rfc3339_to_date M._build_notes = build_notes M._parse_notes = parse_notes M._task_to_gtask = task_to_gtask M._gtask_to_fields = gtask_to_fields ---@return nil function M.health() if vim.fn.executable('curl') == 1 then vim.health.ok('curl found (required for gtasks sync)') else vim.health.warn('curl not found (needed for gtasks sync)') end if vim.fn.executable('openssl') == 1 then vim.health.ok('openssl found (required for gtasks OAuth PKCE)') else vim.health.warn('openssl not found (needed for gtasks OAuth)') end local tokens = load_tokens() if tokens and tokens.refresh_token then vim.health.ok('gtasks tokens found') else vim.health.info('no gtasks tokens — run :Pending gtasks auth') end end return M