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)