feat: Google Tasks bidirectional sync and CLI refactor (#59)

* feat(gtasks): add Google Tasks bidirectional sync

Problem: pending.nvim only supported one-way push to Google Calendar.
Users who use Google Tasks had no way to sync tasks bidirectionally.

Solution: add `lua/pending/sync/gtasks.lua` backend with OAuth PKCE
auth, push/pull/sync actions, and field mapping between pending tasks
and Google Tasks (category↔tasklist, `priority`/`recur` via notes).

* refactor(cli): promote sync backends to top-level subcommands

Problem: `:Pending sync gtasks auth` required an extra `sync` keyword
that added no value and made the command unnecessarily verbose.

Solution: route `gtasks` and `gcal` as top-level `:Pending` subcommands
via `SYNC_BACKEND_SET` lookup. Tab completion introspects backend
modules for available actions instead of hardcoding `{ 'auth', 'sync' }`.

* docs(gtasks): document Google Tasks backend and CLI changes

Problem: vimdoc had no coverage for the gtasks backend and still
referenced the old `:Pending sync <backend>` command form.

Solution: add `:Pending-gtasks` and `:Pending-gcal` command sections
with per-action docs, update sync backend interface, and add gtasks
config example.

* ci: format
This commit is contained in:
Barrett Ruth 2026-03-05 01:01:29 -05:00 committed by GitHub
parent 3e8fd0a6a3
commit 21628abe53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1114 additions and 85 deletions

View file

@ -41,6 +41,7 @@ Features: ~
- Foldable category sections (`zc`/`zo`) in category view
- Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (`<C-x><C-o>`)
- Google Calendar one-way push via OAuth PKCE
- Google Tasks bidirectional sync via OAuth PKCE
==============================================================================
CONTENTS *pending-contents*
@ -63,15 +64,16 @@ CONTENTS *pending-contents*
16. Recipes ............................................... |pending-recipes|
17. Sync Backends ................................... |pending-sync-backend|
18. Google Calendar .......................................... |pending-gcal|
19. Data Format .............................................. |pending-data|
20. Health Check ........................................... |pending-health|
19. Google Tasks ............................................ |pending-gtasks|
20. Data Format .............................................. |pending-data|
21. Health Check ........................................... |pending-health|
==============================================================================
REQUIREMENTS *pending-requirements*
- Neovim 0.10+
- No external dependencies for local use
- `curl` and `openssl` are required for the `gcal` sync backend
- `curl` and `openssl` are required for the `gcal` and `gtasks` sync backends
==============================================================================
INSTALL *pending-install*
@ -146,24 +148,42 @@ COMMANDS *pending-commands*
Populate the quickfix list with all tasks that are overdue or due today.
Open the list with |:copen| to navigate to each task's category.
*:Pending-sync*
:Pending sync {backend} [{action}]
Run a sync action against a named backend. {backend} is required — bare
`:Pending sync` prints a usage message. {action} defaults to `sync`
when omitted. Each backend lives at `lua/pending/sync/<name>.lua`.
*:Pending-gtasks*
:Pending gtasks [{action}]
Run a Google Tasks sync action. {action} defaults to `sync` when omitted.
Actions: ~
`sync` Push local changes then pull remote changes (default).
`push` Push local changes to Google Tasks only.
`pull` Pull remote changes from Google Tasks only.
`auth` Run the OAuth authorization flow.
Examples: >vim
:Pending sync gcal " runs gcal.sync()
:Pending sync gcal auth " runs gcal.auth()
:Pending sync gcal sync " explicit sync (same as bare)
:Pending gtasks " push then pull (default)
:Pending gtasks push " push local → Google Tasks
:Pending gtasks pull " pull Google Tasks → local
:Pending gtasks auth " authorize
<
Tab completion after `:Pending sync ` lists discovered backends.
Tab completion after `:Pending sync gcal ` lists available actions.
Tab completion after `:Pending gtasks ` lists available actions.
See |pending-gtasks| for full details.
Built-in backends: ~
*:Pending-gcal*
:Pending gcal [{action}]
Run a Google Calendar sync action. {action} defaults to `sync` when
omitted.
`gcal` Google Calendar one-way push. See |pending-gcal|.
Actions: ~
`sync` Push tasks with due dates to Google Calendar (default).
`auth` Run the OAuth authorization flow.
Examples: >vim
:Pending gcal " push to Google Calendar (default)
:Pending gcal auth " authorize
<
Tab completion after `:Pending gcal ` lists available actions.
See |pending-gcal| for full details.
*:Pending-filter*
:Pending filter {predicates}
@ -590,6 +610,9 @@ loads: >lua
calendar = 'Pendings',
credentials_path = '/path/to/client_secret.json',
},
gtasks = {
credentials_path = '/path/to/client_secret.json',
},
},
}
<
@ -870,21 +893,30 @@ Open tasks in a new tab on startup: >lua
SYNC BACKENDS *pending-sync-backend*
Sync backends are Lua modules under `lua/pending/sync/<name>.lua`. Each
module returns a table conforming to the backend interface: >lua
backend is exposed as a top-level `:Pending` subcommand: >vim
:Pending gtasks [action]
:Pending gcal [action]
<
Each module returns a table conforming to the backend interface: >lua
---@class pending.SyncBackend
---@field name string
---@field auth fun(): nil
---@field sync fun(): nil
---@field push? fun(): nil
---@field pull? fun(): nil
---@field health? fun(): nil
<
Required fields: ~
{name} Backend identifier (matches the filename).
{sync} Main sync action. Called by `:Pending sync <name>`.
{auth} Authorization flow. Called by `:Pending sync <name> auth`.
{sync} Main sync action. Called by `:Pending <name>`.
{auth} Authorization flow. Called by `:Pending <name> auth`.
Optional fields: ~
{push} Push-only action. Called by `:Pending <name> push`.
{pull} Pull-only action. Called by `:Pending <name> pull`.
{health} Called by `:checkhealth pending` to report backend-specific
diagnostics (e.g. checking for external tools).
@ -923,7 +955,7 @@ Fields: ~
that Google provides or as a bare credentials object.
OAuth flow: ~
On the first `:Pending sync gcal` call the plugin detects that no refresh token
On the first `:Pending gcal` call the plugin detects that no refresh token
exists and opens the Google authorization URL in the browser using
|vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the
OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used —
@ -933,7 +965,7 @@ authorization code is exchanged for tokens and the refresh token is stored at
use the stored refresh token and refresh the access token automatically when
it is about to expire.
`:Pending sync gcal` behavior: ~
`:Pending gcal` behavior: ~
For each task in the store:
- A pending task with a due date and no existing event: a new all-day event is
created and the event ID is stored in the task's `_extra` table.
@ -946,6 +978,67 @@ For each task in the store:
A summary notification is shown after sync: `created: N, updated: N,
deleted: N`.
==============================================================================
GOOGLE TASKS *pending-gtasks*
pending.nvim can sync tasks bidirectionally with Google Tasks. Each
pending.nvim category maps to a Google Tasks list of the same name. Lists are
created automatically on first sync.
Configuration: >lua
vim.g.pending = {
sync = {
gtasks = {
credentials_path = '/path/to/client_secret.json',
},
},
}
<
*pending.GtasksConfig*
Fields: ~
{credentials_path} (string)
Path to the OAuth client secret JSON file downloaded
from the Google Cloud Console. Default:
`stdpath('data')..'/pending/gtasks_credentials.json'`.
Accepts the `installed` wrapper format or a bare
credentials object.
OAuth flow: ~
Same PKCE flow as the gcal backend; listens on port 18393. Tokens are stored
at `stdpath('data')/pending/gtasks_tokens.json`. Run `:Pending gtasks auth`
to authorize; subsequent syncs refresh the token automatically.
`:Pending gtasks` actions: ~
`:Pending gtasks` (or `:Pending gtasks sync`) runs push then pull. Use
`:Pending gtasks push` or `:Pending gtasks pull` to run only one direction.
Push (local → Google Tasks, `:Pending gtasks push`):
- Pending task with no `_gtasks_task_id`: created in the matching list.
- Pending task with an existing ID: updated in Google Tasks.
- Done task with an existing ID: marked `completed` in Google Tasks.
- Deleted task with an existing ID: deleted from Google Tasks.
Pull (Google Tasks → local, `:Pending gtasks pull`):
- GTasks task already known (matched by `_gtasks_task_id`): updated locally
if `gtasks.updated` timestamp is newer than `task.modified`.
- GTasks task not known locally: created as a new pending.nvim task in the
category matching the list name.
Field mapping: ~
{title} ↔ task description
{status} `needsAction` ↔ `pending`, `completed` ↔ `done`
{due} date-only; time component ignored (GTasks limitation)
{notes} serializes extra fields: `pri:1 rec:weekly`
The `notes` field is used exclusively for pending.nvim metadata. Any existing
notes on tasks created outside pending.nvim are parsed for known tokens and
the remainder is ignored.
Recurrence (`rec:`) is stored in notes for round-tripping but is not
expanded by Google Tasks (GTasks has no recurrence API).
==============================================================================
DATA FORMAT *pending-data*
@ -979,7 +1072,8 @@ Task fields: ~
Any field not in the list above is preserved in `_extra` and written back on
save. This is used internally to store the Google Calendar event ID
(`_gcal_event_id`) and allows third-party tooling to annotate tasks without
(`_gcal_event_id`) and Google Tasks IDs (`_gtasks_task_id`,
`_gtasks_list_id`), and allows third-party tooling to annotate tasks without
data loss.
The `version` field is checked on load. If the file version is newer than the

