From ad59e894c7ce7fdbd5ab273e5fb9df6c4012be90 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:08:22 -0500 Subject: [PATCH 1/3] feat(sync): unify Google auth under :Pending auth (#72) * feat(sync): unify Google auth under :Pending auth Problem: users had to run `:Pending gtasks auth` and `:Pending gcal auth` separately, producing two token files and two browser consents for the same Google account. Solution: introduce `oauth.google_client` with combined tasks + calendar scopes and a single `google_tokens.json`. Remove per-backend `auth`/`setup` from `gcal` and `gtasks`; add top-level `:Pending auth` that prompts with `vim.ui.select` and delegates to the shared client's `setup()` or `auth()` based on credential availability. * docs: update vimdoc for unified Google auth Problem: `doc/pending.txt` still documented per-backend `:Pending gtasks auth` / `:Pending gcal auth` commands and separate token files, which no longer exist after the auth unification. Solution: add `:Pending auth` entry to COMMANDS and a new `*pending-google-auth*` section covering the shared PKCE flow, combined scopes, and `google_tokens.json`. Remove `auth` from gcal/gtasks action tables and update all cross-references to use `:Pending auth`. * ci: format --- doc/pending.txt | 81 ++++++++++++++++++++++++++----------- lua/pending/init.lua | 20 +++++++++ lua/pending/sync/gcal.lua | 24 ++--------- lua/pending/sync/gtasks.lua | 28 +++---------- lua/pending/sync/oauth.lua | 8 ++++ plugin/pending.lua | 2 +- spec/sync_spec.lua | 16 +++----- 7 files changed, 102 insertions(+), 77 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 2465ba3..994afc6 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -65,8 +65,9 @@ CONTENTS *pending-contents* 17. Sync Backends ................................... |pending-sync-backend| 18. Google Calendar .......................................... |pending-gcal| 19. Google Tasks ............................................ |pending-gtasks| - 20. Data Format .............................................. |pending-data| - 21. Health Check ........................................... |pending-health| + 20. Google Authentication ......................... |pending-google-auth| + 21. Data Format .............................................. |pending-data| + 22. Health Check ........................................... |pending-health| ============================================================================== REQUIREMENTS *pending-requirements* @@ -148,6 +149,15 @@ 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-auth* +:Pending auth + Authorize pending.nvim to access Google services (Tasks and Calendar). + Prompts with |vim.ui.select| to choose gtasks, gcal, or both — all + options run the same combined OAuth flow and produce a single shared + token file. If no credentials are configured, the setup wizard runs + first to collect a client ID and secret. + See |pending-google-auth| for full details. + *:Pending-gtasks* :Pending gtasks {action} Run a Google Tasks action. An explicit action is required. @@ -156,13 +166,11 @@ COMMANDS *pending-commands* `sync` Push local changes then pull remote changes. `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 gtasks sync " push then pull :Pending gtasks push " push local → Google Tasks :Pending gtasks pull " pull Google Tasks → local - :Pending gtasks auth " authorize < Tab completion after `:Pending gtasks ` lists available actions. @@ -174,11 +182,9 @@ COMMANDS *pending-commands* Actions: ~ `push` Push tasks with due dates to Google Calendar. - `auth` Run the OAuth authorization flow. Examples: >vim :Pending gcal push " push to Google Calendar - :Pending gcal auth " authorize < Tab completion after `:Pending gcal ` lists available actions. @@ -920,7 +926,6 @@ Each module returns a table conforming to the backend interface: >lua ---@class pending.SyncBackend ---@field name string - ---@field auth fun(): nil ---@field push? fun(): nil ---@field pull? fun(): nil ---@field sync? fun(): nil @@ -929,15 +934,17 @@ Each module returns a table conforming to the backend interface: >lua Required fields: ~ {name} Backend identifier (matches the filename). - {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`. + {sync} Main sync action. Called by `:Pending sync`. {health} Called by `:checkhealth pending` to report backend-specific diagnostics (e.g. checking for external tools). +Note: authorization is not a per-backend action. Use `:Pending auth` to +authenticate all Google backends at once. See |pending-google-auth|. + Backend-specific configuration goes under `sync.` in |pending-config|. ============================================================================== @@ -957,7 +964,7 @@ Configuration: >lua < No configuration is required to get started — bundled OAuth credentials are -used by default. Run `:Pending gcal auth` and the browser opens immediately. +used by default. Run `:Pending auth` and the browser opens immediately. *pending.GcalConfig* Fields: ~ @@ -983,15 +990,8 @@ Credentials are resolved in order: 3. Bundled credentials shipped with the plugin (always available). OAuth flow: ~ -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. After -the user grants consent, the -authorization code is exchanged for tokens and the refresh token is stored at -`stdpath('data')/pending/gcal_tokens.json` with mode `600`. Subsequent syncs -use the stored refresh token and refresh the access token automatically when -it is about to expire. +See |pending-google-auth|. Tokens are shared with the gtasks backend and +stored at `stdpath('data')/pending/google_tokens.json`. `:Pending gcal push` behavior: ~ For each task in the store: @@ -1020,7 +1020,7 @@ Configuration: >lua < No configuration is required to get started — bundled OAuth credentials are -used by default. Run `:Pending gtasks auth` and the browser opens immediately. +used by default. Run `:Pending auth` and the browser opens immediately. *pending.GtasksConfig* Fields: ~ @@ -1043,9 +1043,8 @@ Credential resolution: ~ Same three-tier resolution as the gcal backend (see |pending-gcal|). 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. +See |pending-google-auth|. Tokens are shared with the gcal backend and +stored at `stdpath('data')/pending/google_tokens.json`. `:Pending gtasks` actions: ~ @@ -1077,6 +1076,42 @@ 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). +============================================================================== +GOOGLE AUTHENTICATION *pending-google-auth* + +Both the gcal and gtasks backends share a single OAuth client with combined +scopes (`tasks` + `calendar`). One authorization flow covers both services +and produces one token file. + +:Pending auth ~ +Prompts with |vim.ui.select| offering three options: `gtasks`, `gcal`, and +`both`. All three options run the identical combined OAuth flow — the choice +is informational only. If no real credentials are configured (i.e. bundled +placeholders are in use), the setup wizard runs first to collect a client ID +and client secret before opening the browser. + +OAuth flow: ~ +A PKCE (Proof Key for Code Exchange) flow is used: +1. A random 64-character `code_verifier` is generated. +2. Its SHA-256 hash is base64url-encoded as the `code_challenge`. +3. The Google authorization URL is opened in the browser via |vim.ui.open()|. +4. A temporary TCP server on port 18392 waits up to 120 seconds for the + OAuth redirect. +5. The authorization code is exchanged for tokens via `curl`. +6. The refresh token is written to + `stdpath('data')/pending/google_tokens.json` with mode `600`. +7. Subsequent syncs refresh the access token automatically when it is about + to expire (within 60 seconds of the `expires_in` window). + +Credential resolution: ~ +Credentials are resolved in order for the `google` config key: +1. `client_id` + `client_secret` under `sync.google` (highest priority). +2. JSON file at `sync.google.credentials_path` or the default path + `stdpath('data')/pending/google_credentials.json`. +3. Bundled placeholder credentials (always available; trigger setup wizard). + +The `installed` wrapper format from the Google Cloud Console is accepted. + ============================================================================== DATA FORMAT *pending-data* diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 1e05c36..446d375 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -828,6 +828,24 @@ function M.edit(id_str, rest) log.info('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', ')) end +---@return nil +function M.auth() + local oauth = require('pending.sync.oauth') + vim.ui.select({ 'gtasks', 'gcal', 'both' }, { + prompt = 'Authenticate:', + }, function(choice) + if not choice then + return + end + local creds = oauth.google_client:resolve_credentials() + if creds.client_id == oauth.BUNDLED_CLIENT_ID then + oauth.google_client:setup() + else + oauth.google_client:auth() + end + end) +end + ---@param args string ---@return nil function M.command(args) @@ -841,6 +859,8 @@ 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 == 'auth' then + M.auth() elseif SYNC_BACKEND_SET[cmd] then local action = rest:match('^(%S+)') run_sync(cmd, action) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index bddb461..99b9e76 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -7,14 +7,6 @@ local M = {} M.name = 'gcal' local BASE_URL = 'https://www.googleapis.com/calendar/v3' -local SCOPE = 'https://www.googleapis.com/auth/calendar' - -local client = oauth.new({ - name = 'gcal', - scope = SCOPE, - port = 18392, - config_key = 'gcal', -}) ---@param access_token string ---@return table? name_to_id @@ -139,15 +131,15 @@ end ---@param callback fun(access_token: string): nil local function with_token(callback) oauth.async(function() - local token = client:get_access_token() + local token = oauth.google_client:get_access_token() if not token then - client:auth(function() + oauth.google_client:auth(function() oauth.async(function() - local fresh = client:get_access_token() + local fresh = oauth.google_client:get_access_token() if fresh then callback(fresh) else - log.error(client.name .. ': authorization failed or was cancelled') + log.error(oauth.google_client.name .. ': authorization failed or was cancelled') end end) end) @@ -157,14 +149,6 @@ local function with_token(callback) end) end -function M.setup() - client:setup() -end - -function M.auth() - client:auth() -end - function M.push() with_token(function(access_token) local calendars, cal_err = get_all_calendars(access_token) diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index d1ae10f..d747c51 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -7,14 +7,6 @@ local M = {} M.name = 'gtasks' local BASE_URL = 'https://tasks.googleapis.com/tasks/v1' -local SCOPE = 'https://www.googleapis.com/auth/tasks' - -local client = oauth.new({ - name = 'gtasks', - scope = SCOPE, - port = 18393, - config_key = 'gtasks', -}) ---@param access_token string ---@return table? name_to_id @@ -355,15 +347,15 @@ end ---@param callback fun(access_token: string): nil local function with_token(callback) oauth.async(function() - local token = client:get_access_token() + local token = oauth.google_client:get_access_token() if not token then - client:auth(function() + oauth.google_client:auth(function() oauth.async(function() - local fresh = client:get_access_token() + local fresh = oauth.google_client:get_access_token() if fresh then callback(fresh) else - log.error(client.name .. ': authorization failed or was cancelled') + log.error(oauth.google_client.name .. ': authorization failed or was cancelled') end end) end) @@ -373,14 +365,6 @@ local function with_token(callback) end) end -function M.setup() - client:setup() -end - -function M.auth() - client:auth() -end - function M.push() with_token(function(access_token) local tasklists, s, now_ts = sync_setup(access_token) @@ -462,11 +446,11 @@ M._gtask_to_fields = gtask_to_fields ---@return nil function M.health() oauth.health(M.name) - local tokens = client:load_tokens() + local tokens = oauth.google_client:load_tokens() if tokens and tokens.refresh_token then vim.health.ok('gtasks tokens found') else - vim.health.info('no gtasks tokens — run :Pending gtasks auth') + vim.health.info('no gtasks tokens — run :Pending auth') end end diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index cb490e4..887769c 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -501,5 +501,13 @@ end M._BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID M._BUNDLED_CLIENT_SECRET = BUNDLED_CLIENT_SECRET +M.BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID + +M.google_client = M.new({ + name = 'google', + scope = 'https://www.googleapis.com/auth/tasks' .. ' https://www.googleapis.com/auth/calendar', + port = 18392, + config_key = 'google', +}) return M diff --git a/plugin/pending.lua b/plugin/pending.lua index f6ed6bb..cba4916 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -167,7 +167,7 @@ end, { nargs = '*', complete = function(arg_lead, cmd_line) local pending = require('pending') - local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'undo' } + local subcmds = { 'add', 'archive', 'auth', 'due', 'edit', 'filter', 'undo' } for _, b in ipairs(pending.sync_backends()) do table.insert(subcmds, b) end diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua index 20a85c1..a491dd3 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -73,15 +73,14 @@ describe('sync', function() assert.is_true(called) end) - it('routes auth action', function() + it('routes auth command', function() local called = false - local gcal = require('pending.sync.gcal') - local orig_auth = gcal.auth - gcal.auth = function() + local orig_auth = pending.auth + pending.auth = function() called = true end - pending.command('gcal auth') - gcal.auth = orig_auth + pending.command('auth') + pending.auth = orig_auth assert.is_true(called) end) end) @@ -102,11 +101,6 @@ describe('sync', function() assert.are.equal('gcal', gcal.name) end) - it('has auth function', function() - local gcal = require('pending.sync.gcal') - assert.are.equal('function', type(gcal.auth)) - end) - it('has push function', function() local gcal = require('pending.sync.gcal') assert.are.equal('function', type(gcal.push)) From 3795999f3d9b25fcb85368fb11046b10cced9f0f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 22:37:34 -0500 Subject: [PATCH 2/3] feat(sync): selective push, remote deletion detection, and gcal fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: `push_pass` updated all remote-linked tasks unconditionally, causing unnecessary API calls and potential clobbering of remote edits made between syncs. `pull`/`sync` never noticed when a task disappeared from remote. `update_event` omitted `transparency` that `create_event` set. Failure counts were absent from sync log summaries. Solution: Introduce `_gtasks_synced_at` in `_extra` — stamped after every successful push/pull create or update — so `push_pass` skips tasks unchanged since last sync. Add `detect_remote_deletions` to unlink local tasks whose remote entry disappeared from a successfully fetched list. Surface failures as `!N` in all sync logs and `unlinked: N` for pull/sync. Add `transparency = 'transparent'` to `update_event`. Cover new behaviour with 7 tests in `gtasks_spec.lua`. --- lua/pending/sync/gcal.lua | 1 + lua/pending/sync/gtasks.lua | 106 ++++++++++++++++---- spec/gtasks_spec.lua | 190 ++++++++++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 17 deletions(-) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 99b9e76..f90d7c1 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -97,6 +97,7 @@ local function update_event(access_token, calendar_id, event_id, task) summary = task.description, start = { date = task.due }, ['end'] = { date = next_day(task.due or '') }, + transparency = 'transparent', } local _, err = oauth.curl_request( 'PATCH', diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index d747c51..0c0239e 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -231,8 +231,9 @@ end ---@return integer created ---@return integer updated ---@return integer deleted +---@return integer failed local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) - local created, updated, deleted = 0, 0, 0 + local created, updated, deleted, failed = 0, 0, 0, 0 for _, task in ipairs(s:tasks()) do local extra = task._extra or {} local gtid = extra['_gtasks_task_id'] --[[@as string?]] @@ -242,12 +243,14 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) local err = delete_gtask(access_token, list_id, gtid) if err then log.warn('gtasks delete failed: ' .. err) + failed = failed + 1 else if not task._extra then task._extra = {} end task._extra['_gtasks_task_id'] = nil task._extra['_gtasks_list_id'] = nil + task._extra['_gtasks_synced_at'] = nil if next(task._extra) == nil then task._extra = nil end @@ -256,11 +259,17 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) 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 err then - log.warn('gtasks update failed: ' .. err) - else - updated = updated + 1 + local synced_at = extra['_gtasks_synced_at'] --[[@as string?]] + if not synced_at or task.modified > synced_at then + local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task)) + if err then + log.warn('gtasks update failed: ' .. err) + failed = failed + 1 + else + task._extra = task._extra or {} + task._extra['_gtasks_synced_at'] = now_ts + updated = updated + 1 + end end elseif task.status == 'pending' then local cat = task.category or config.get().default_category @@ -269,12 +278,14 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task)) if create_err then log.warn('gtasks create failed: ' .. create_err) + failed = failed + 1 elseif new_id then if not task._extra then task._extra = {} end task._extra['_gtasks_task_id'] = new_id task._extra['_gtasks_list_id'] = lid + task._extra['_gtasks_synced_at'] = now_ts task.modified = now_ts by_gtasks_id[new_id] = task created = created + 1 @@ -283,7 +294,7 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) end end end - return created, updated, deleted + return created, updated, deleted, failed end ---@param access_token string @@ -293,14 +304,24 @@ end ---@param by_gtasks_id table ---@return integer created ---@return integer updated +---@return integer failed +---@return table seen_remote_ids +---@return table fetched_list_ids local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) - local created, updated = 0, 0 + local created, updated, failed = 0, 0, 0 + ---@type table + local seen_remote_ids = {} + ---@type table + local fetched_list_ids = {} for list_name, list_id in pairs(tasklists) do local items, err = list_gtasks(access_token, list_id) if err then log.warn('error fetching list ' .. list_name .. ': ' .. err) + failed = failed + 1 else + fetched_list_ids[list_id] = true for _, gtask in ipairs(items or {}) do + seen_remote_ids[gtask.id] = true local local_task = by_gtasks_id[gtask.id] if local_task then local gtask_updated = gtask.updated or '' @@ -310,6 +331,8 @@ local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) for k, v in pairs(fields) do local_task[k] = v end + local_task._extra = local_task._extra or {} + local_task._extra['_gtasks_synced_at'] = now_ts local_task.modified = now_ts updated = updated + 1 end @@ -318,6 +341,7 @@ local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) fields._extra = { _gtasks_task_id = gtask.id, _gtasks_list_id = list_id, + _gtasks_synced_at = now_ts, } local new_task = s:add(fields) by_gtasks_id[gtask.id] = new_task @@ -326,7 +350,34 @@ local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) end end end - return created, updated + return created, updated, failed, seen_remote_ids, fetched_list_ids +end + +---@param s pending.Store +---@param seen_remote_ids table +---@param fetched_list_ids table +---@param now_ts string +---@return integer unlinked +local function detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts) + local unlinked = 0 + for _, task in ipairs(s:tasks()) do + local extra = task._extra or {} + local gtid = extra['_gtasks_task_id'] + local list_id = extra['_gtasks_list_id'] + if task.status ~= 'deleted' + and gtid and list_id + and fetched_list_ids[list_id] + and not seen_remote_ids[gtid] + then + task._extra['_gtasks_task_id'] = nil + task._extra['_gtasks_list_id'] = nil + task._extra['_gtasks_synced_at'] = nil + if next(task._extra) == nil then task._extra = nil end + task.modified = now_ts + unlinked = unlinked + 1 + end + end + return unlinked end ---@param access_token string @@ -374,14 +425,17 @@ function M.push() ---@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) + local created, updated, deleted, failed = + push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) s:save() require('pending')._recompute_counts() local buffer = require('pending.buffer') if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render(buffer.bufnr()) end - log.info(string.format('Google Tasks pushed — +%d ~%d -%d', created, updated, deleted)) + log.info( + string.format('Google Tasks pushed — +%d ~%d -%d !%d', created, updated, deleted, failed) + ) end) end @@ -394,14 +448,24 @@ function M.pull() ---@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) + local created, updated, failed, seen_remote_ids, fetched_list_ids = + pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local unlinked = detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts) s:save() require('pending')._recompute_counts() local buffer = require('pending.buffer') if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render(buffer.bufnr()) end - log.info(string.format('Google Tasks pulled — +%d ~%d', created, updated)) + log.info( + string.format( + 'Google Tasks pulled — +%d ~%d !%d, unlinked: %d', + created, + updated, + failed, + unlinked + ) + ) end) end @@ -414,9 +478,11 @@ function M.sync() ---@cast s pending.Store ---@cast now_ts string local by_gtasks_id = build_id_index(s) - local pushed_create, pushed_update, pushed_delete = + local pushed_create, pushed_update, pushed_delete, pushed_failed = push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) - local pulled_create, pulled_update = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local pulled_create, pulled_update, pulled_failed, seen_remote_ids, fetched_list_ids = + pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local unlinked = detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts) s:save() require('pending')._recompute_counts() local buffer = require('pending.buffer') @@ -425,12 +491,15 @@ function M.sync() end log.info( string.format( - 'Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d', + 'Google Tasks synced — push: +%d ~%d -%d !%d, pull: +%d ~%d !%d, unlinked: %d', pushed_create, pushed_update, pushed_delete, + pushed_failed, pulled_create, - pulled_update + pulled_update, + pulled_failed, + unlinked ) ) end) @@ -442,6 +511,9 @@ M._build_notes = build_notes M._parse_notes = parse_notes M._task_to_gtask = task_to_gtask M._gtask_to_fields = gtask_to_fields +M._push_pass = push_pass +M._pull_pass = pull_pass +M._detect_remote_deletions = detect_remote_deletions ---@return nil function M.health() diff --git a/spec/gtasks_spec.lua b/spec/gtasks_spec.lua index 19328d9..1e0f7ef 100644 --- a/spec/gtasks_spec.lua +++ b/spec/gtasks_spec.lua @@ -176,3 +176,193 @@ describe('gtasks field conversion', function() end) end) end) + +describe('gtasks push_pass _gtasks_synced_at', function() + local helpers = require('spec.helpers') + local store_mod = require('pending.store') + local oauth = require('pending.sync.oauth') + local s + local orig_curl + + before_each(function() + local dir = helpers.tmpdir() + s = store_mod.new(dir .. '/pending.json') + s:load() + orig_curl = oauth.curl_request + end) + + after_each(function() + oauth.curl_request = orig_curl + end) + + it('sets _gtasks_synced_at after push create', function() + local task = + s:add({ description = 'New task', status = 'pending', category = 'Work', priority = 0 }) + + oauth.curl_request = function(method, url, _headers, _body) + if method == 'POST' and url:find('/tasks$') then + return { id = 'gtask-new-1' }, nil + end + return {}, nil + end + + local now_ts = '2026-03-05T10:00:00Z' + local tasklists = { Work = 'list-1' } + local by_id = {} + gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id) + + assert.is_not_nil(task._extra) + assert.equals('2026-03-05T10:00:00Z', task._extra['_gtasks_synced_at']) + end) + + it('skips update when modified <= _gtasks_synced_at', function() + local task = + s:add({ description = 'Existing task', status = 'pending', category = 'Work', priority = 0 }) + task._extra = { + _gtasks_task_id = 'remote-1', + _gtasks_list_id = 'list-1', + _gtasks_synced_at = '2026-03-05T10:00:00Z', + } + task.modified = '2026-03-05T09:00:00Z' + + local patch_called = false + oauth.curl_request = function(method, _url, _headers, _body) + if method == 'PATCH' then + patch_called = true + end + return {}, nil + end + + local now_ts = '2026-03-05T11:00:00Z' + local tasklists = { Work = 'list-1' } + local by_id = { ['remote-1'] = task } + gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id) + + assert.is_false(patch_called) + end) + + it('pushes update when modified > _gtasks_synced_at', function() + local task = + s:add({ description = 'Changed task', status = 'pending', category = 'Work', priority = 0 }) + task._extra = { + _gtasks_task_id = 'remote-2', + _gtasks_list_id = 'list-1', + _gtasks_synced_at = '2026-03-05T08:00:00Z', + } + task.modified = '2026-03-05T09:00:00Z' + + local patch_called = false + oauth.curl_request = function(method, _url, _headers, _body) + if method == 'PATCH' then + patch_called = true + end + return {}, nil + end + + local now_ts = '2026-03-05T11:00:00Z' + local tasklists = { Work = 'list-1' } + local by_id = { ['remote-2'] = task } + gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id) + + assert.is_true(patch_called) + end) + + it('pushes update when no _gtasks_synced_at (backwards compat)', function() + local task = + s:add({ description = 'Old task', status = 'pending', category = 'Work', priority = 0 }) + task._extra = { + _gtasks_task_id = 'remote-3', + _gtasks_list_id = 'list-1', + } + task.modified = '2026-01-01T00:00:00Z' + + local patch_called = false + oauth.curl_request = function(method, _url, _headers, _body) + if method == 'PATCH' then + patch_called = true + end + return {}, nil + end + + local now_ts = '2026-03-05T11:00:00Z' + local tasklists = { Work = 'list-1' } + local by_id = { ['remote-3'] = task } + gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id) + + assert.is_true(patch_called) + end) +end) + +describe('gtasks detect_remote_deletions', function() + local helpers = require('spec.helpers') + local store_mod = require('pending.store') + local s + + before_each(function() + local dir = helpers.tmpdir() + s = store_mod.new(dir .. '/pending.json') + s:load() + end) + + it('clears remote IDs when list was fetched but task ID is absent', function() + local task = + s:add({ description = 'Gone remote', status = 'pending', category = 'Work', priority = 0 }) + task._extra = { + _gtasks_task_id = 'old-remote-id', + _gtasks_list_id = 'list-1', + _gtasks_synced_at = '2026-01-01T00:00:00Z', + } + + local seen = {} + local fetched = { ['list-1'] = true } + local now_ts = '2026-03-05T10:00:00Z' + + local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts) + + assert.equals(1, unlinked) + assert.is_nil(task._extra) + assert.equals('2026-03-05T10:00:00Z', task.modified) + end) + + it('leaves task untouched when its list fetch failed', function() + local task = s:add({ + description = 'Unknown list task', + status = 'pending', + category = 'Work', + priority = 0, + }) + task._extra = { + _gtasks_task_id = 'remote-id', + _gtasks_list_id = 'list-unfetched', + } + + local seen = {} + local fetched = {} + local now_ts = '2026-03-05T10:00:00Z' + + local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts) + + assert.equals(0, unlinked) + assert.is_not_nil(task._extra) + assert.equals('remote-id', task._extra['_gtasks_task_id']) + end) + + it('skips tasks with status == deleted', function() + local task = + s:add({ description = 'Deleted task', status = 'deleted', category = 'Work', priority = 0 }) + task._extra = { + _gtasks_task_id = 'remote-del', + _gtasks_list_id = 'list-1', + } + + local seen = {} + local fetched = { ['list-1'] = true } + local now_ts = '2026-03-05T10:00:00Z' + + local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts) + + assert.equals(0, unlinked) + assert.is_not_nil(task._extra) + assert.equals('remote-del', task._extra['_gtasks_task_id']) + end) +end) From 600aa89ec72af326ba421f9ce8357fa4da76f925 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 22:39:56 -0500 Subject: [PATCH 3/3] ci: formt --- lua/pending/sync/gtasks.lua | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index 0c0239e..9fc7459 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -364,15 +364,19 @@ local function detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now local extra = task._extra or {} local gtid = extra['_gtasks_task_id'] local list_id = extra['_gtasks_list_id'] - if task.status ~= 'deleted' - and gtid and list_id + if + task.status ~= 'deleted' + and gtid + and list_id and fetched_list_ids[list_id] and not seen_remote_ids[gtid] then task._extra['_gtasks_task_id'] = nil task._extra['_gtasks_list_id'] = nil task._extra['_gtasks_synced_at'] = nil - if next(task._extra) == nil then task._extra = nil end + if next(task._extra) == nil then + task._extra = nil + end task.modified = now_ts unlinked = unlinked + 1 end