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 - Foldable category sections (`zc`/`zo`) in category view
- Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (`<C-x><C-o>`) - Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (`<C-x><C-o>`)
- Google Calendar one-way push via OAuth PKCE - Google Calendar one-way push via OAuth PKCE
- Google Tasks bidirectional sync via OAuth PKCE
============================================================================== ==============================================================================
CONTENTS *pending-contents* CONTENTS *pending-contents*
@ -63,15 +64,16 @@ CONTENTS *pending-contents*
16. Recipes ............................................... |pending-recipes| 16. Recipes ............................................... |pending-recipes|
17. Sync Backends ................................... |pending-sync-backend| 17. Sync Backends ................................... |pending-sync-backend|
18. Google Calendar .......................................... |pending-gcal| 18. Google Calendar .......................................... |pending-gcal|
19. Data Format .............................................. |pending-data| 19. Google Tasks ............................................ |pending-gtasks|
20. Health Check ........................................... |pending-health| 20. Data Format .............................................. |pending-data|
21. Health Check ........................................... |pending-health|
============================================================================== ==============================================================================
REQUIREMENTS *pending-requirements* REQUIREMENTS *pending-requirements*
- Neovim 0.10+ - Neovim 0.10+
- No external dependencies for local use - 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* INSTALL *pending-install*
@ -146,24 +148,42 @@ COMMANDS *pending-commands*
Populate the quickfix list with all tasks that are overdue or due today. 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. Open the list with |:copen| to navigate to each task's category.
*:Pending-sync* *:Pending-gtasks*
:Pending sync {backend} [{action}] :Pending gtasks [{action}]
Run a sync action against a named backend. {backend} is required — bare Run a Google Tasks sync action. {action} defaults to `sync` when omitted.
`:Pending sync` prints a usage message. {action} defaults to `sync`
when omitted. Each backend lives at `lua/pending/sync/<name>.lua`. 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 Examples: >vim
:Pending sync gcal " runs gcal.sync() :Pending gtasks " push then pull (default)
:Pending sync gcal auth " runs gcal.auth() :Pending gtasks push " push local → Google Tasks
:Pending sync gcal sync " explicit sync (same as bare) :Pending gtasks pull " pull Google Tasks → local
:Pending gtasks auth " authorize
< <
Tab completion after `:Pending sync ` lists discovered backends. Tab completion after `:Pending gtasks ` lists available actions.
Tab completion after `:Pending sync gcal ` 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*
:Pending filter {predicates} :Pending filter {predicates}
@ -590,6 +610,9 @@ loads: >lua
calendar = 'Pendings', calendar = 'Pendings',
credentials_path = '/path/to/client_secret.json', 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 *pending-sync-backend*
Sync backends are Lua modules under `lua/pending/sync/<name>.lua`. Each 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 ---@class pending.SyncBackend
---@field name string ---@field name string
---@field auth fun(): nil ---@field auth fun(): nil
---@field sync fun(): nil ---@field sync fun(): nil
---@field push? fun(): nil
---@field pull? fun(): nil
---@field health? fun(): nil ---@field health? fun(): nil
< <
Required fields: ~ Required fields: ~
{name} Backend identifier (matches the filename). {name} Backend identifier (matches the filename).
{sync} Main sync action. Called by `:Pending sync <name>`. {sync} Main sync action. Called by `:Pending <name>`.
{auth} Authorization flow. Called by `:Pending sync <name> auth`. {auth} Authorization flow. Called by `:Pending <name> auth`.
Optional fields: ~ 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 {health} Called by `:checkhealth pending` to report backend-specific
diagnostics (e.g. checking for external tools). diagnostics (e.g. checking for external tools).
@ -923,7 +955,7 @@ Fields: ~
that Google provides or as a bare credentials object. that Google provides or as a bare credentials object.
OAuth flow: ~ 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 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 |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 — 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 use the stored refresh token and refresh the access token automatically when
it is about to expire. it is about to expire.
`:Pending sync gcal` behavior: ~ `:Pending gcal` behavior: ~
For each task in the store: For each task in the store:
- A pending task with a due date and no existing event: a new all-day event is - 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. 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, A summary notification is shown after sync: `created: N, updated: N,
deleted: 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* 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 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 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. data loss.
The `version` field is checked on load. If the file version is newer than the 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 calendar? string
---@field credentials_path? string ---@field credentials_path? string
---@class pending.GtasksConfig
---@field credentials_path? string
---@class pending.SyncConfig ---@class pending.SyncConfig
---@field gcal? pending.GcalConfig ---@field gcal? pending.GcalConfig
---@field gtasks? pending.GtasksConfig
---@class pending.Keymaps ---@class pending.Keymaps
---@field close? string|false ---@field close? string|false

View file

@ -523,14 +523,19 @@ function M.add(text)
vim.notify('Pending added: ' .. description) vim.notify('Pending added: ' .. description)
end 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 backend_name string
---@param action? string ---@param action? string
---@return nil ---@return nil
function M.sync(backend_name, action) local function run_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
action = (action and action ~= '') and action or 'sync' action = (action and action ~= '') and action or 'sync'
local ok, backend = pcall(require, 'pending.sync.' .. backend_name) local ok, backend = pcall(require, 'pending.sync.' .. backend_name)
if not ok then if not ok then
@ -835,9 +840,9 @@ function M.command(args)
elseif cmd == 'edit' then elseif cmd == 'edit' then
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)') local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
M.edit(id_str, edit_rest) M.edit(id_str, edit_rest)
elseif cmd == 'sync' then elseif SYNC_BACKEND_SET[cmd] then
local backend, action = rest:match('^(%S+)%s*(.*)') local action = rest:match('^(%S+)') or 'sync'
M.sync(backend, action) run_sync(cmd, action)
elseif cmd == 'archive' then elseif cmd == 'archive' then
local d = rest ~= '' and tonumber(rest) or nil local d = rest ~= '' and tonumber(rest) or nil
M.archive(d) M.archive(d)
@ -854,4 +859,14 @@ function M.command(args)
end end
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 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, bar = true,
nargs = '*', nargs = '*',
complete = function(arg_lead, cmd_line) 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 if not cmd_line:match('^Pending%s+%S') then
return filter_candidates(arg_lead, subcmds) return filter_candidates(arg_lead, subcmds)
end end
@ -198,34 +203,26 @@ end, {
if cmd_line:match('^Pending%s+edit') then if cmd_line:match('^Pending%s+edit') then
return complete_edit(arg_lead, cmd_line) return complete_edit(arg_lead, cmd_line)
end end
if cmd_line:match('^Pending%s+sync') then local backend_set = pending.sync_backend_set()
local after_sync = cmd_line:match('^Pending%s+sync%s+(.*)') local matched_backend = cmd_line:match('^Pending%s+(%S+)')
if not after_sync then 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 {} return {}
end end
local parts = {} local ok, mod = pcall(require, 'pending.sync.' .. matched_backend)
for part in after_sync:gmatch('%S+') do if not ok then
table.insert(parts, part)
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)
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 {} return {}
end end
local actions = {}
for k, v in pairs(mod) do
if type(v) == 'function' and k:sub(1, 1) ~= '_' then
table.insert(actions, k)
end
end
table.sort(actions)
return filter_candidates(arg_lead, actions)
end
return {} return {}
end, 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) end)
describe('dispatch', function() describe('dispatch', function()
it('errors on bare :Pending sync with no backend', function() it('errors on unknown subcommand', function()
local msg local msg
local orig = vim.notify local orig = vim.notify
vim.notify = function(m, level) vim.notify = function(m, level)
@ -31,35 +31,9 @@ describe('sync', function()
msg = m msg = m
end end
end end
pending.sync(nil) pending.command('notreal')
vim.notify = orig vim.notify = orig
assert.are.equal('Usage: :Pending sync <backend> [action]', msg) assert.are.equal('Unknown Pending subcommand: notreal', 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)
end) end)
it('errors on unknown action for valid backend', function() it('errors on unknown action for valid backend', function()
@ -70,7 +44,7 @@ describe('sync', function()
msg = m msg = m
end end
end end
pending.sync('gcal', 'notreal') pending.command('gcal notreal')
vim.notify = orig vim.notify = orig
assert.are.equal("gcal backend has no 'notreal' action", msg) assert.are.equal("gcal backend has no 'notreal' action", msg)
end) end)
@ -82,7 +56,7 @@ describe('sync', function()
gcal.sync = function() gcal.sync = function()
called = true called = true
end end
pending.sync('gcal') pending.command('gcal')
gcal.sync = orig_sync gcal.sync = orig_sync
assert.is_true(called) assert.is_true(called)
end) end)
@ -94,7 +68,7 @@ describe('sync', function()
gcal.sync = function() gcal.sync = function()
called = true called = true
end end
pending.sync('gcal', 'sync') pending.command('gcal sync')
gcal.sync = orig_sync gcal.sync = orig_sync
assert.is_true(called) assert.is_true(called)
end) end)
@ -106,7 +80,7 @@ describe('sync', function()
gcal.auth = function() gcal.auth = function()
called = true called = true
end end
pending.sync('gcal', 'auth') pending.command('gcal auth')
gcal.auth = orig_auth gcal.auth = orig_auth
assert.is_true(called) assert.is_true(called)
end) end)