diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 80802a7..2e50fd5 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -1,6 +1,7 @@ local config = require('pending.config') local log = require('pending.log') local oauth = require('pending.sync.oauth') +local util = require('pending.sync.util') local M = {} @@ -154,21 +155,6 @@ local function unlink_remote(task, extra, now_ts) task.modified = now_ts end ----@param parts {[1]: integer, [2]: string}[] ----@return string -local function fmt_counts(parts) - local items = {} - for _, p in ipairs(parts) do - if p[1] > 0 then - table.insert(items, p[1] .. ' ' .. p[2]) - end - end - if #items == 0 then - return 'nothing to do' - end - return table.concat(items, ' | ') -end - function M.push() oauth.with_token(oauth.google_client, 'gcal', function(access_token) local calendars, cal_err = get_all_calendars(access_token) @@ -246,13 +232,8 @@ function M.push() end end - 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('gcal push: ' .. fmt_counts({ + util.finish(s) + log.info('gcal push: ' .. util.fmt_counts({ { created, 'added' }, { updated, 'updated' }, { deleted, 'removed' }, @@ -261,6 +242,32 @@ function M.push() end) end +---@param args? string +---@return nil +function M.auth(args) + if args == 'clear' then + oauth.google_client:clear_tokens() + log.info('gcal: OAuth tokens cleared — run :Pending auth gcal to re-authenticate.') + elseif args == 'reset' then + oauth.google_client:_wipe() + log.info( + 'gcal: OAuth tokens and credentials cleared — run :Pending auth gcal to set up from scratch.' + ) + else + 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 + +---@return string[] +function M.auth_complete() + return { 'clear', 'reset' } +end + ---@return nil function M.health() oauth.health(M.name) @@ -268,7 +275,7 @@ function M.health() if tokens and tokens.refresh_token then vim.health.ok('gcal tokens found') else - vim.health.info('no gcal tokens — run :Pending auth') + vim.health.info('no gcal tokens — run :Pending auth gcal') end end diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index 014e80a..9eade7d 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -1,6 +1,7 @@ local config = require('pending.config') local log = require('pending.log') local oauth = require('pending.sync.oauth') +local util = require('pending.sync.util') local M = {} @@ -195,21 +196,6 @@ local function unlink_remote(task, now_ts) task.modified = now_ts end ----@param parts {[1]: integer, [2]: string}[] ----@return string -local function fmt_counts(parts) - local items = {} - for _, p in ipairs(parts) do - if p[1] > 0 then - table.insert(items, p[1] .. ' ' .. p[2]) - end - end - if #items == 0 then - return 'nothing to do' - end - return table.concat(items, ' | ') -end - ---@param task pending.Task ---@return table local function task_to_gtask(task) @@ -447,13 +433,8 @@ function M.push() local by_gtasks_id = build_id_index(s) 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('gtasks push: ' .. fmt_counts({ + util.finish(s) + log.info('gtasks push: ' .. util.fmt_counts({ { created, 'added' }, { updated, 'updated' }, { deleted, 'deleted' }, @@ -474,13 +455,8 @@ function M.pull() 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('gtasks pull: ' .. fmt_counts({ + util.finish(s) + log.info('gtasks pull: ' .. util.fmt_counts({ { created, 'added' }, { updated, 'updated' }, { unlinked, 'unlinked' }, @@ -503,18 +479,13 @@ function M.sync() 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') - if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then - buffer.render(buffer.bufnr()) - end - log.info('gtasks sync — push: ' .. fmt_counts({ + util.finish(s) + log.info('gtasks sync — push: ' .. util.fmt_counts({ { pushed_create, 'added' }, { pushed_update, 'updated' }, { pushed_delete, 'deleted' }, { pushed_failed, 'failed' }, - }) .. ' pull: ' .. fmt_counts({ + }) .. ' pull: ' .. util.fmt_counts({ { pulled_create, 'added' }, { pulled_update, 'updated' }, { unlinked, 'unlinked' }, @@ -533,6 +504,32 @@ M._push_pass = push_pass M._pull_pass = pull_pass M._detect_remote_deletions = detect_remote_deletions +---@param args? string +---@return nil +function M.auth(args) + if args == 'clear' then + oauth.google_client:clear_tokens() + log.info('gtasks: OAuth tokens cleared — run :Pending auth gtasks to re-authenticate.') + elseif args == 'reset' then + oauth.google_client:_wipe() + log.info( + 'gtasks: OAuth tokens and credentials cleared — run :Pending auth gtasks to set up from scratch.' + ) + else + 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 + +---@return string[] +function M.auth_complete() + return { 'clear', 'reset' } +end + ---@return nil function M.health() oauth.health(M.name) @@ -540,7 +537,7 @@ function M.health() if tokens and tokens.refresh_token then vim.health.ok('gtasks tokens found') else - vim.health.info('no gtasks tokens — run :Pending auth') + vim.health.info('no gtasks tokens — run :Pending auth gtasks') end end diff --git a/spec/sync_util_spec.lua b/spec/sync_util_spec.lua new file mode 100644 index 0000000..aad4c85 --- /dev/null +++ b/spec/sync_util_spec.lua @@ -0,0 +1,101 @@ +require('spec.helpers') + +local config = require('pending.config') +local util = require('pending.sync.util') + +describe('sync util', function() + before_each(function() + config.reset() + end) + + after_each(function() + config.reset() + end) + + describe('fmt_counts', function() + it('returns nothing to do for empty counts', function() + assert.equals('nothing to do', util.fmt_counts({})) + end) + + it('returns nothing to do when all zero', function() + assert.equals('nothing to do', util.fmt_counts({ { 0, 'added' }, { 0, 'failed' } })) + end) + + it('formats single non-zero count', function() + assert.equals('3 added', util.fmt_counts({ { 3, 'added' }, { 0, 'failed' } })) + end) + + it('joins multiple non-zero counts with pipe', function() + local result = util.fmt_counts({ { 2, 'added' }, { 1, 'updated' }, { 0, 'failed' } }) + assert.equals('2 added | 1 updated', result) + end) + end) + + describe('with_guard', function() + it('prevents concurrent calls', function() + local inner_called = false + local blocked = false + + local msgs = {} + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.WARN then + table.insert(msgs, m) + end + end + + util.with_guard('test', function() + inner_called = true + util.with_guard('test2', function() + blocked = true + end) + end) + + vim.notify = orig + assert.is_true(inner_called) + assert.is_false(blocked) + assert.equals(1, #msgs) + assert.truthy(msgs[1]:find('Sync already in progress')) + end) + + it('clears guard after error', function() + pcall(util.with_guard, 'err-test', function() + error('boom') + end) + + assert.is_false(util.sync_in_flight()) + end) + + it('clears guard after success', function() + util.with_guard('ok-test', function() end) + assert.is_false(util.sync_in_flight()) + end) + end) + + describe('finish', function() + it('calls save and recompute', function() + local helpers = require('spec.helpers') + local store_mod = require('pending.store') + local tmpdir = helpers.tmpdir() + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + config.reset() + package.loaded['pending'] = nil + local pending = require('pending') + + local s = store_mod.new(tmpdir .. '/tasks.json') + s:load() + s:add({ description = 'Test', status = 'pending', category = 'Work', priority = 0 }) + + util.finish(s) + + local reloaded = store_mod.new(tmpdir .. '/tasks.json') + reloaded:load() + assert.equals(1, #reloaded:tasks()) + + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + package.loaded['pending'] = nil + end) + end) +end)