View file

@ -10,8 +10,12 @@
---@field calendar? string
---@field credentials_path? string
---@class pending.GtasksConfig
---@field credentials_path? string
---@class pending.SyncConfig
---@field gcal? pending.GcalConfig
---@field gtasks? pending.GtasksConfig
---@class pending.Keymaps
---@field close? string|false

View file

@ -523,14 +523,19 @@ function M.add(text)
vim.notify('Pending added: ' .. description)
end
---@type string[]
local SYNC_BACKENDS = { 'gcal', 'gtasks' }
---@type table<string, true>
local SYNC_BACKEND_SET = {}
for _, b in ipairs(SYNC_BACKENDS) do
SYNC_BACKEND_SET[b] = true
end
---@param backend_name string
---@param action? string
---@return nil
function M.sync(backend_name, action)
if not backend_name or backend_name == '' then
vim.notify('Usage: :Pending sync <backend> [action]', vim.log.levels.ERROR)
return
end
local function run_sync(backend_name, action)
action = (action and action ~= '') and action or 'sync'
local ok, backend = pcall(require, 'pending.sync.' .. backend_name)
if not ok then
@ -835,9 +840,9 @@ function M.command(args)
elseif cmd == 'edit' then
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
M.edit(id_str, edit_rest)
elseif cmd == 'sync' then
local backend, action = rest:match('^(%S+)%s*(.*)')
M.sync(backend, action)
elseif SYNC_BACKEND_SET[cmd] then
local action = rest:match('^(%S+)') or 'sync'
run_sync(cmd, action)
elseif cmd == 'archive' then
local d = rest ~= '' and tonumber(rest) or nil
M.archive(d)
@ -854,4 +859,14 @@ function M.command(args)
end
end
---@return string[]
function M.sync_backends()
return SYNC_BACKENDS
end
---@return table<string, true>
function M.sync_backend_set()
return SYNC_BACKEND_SET
end
return M

767
lua/pending/sync/gtasks.lua Normal file
View file

@ -0,0 +1,767 @@
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<string, any>
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 '<html><body><h1>Authorization successful</h1><p>You can close this tab.</p></body></html>'
or '<html><body><h1>Authorization failed</h1></body></html>'
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<string, string>? 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<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 =
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<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 = 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<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()
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

View file

@ -166,7 +166,12 @@ end, {
bar = true,
nargs = '*',
complete = function(arg_lead, cmd_line)
local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'init', 'sync', 'undo' }
local pending = require('pending')
local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'init', 'undo' }
for _, b in ipairs(pending.sync_backends()) do
table.insert(subcmds, b)
end
table.sort(subcmds)
if not cmd_line:match('^Pending%s+%S') then
return filter_candidates(arg_lead, subcmds)
end
@ -198,33 +203,25 @@ end, {
if cmd_line:match('^Pending%s+edit') then
return complete_edit(arg_lead, cmd_line)
end
if cmd_line:match('^Pending%s+sync') then
local after_sync = cmd_line:match('^Pending%s+sync%s+(.*)')
if not after_sync then
local backend_set = pending.sync_backend_set()
local matched_backend = cmd_line:match('^Pending%s+(%S+)')
if matched_backend and backend_set[matched_backend] then
local after_backend = cmd_line:match('^Pending%s+%S+%s+(.*)')
if not after_backend then
return {}
end
local parts = {}
for part in after_sync:gmatch('%S+') do
table.insert(parts, part)
local ok, mod = pcall(require, 'pending.sync.' .. matched_backend)
if not ok then
return {}
end
local trailing_space = after_sync:match('%s$')
if #parts == 0 or (#parts == 1 and not trailing_space) then
local backends = {}
local pattern = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
for _, path in ipairs(pattern) do
local name = vim.fn.fnamemodify(path, ':t:r')
table.insert(backends, name)
local actions = {}
for k, v in pairs(mod) do
if type(v) == 'function' and k:sub(1, 1) ~= '_' then
table.insert(actions, k)
end
table.sort(backends)
return filter_candidates(arg_lead, backends)
end
if #parts == 1 and trailing_space then
return filter_candidates(arg_lead, { 'auth', 'sync' })
end
if #parts >= 2 and not trailing_space then
return filter_candidates(arg_lead, { 'auth', 'sync' })
end
return {}
table.sort(actions)
return filter_candidates(arg_lead, actions)
end
return {}
end,

178
spec/gtasks_spec.lua Normal file
View file

@ -0,0 +1,178 @@
require('spec.helpers')
local gtasks = require('pending.sync.gtasks')
describe('gtasks field conversion', function()
describe('due date helpers', function()
it('converts date-only to RFC 3339', function()
assert.equals('2026-03-15T00:00:00.000Z', gtasks._due_to_rfc3339('2026-03-15'))
end)
it('converts datetime to RFC 3339 (strips time)', function()
assert.equals('2026-03-15T00:00:00.000Z', gtasks._due_to_rfc3339('2026-03-15T14:30'))
end)
it('strips RFC 3339 to date-only', function()
assert.equals('2026-03-15', gtasks._rfc3339_to_date('2026-03-15T00:00:00.000Z'))
end)
end)
describe('build_notes', function()
it('returns nil when no priority or recur', function()
assert.is_nil(gtasks._build_notes({ priority = 0, recur = nil }))
end)
it('encodes priority', function()
assert.equals('pri:1', gtasks._build_notes({ priority = 1, recur = nil }))
end)
it('encodes recur', function()
assert.equals('rec:weekly', gtasks._build_notes({ priority = 0, recur = 'weekly' }))
end)
it('encodes completion-mode recur with ! prefix', function()
assert.equals(
'rec:!daily',
gtasks._build_notes({ priority = 0, recur = 'daily', recur_mode = 'completion' })
)
end)
it('encodes both priority and recur', function()
assert.equals('pri:1 rec:weekly', gtasks._build_notes({ priority = 1, recur = 'weekly' }))
end)
end)
describe('parse_notes', function()
it('returns zeros/nils for nil input', function()
local pri, rec, mode = gtasks._parse_notes(nil)
assert.equals(0, pri)
assert.is_nil(rec)
assert.is_nil(mode)
end)
it('parses priority', function()
local pri = gtasks._parse_notes('pri:1')
assert.equals(1, pri)
end)
it('parses recur', function()
local _, rec = gtasks._parse_notes('rec:weekly')
assert.equals('weekly', rec)
end)
it('parses completion-mode recur', function()
local _, rec, mode = gtasks._parse_notes('rec:!daily')
assert.equals('daily', rec)
assert.equals('completion', mode)
end)
it('parses both priority and recur', function()
local pri, rec = gtasks._parse_notes('pri:1 rec:monthly')
assert.equals(1, pri)
assert.equals('monthly', rec)
end)
it('round-trips through build_notes', function()
local task = { priority = 1, recur = 'weekly', recur_mode = nil }
local notes = gtasks._build_notes(task)
local pri, rec = gtasks._parse_notes(notes)
assert.equals(1, pri)
assert.equals('weekly', rec)
end)
end)
describe('task_to_gtask', function()
it('maps description to title', function()
local body = gtasks._task_to_gtask({
description = 'Buy milk',
status = 'pending',
priority = 0,
})
assert.equals('Buy milk', body.title)
end)
it('maps pending status to needsAction', function()
local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 })
assert.equals('needsAction', body.status)
end)
it('maps done status to completed', function()
local body = gtasks._task_to_gtask({ description = 'x', status = 'done', priority = 0 })
assert.equals('completed', body.status)
end)
it('converts due date to RFC 3339', function()
local body = gtasks._task_to_gtask({
description = 'x',
status = 'pending',
priority = 0,
due = '2026-03-15',
})
assert.equals('2026-03-15T00:00:00.000Z', body.due)
end)
it('omits due when nil', function()
local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 })
assert.is_nil(body.due)
end)
it('includes notes when priority is set', function()
local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 1 })
assert.equals('pri:1', body.notes)
end)
it('omits notes when no extra fields', function()
local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 })
assert.is_nil(body.notes)
end)
end)
describe('gtask_to_fields', function()
it('maps title to description', function()
local fields = gtasks._gtask_to_fields({ title = 'Buy milk', status = 'needsAction' }, 'Work')
assert.equals('Buy milk', fields.description)
end)
it('maps category from list name', function()
local fields = gtasks._gtask_to_fields({ title = 'x', status = 'needsAction' }, 'Personal')
assert.equals('Personal', fields.category)
end)
it('maps needsAction to pending', function()
local fields = gtasks._gtask_to_fields({ title = 'x', status = 'needsAction' }, 'Work')
assert.equals('pending', fields.status)
end)
it('maps completed to done', function()
local fields = gtasks._gtask_to_fields({ title = 'x', status = 'completed' }, 'Work')
assert.equals('done', fields.status)
end)
it('strips due date to YYYY-MM-DD', function()
local fields = gtasks._gtask_to_fields({
title = 'x',
status = 'needsAction',
due = '2026-03-15T00:00:00.000Z',
}, 'Work')
assert.equals('2026-03-15', fields.due)
end)
it('parses priority from notes', function()
local fields = gtasks._gtask_to_fields({
title = 'x',
status = 'needsAction',
notes = 'pri:1',
}, 'Work')
assert.equals(1, fields.priority)
end)
it('parses recur from notes', function()
local fields = gtasks._gtask_to_fields({
title = 'x',
status = 'needsAction',
notes = 'rec:weekly',
}, 'Work')
assert.equals('weekly', fields.recur)
end)
end)
end)

View file

@ -23,7 +23,7 @@ describe('sync', function()
end)
describe('dispatch', function()
it('errors on bare :Pending sync with no backend', function()
it('errors on unknown subcommand', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
@ -31,35 +31,9 @@ describe('sync', function()
msg = m
end
end
pending.sync(nil)
pending.command('notreal')
vim.notify = orig
assert.are.equal('Usage: :Pending sync <backend> [action]', msg)
end)
it('errors on empty backend string', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.sync('')
vim.notify = orig
assert.are.equal('Usage: :Pending sync <backend> [action]', msg)
end)
it('errors on unknown backend', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.sync('notreal')
vim.notify = orig
assert.are.equal('Unknown sync backend: notreal', msg)
assert.are.equal('Unknown Pending subcommand: notreal', msg)
end)
it('errors on unknown action for valid backend', function()
@ -70,7 +44,7 @@ describe('sync', function()
msg = m
end
end
pending.sync('gcal', 'notreal')
pending.command('gcal notreal')
vim.notify = orig
assert.are.equal("gcal backend has no 'notreal' action", msg)
end)
@ -82,7 +56,7 @@ describe('sync', function()
gcal.sync = function()
called = true
end
pending.sync('gcal')
pending.command('gcal')
gcal.sync = orig_sync
assert.is_true(called)
end)
@ -94,7 +68,7 @@ describe('sync', function()
gcal.sync = function()
called = true
end
pending.sync('gcal', 'sync')
pending.command('gcal sync')
gcal.sync = orig_sync
assert.is_true(called)
end)
@ -106,7 +80,7 @@ describe('sync', function()
gcal.auth = function()
called = true
end
pending.sync('gcal', 'auth')
pending.command('gcal auth')
gcal.auth = orig_auth
assert.is_true(called)
end)