pending.nvim/lua/pending/sync/gtasks.lua
Barrett Ruth b2efe168fe fix(sync): replace cryptic sigil counters with readable output
Problem: sync summaries used unexplained sigils (`+/-/~` and `!`) that
conveyed no meaning, mixed symbol and prose formats across operations,
and `gcal push` silently swallowed failures with no aggregate counter.

Solution: replace all summary `log.info` calls with a shared
`fmt_counts` helper that formats `N label` pairs separated by ` | `,
suppresses zero counts, and falls back to "nothing to do". Add a
`failed` counter to `gcal.push` to surface errors previously only
emitted as individual warnings.
2026-03-06 13:22:21 -05:00

570 lines
16 KiB
Lua

local config = require('pending.config')
local log = require('pending.log')
local oauth = require('pending.sync.oauth')
local M = {}
M.name = 'gtasks'
local BASE_URL = 'https://tasks.googleapis.com/tasks/v1'
---@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
---@return boolean
local function allow_remote_delete()
local cfg = config.get()
local sync = cfg.sync or {}
local per = (sync.gtasks or {}) --[[@as pending.GtasksConfig]]
if per.remote_delete ~= nil then
return per.remote_delete == true
end
return sync.remote_delete == true
end
---@param task pending.Task
---@param now_ts string
local function unlink_remote(task, now_ts)
task._extra['_gtasks_task_id'] = nil
task._extra['_gtasks_list_id'] = nil
task._extra['_gtasks_synced_at'] = nil
if next(task._extra) == nil then
task._extra = nil
end
task.modified = now_ts
end
---@param parts {[1]: integer, [2]: string}[]
---@return string
local function fmt_counts(parts)
local items = {}
for _, p in ipairs(parts) do
if p[1] > 0 then
table.insert(items, p[1] .. ' ' .. p[2])
end
end
if #items == 0 then
return 'nothing to do'
end
return table.concat(items, ' | ')
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
---@return integer failed
local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local created, updated, deleted, failed = 0, 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
if allow_remote_delete() then
local err = delete_gtask(access_token, list_id, gtid)
if err then
log.warn('gtasks delete failed: ' .. err)
failed = failed + 1
else
unlink_remote(task, now_ts)
deleted = deleted + 1
end
else
log.debug(
'gtasks: remote delete skipped (remote_delete disabled) — unlinked task #' .. task.id
)
unlink_remote(task, now_ts)
deleted = deleted + 1
end
elseif task.status ~= 'deleted' then
if gtid and list_id then
local synced_at = extra['_gtasks_synced_at'] --[[@as string?]]
if not synced_at or task.modified > synced_at then
local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task))
if err then
log.warn('gtasks update failed: ' .. err)
failed = failed + 1
else
task._extra = task._extra or {}
task._extra['_gtasks_synced_at'] = now_ts
updated = updated + 1
end
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 create_err then
log.warn('gtasks create failed: ' .. create_err)
failed = failed + 1
elseif 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._extra['_gtasks_synced_at'] = now_ts
task.modified = now_ts
by_gtasks_id[new_id] = task
created = created + 1
end
end
end
end
end
return created, updated, deleted, failed
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 failed
---@return table<string, true> seen_remote_ids
---@return table<string, true> fetched_list_ids
local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local created, updated, failed = 0, 0, 0
---@type table<string, true>
local seen_remote_ids = {}
---@type table<string, true>
local fetched_list_ids = {}
for list_name, list_id in pairs(tasklists) do
local items, err = list_gtasks(access_token, list_id)
if err then
log.warn('error fetching list ' .. list_name .. ': ' .. err)
failed = failed + 1
else
fetched_list_ids[list_id] = true
for _, gtask in ipairs(items or {}) do
seen_remote_ids[gtask.id] = true
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._extra = local_task._extra or {}
local_task._extra['_gtasks_synced_at'] = now_ts
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,
_gtasks_synced_at = now_ts,
}
local new_task = s:add(fields)
by_gtasks_id[gtask.id] = new_task
created = created + 1
end
end
end
end
return created, updated, failed, seen_remote_ids, fetched_list_ids
end
---@param s pending.Store
---@param seen_remote_ids table<string, true>
---@param fetched_list_ids table<string, true>
---@param now_ts string
---@return integer unlinked
local function detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts)
local unlinked = 0
for _, task in ipairs(s:tasks()) do
local extra = task._extra or {}
local gtid = extra['_gtasks_task_id']
local list_id = extra['_gtasks_list_id']
if
task.status ~= 'deleted'
and gtid
and list_id
and fetched_list_ids[list_id]
and not seen_remote_ids[gtid]
then
task._extra['_gtasks_task_id'] = nil
task._extra['_gtasks_list_id'] = nil
task._extra['_gtasks_synced_at'] = nil
if next(task._extra) == nil then
task._extra = nil
end
task.modified = now_ts
unlinked = unlinked + 1
end
end
return unlinked
end
---@param access_token string
---@return table<string, string>? tasklists
---@return pending.Store? s
---@return string? now_ts
local function sync_setup(access_token)
local tasklists, tl_err = get_all_tasklists(access_token)
if tl_err or not tasklists then
log.error(tl_err or 'failed to fetch task lists')
return nil, nil, nil
end
local s = require('pending').store()
local now_ts = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
return tasklists, s, now_ts
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
log.warn('not authenticated — run :Pending auth')
return
end
callback(token)
end)
end
function M.push()
with_token(function(access_token)
local tasklists, s, now_ts = sync_setup(access_token)
if not tasklists then
return
end
---@cast s pending.Store
---@cast now_ts string
local by_gtasks_id = build_id_index(s)
local created, updated, deleted, failed =
push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
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(
'gtasks push: '
.. fmt_counts({
{ created, 'added' },
{ updated, 'updated' },
{ deleted, 'deleted' },
{ failed, 'failed' },
})
)
end)
end
function M.pull()
with_token(function(access_token)
local tasklists, s, now_ts = sync_setup(access_token)
if not tasklists then
return
end
---@cast s pending.Store
---@cast now_ts string
local by_gtasks_id = build_id_index(s)
local created, updated, failed, seen_remote_ids, fetched_list_ids =
pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local unlinked = detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts)
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(
'gtasks pull: '
.. fmt_counts({
{ created, 'added' },
{ updated, 'updated' },
{ unlinked, 'unlinked' },
{ failed, 'failed' },
})
)
end)
end
function M.sync()
with_token(function(access_token)
local tasklists, s, now_ts = sync_setup(access_token)
if not tasklists then
return
end
---@cast s pending.Store
---@cast now_ts string
local by_gtasks_id = build_id_index(s)
local pushed_create, pushed_update, pushed_delete, pushed_failed =
push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local pulled_create, pulled_update, pulled_failed, seen_remote_ids, fetched_list_ids =
pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local unlinked = detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts)
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(
'gtasks sync — push: '
.. fmt_counts({
{ pushed_create, 'added' },
{ pushed_update, 'updated' },
{ pushed_delete, 'deleted' },
{ pushed_failed, 'failed' },
})
.. ' pull: '
.. fmt_counts({
{ pulled_create, 'added' },
{ pulled_update, 'updated' },
{ unlinked, 'unlinked' },
{ pulled_failed, 'failed' },
})
)
end)
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
M._push_pass = push_pass
M._pull_pass = pull_pass
M._detect_remote_deletions = detect_remote_deletions
---@return nil
function M.health()
oauth.health(M.name)
local tokens = oauth.google_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 auth')
end
end
return M