feat: Google Tasks bidirectional sync and CLI refactor (#59)
* 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 <backend>` 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
This commit is contained in:
parent
3e8fd0a6a3
commit
21628abe53
7 changed files with 1114 additions and 85 deletions
178
spec/gtasks_spec.lua
Normal file
178
spec/gtasks_spec.lua
Normal file
|
|
@ -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)
|
||||
|
|
@ -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 <backend> [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 <backend> [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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue