From a6be248cbf8dc4b556cc6eb6e07525937eebec04 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:01:29 -0500 Subject: [PATCH] feat: Google Tasks bidirectional sync and CLI refactor (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 ` 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 --- doc/pending.txt | 136 ++++++- lua/pending/config.lua | 4 + lua/pending/init.lua | 31 +- lua/pending/sync/gtasks.lua | 767 ++++++++++++++++++++++++++++++++++++ plugin/pending.lua | 43 +- spec/gtasks_spec.lua | 178 +++++++++ spec/sync_spec.lua | 40 +- 7 files changed, 1114 insertions(+), 85 deletions(-) create mode 100644 lua/pending/sync/gtasks.lua create mode 100644 spec/gtasks_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt index 01728a3..08c6315 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -41,6 +41,7 @@ Features: ~ - Foldable category sections (`zc`/`zo`) in category view - Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (``) - 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/.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/.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 `. - {auth} Authorization flow. Called by `:Pending sync auth`. + {sync} Main sync action. Called by `:Pending `. + {auth} Authorization flow. Called by `:Pending auth`. Optional fields: ~ + {push} Push-only action. Called by `:Pending push`. + {pull} Pull-only action. Called by `:Pending 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 diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 09c5cf0..58da035 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -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 diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 12b6a7e..f4f7264 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -523,14 +523,19 @@ function M.add(text) vim.notify('Pending added: ' .. description) end +---@type string[] +local SYNC_BACKENDS = { 'gcal', 'gtasks' } + +---@type table +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 [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 +function M.sync_backend_set() + return SYNC_BACKEND_SET +end + return M diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua new file mode 100644 index 0000000..7476ee6 --- /dev/null +++ b/lua/pending/sync/gtasks.lua @@ -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 +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 '

Authorization successful

You can close this tab.

' + or '

Authorization failed

' + 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? 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 +---@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 +local function build_id_index(s) + ---@type table + 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 +---@param s pending.Store +---@param now_ts string +---@param by_gtasks_id table +---@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 +---@param s pending.Store +---@param now_ts string +---@param by_gtasks_id table +---@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? 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 + ---@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 + ---@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 + ---@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 diff --git a/plugin/pending.lua b/plugin/pending.lua index f533dcf..13f16d3 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -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, diff --git a/spec/gtasks_spec.lua b/spec/gtasks_spec.lua new file mode 100644 index 0000000..19328d9 --- /dev/null +++ b/spec/gtasks_spec.lua @@ -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) diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua index 9e24e7d..ce38635 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -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 [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 [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)