* 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
282 lines
7.8 KiB
Lua
282 lines
7.8 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 = 'gcal'
|
|
|
|
local BASE_URL = 'https://www.googleapis.com/calendar/v3'
|
|
|
|
---@param access_token string
|
|
---@return table<string, string>? 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<string, string>
|
|
---@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
|
|
|
|
---@return boolean
|
|
local function allow_remote_delete()
|
|
local cfg = config.get()
|
|
local sync = cfg.sync or {}
|
|
local per = (sync.gcal or {}) --[[@as pending.GcalConfig]]
|
|
if per.remote_delete ~= nil then
|
|
return per.remote_delete == true
|
|
end
|
|
return sync.remote_delete == true
|
|
end
|
|
|
|
---@param task pending.Task
|
|
---@param extra table
|
|
---@param now_ts string
|
|
local function unlink_remote(task, extra, now_ts)
|
|
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 = now_ts
|
|
end
|
|
|
|
function M.push()
|
|
oauth.with_token(oauth.google_client, 'gcal', 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 now_ts = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
|
|
local created, updated, deleted, failed = 0, 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
|
|
if allow_remote_delete() then
|
|
local del_err =
|
|
delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]])
|
|
if del_err then
|
|
log.warn('Failed to delete calendar event: ' .. del_err)
|
|
failed = failed + 1
|
|
else
|
|
unlink_remote(task, extra, now_ts)
|
|
deleted = deleted + 1
|
|
end
|
|
else
|
|
log.debug(
|
|
'gcal: remote delete skipped (remote_delete disabled) — unlinked task #' .. task.id
|
|
)
|
|
unlink_remote(task, extra, now_ts)
|
|
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('Failed to update calendar event: ' .. upd_err)
|
|
failed = failed + 1
|
|
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('Failed to create calendar: ' .. (lid_err or 'unknown'))
|
|
failed = failed + 1
|
|
else
|
|
local new_id, create_err = create_event(access_token, lid, task)
|
|
if create_err then
|
|
log.warn('Failed to create calendar event: ' .. create_err)
|
|
failed = failed + 1
|
|
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 = now_ts
|
|
created = created + 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
util.finish(s)
|
|
log.info('gcal: push ' .. util.fmt_counts({
|
|
{ created, 'added' },
|
|
{ updated, 'updated' },
|
|
{ deleted, 'removed' },
|
|
{ failed, 'failed' },
|
|
}))
|
|
end)
|
|
end
|
|
|
|
---@param args? string
|
|
---@return nil
|
|
function M.auth(args)
|
|
if args == 'clear' then
|
|
oauth.google_client:clear_tokens()
|
|
log.info('gcal: OAuth tokens cleared — run :Pending auth gcal to re-authenticate.')
|
|
elseif args == 'reset' then
|
|
oauth.google_client:_wipe()
|
|
log.info(
|
|
'gcal: OAuth tokens and credentials cleared — run :Pending auth gcal 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('gcal tokens found')
|
|
else
|
|
vim.health.info('no gcal tokens — run :Pending auth gcal')
|
|
end
|
|
end
|
|
|
|
return M
|