* feat(s3): create bucket interactively during auth when unconfigured Problem: when a user runs `:Pending s3 auth` with no bucket configured, auth succeeds but offers no way to create the bucket. The user must manually run `aws s3api create-bucket` and update their config. Solution: add `util.input()` coroutine-aware prompt wrapper and a `create_bucket()` flow in `s3.lua` that prompts for bucket name and region, handles the `us-east-1` LocationConstraint quirk, and logs a config snippet on success. Called automatically from `auth()` when `sync.s3.bucket` is absent. * ci: typing * feat(parse): add `parse_duration_to_days` for duration string conversion Problem: The archive command accepted only a bare integer for days, inconsistent with the `+Nd`/`+Nw`/`+Nm` duration syntax used elsewhere. Solution: Add `parse_duration_to_days()` supporting `Nd`, `Nw`, `Nm`, and bare integers. Returns nil on invalid input for caller error handling. * feat(archive): duration syntax and confirmation prompt Problem: `:Pending archive` accepted only a bare integer for days and silently deleted tasks with no confirmation, risking accidental data loss. Solution: Accept duration strings (`7d`, `3w`, `2m`) via `parse.parse_duration_to_days()`, show a `vim.ui.input` confirmation prompt before removing tasks, and skip the prompt when zero tasks match. * feat: add `<C-a>` / `<C-x>` keymaps for priority increment/decrement Problem: Priority could only be cycled with `g!` (0→1→2→3→0), with no way to directly increment or decrement. Solution: Add `adjust_priority()` with clamping at 0 and `max_priority`, exposed as `increment_priority()` / `decrement_priority()` on `<C-a>` / `<C-x>`. Includes `<Plug>` mappings and vimdoc. * fix(s3): use parenthetical defaults in bucket creation prompts Problem: `util.input` with `default` pre-filled the input field, and the success message said "Add to your config" ambiguously. Solution: Show defaults in prompt text as `(default)` instead of pre-filling, and clarify the message to "Add to your pending.nvim config". * ci: format * ci(sync): normalize log prefix to `backend:` across all sync backends Problem: Sync log messages used inconsistent prefixes like `s3 push:`, `gtasks pull:`, `gtasks sync —` instead of the `backend: action` pattern used by auth messages. Solution: Normalize all sync backend logs to `backend: action ...` format across `s3.lua`, `gcal.lua`, and `gtasks.lua`. * ci: fix linter warnings in archive spec and s3 bucket creation
544 lines
16 KiB
Lua
544 lines
16 KiB
Lua
local config = require('pending.config')
|
|
local log = require('pending.log')
|
|
local oauth = require('pending.sync.oauth')
|
|
local util = require('pending.sync.util')
|
|
|
|
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 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('Failed to delete remote task: ' .. 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('Failed to update remote task: ' .. 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('Failed to create remote task: ' .. 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('Failed to fetch task 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
|
|
|
|
function M.push()
|
|
oauth.with_token(oauth.google_client, 'gtasks', 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)
|
|
util.finish(s)
|
|
log.info('gtasks: push ' .. util.fmt_counts({
|
|
{ created, 'added' },
|
|
{ updated, 'updated' },
|
|
{ deleted, 'deleted' },
|
|
{ failed, 'failed' },
|
|
}))
|
|
end)
|
|
end
|
|
|
|
function M.pull()
|
|
oauth.with_token(oauth.google_client, 'gtasks', 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)
|
|
util.finish(s)
|
|
log.info('gtasks: pull ' .. util.fmt_counts({
|
|
{ created, 'added' },
|
|
{ updated, 'updated' },
|
|
{ unlinked, 'unlinked' },
|
|
{ failed, 'failed' },
|
|
}))
|
|
end)
|
|
end
|
|
|
|
function M.sync()
|
|
oauth.with_token(oauth.google_client, 'gtasks', 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)
|
|
util.finish(s)
|
|
log.info('gtasks: sync push ' .. util.fmt_counts({
|
|
{ pushed_create, 'added' },
|
|
{ pushed_update, 'updated' },
|
|
{ pushed_delete, 'deleted' },
|
|
{ pushed_failed, 'failed' },
|
|
}) .. ' | pull ' .. util.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
|
|
|
|
---@param args? string
|
|
---@return nil
|
|
function M.auth(args)
|
|
if args == 'clear' then
|
|
oauth.google_client:clear_tokens()
|
|
log.info('gtasks: OAuth tokens cleared — run :Pending auth gtasks to re-authenticate.')
|
|
elseif args == 'reset' then
|
|
oauth.google_client:_wipe()
|
|
log.info(
|
|
'gtasks: OAuth tokens and credentials cleared — run :Pending auth gtasks to set up from scratch.'
|
|
)
|
|
else
|
|
local creds = oauth.google_client:resolve_credentials()
|
|
if creds.client_id == oauth.BUNDLED_CLIENT_ID then
|
|
oauth.google_client:setup()
|
|
else
|
|
oauth.google_client:auth()
|
|
end
|
|
end
|
|
end
|
|
|
|
---@return string[]
|
|
function M.auth_complete()
|
|
return { 'clear', 'reset' }
|
|
end
|
|
|
|
---@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 gtasks')
|
|
end
|
|
end
|
|
|
|
return M
|