pending.nvim/lua/pending/sync/gtasks.lua
Barrett Ruth 25c8bd4eb0 refactor(sync): extract shared OAuth into oauth.lua
Problem: `gcal.lua` and `gtasks.lua` duplicated ~250 lines of identical
OAuth code (token management, PKCE flow, credential loading, curl
helpers, url encoding).

Solution: Extract a shared `OAuthClient` metatable in `oauth.lua` with
module-level utilities and instance methods. Both backends now delegate
all OAuth to `oauth.new()`. Skip `oauth` in `health.lua` backend
discovery by checking for a `name` field.
2026-03-05 01:17:47 -05:00

443 lines
12 KiB
Lua

local oauth = require('pending.sync.oauth')
local config = require('pending.config')
local M = {}
M.name = 'gtasks'
local BASE_URL = 'https://tasks.googleapis.com/tasks/v1'
local SCOPE = 'https://www.googleapis.com/auth/tasks'
local client = oauth.new({
name = 'gtasks',
scope = SCOPE,
port = 18393,
config_key = 'gtasks',
})
---@param access_token string
---@return table<string, string>? name_to_id
---@return string? err
local function get_all_tasklists(access_token)
local data, err =
oauth.curl_request('GET', BASE_URL .. '/users/@me/lists', oauth.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<string, string>
---@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 = oauth.curl_request(
'POST',
BASE_URL .. '/users/@me/lists',
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 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/'
.. oauth.url_encode(list_id)
.. '/tasks?showCompleted=true&showHidden=true'
local data, err = oauth.curl_request('GET', url, oauth.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 = oauth.curl_request(
'POST',
BASE_URL .. '/lists/' .. oauth.url_encode(list_id) .. '/tasks',
oauth.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 = oauth.curl_request(
'PATCH',
BASE_URL
.. '/lists/'
.. oauth.url_encode(list_id)
.. '/tasks/'
.. oauth.url_encode(task_id),
oauth.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 = oauth.curl_request(
'DELETE',
BASE_URL
.. '/lists/'
.. oauth.url_encode(list_id)
.. '/tasks/'
.. oauth.url_encode(task_id),
oauth.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<string, pending.Task>
local function build_id_index(s)
---@type table<string, pending.Task>
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<string, string>
---@param s pending.Store
---@param now_ts string
---@param by_gtasks_id table<string, pending.Task>
---@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<string, string>
---@param s pending.Store
---@param now_ts string
---@param by_gtasks_id table<string, pending.Task>
---@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<string, string>? tasklists
---@return pending.Store? store
---@return string? now_ts
local function sync_setup()
local access_token = client: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.auth()
client:auth()
end
function M.push()
local access_token, tasklists, s, now_ts = sync_setup()
if not access_token then
return
end
---@cast tasklists table<string, string>
---@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<string, string>
---@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<string, string>
---@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()
oauth.health(M.name)
local tokens = client: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