feat(sync): diff metadata preservation, auth unification, and sync quality improvements (#74)
* 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 * feat(sync): selective push, remote deletion detection, and gcal fix 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`. * ci: formt
This commit is contained in:
parent
1dd40c9a9f
commit
84437155bc
3 changed files with 284 additions and 17 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<string, pending.Task>
|
||||
---@return integer created
|
||||
---@return integer updated
|
||||
---@return integer failed
|
||||
---@return table<string, true> seen_remote_ids
|
||||
---@return table<string, true> 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<string, true>
|
||||
local seen_remote_ids = {}
|
||||
---@type table<string, true>
|
||||
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,38 @@ 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<string, true>
|
||||
---@param fetched_list_ids table<string, true>
|
||||
---@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 +429,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 +452,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 +482,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 +495,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 +515,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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue