diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 09c5cf0..58da035 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -10,8 +10,12 @@ ---@field calendar? string ---@field credentials_path? string +---@class pending.GtasksConfig +---@field credentials_path? string + ---@class pending.SyncConfig ---@field gcal? pending.GcalConfig +---@field gtasks? pending.GtasksConfig ---@class pending.Keymaps ---@field close? string|false diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua new file mode 100644 index 0000000..c6afe09 --- /dev/null +++ b/lua/pending/sync/gtasks.lua @@ -0,0 +1,770 @@ +local config = require('pending.config') + +local M = {} + +M.name = 'gtasks' + +local BASE_URL = 'https://tasks.googleapis.com/tasks/v1' +local TOKEN_URL = 'https://oauth2.googleapis.com/token' +local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' +local SCOPE = 'https://www.googleapis.com/auth/tasks' + +---@class pending.GtasksCredentials +---@field client_id string +---@field client_secret string +---@field redirect_uris? string[] + +---@class pending.GtasksTokens +---@field access_token string +---@field refresh_token string +---@field expires_in? integer +---@field obtained_at? integer + +---@return table +local function gtasks_config() + local cfg = config.get() + return (cfg.sync and cfg.sync.gtasks) or {} +end + +---@return string +local function token_path() + return vim.fn.stdpath('data') .. '/pending/gtasks_tokens.json' +end + +---@return string +local function credentials_path() + local gc = gtasks_config() + return gc.credentials_path or (vim.fn.stdpath('data') .. '/pending/gtasks_credentials.json') +end + +---@param path string +---@return table? +local function load_json_file(path) + local f = io.open(path, 'r') + if not f then + return nil + end + local content = f:read('*a') + f:close() + if content == '' then + return nil + end + local ok, decoded = pcall(vim.json.decode, content) + if not ok then + return nil + end + return decoded +end + +---@param path string +---@param data table +---@return boolean +local function save_json_file(path, data) + local dir = vim.fn.fnamemodify(path, ':h') + if vim.fn.isdirectory(dir) == 0 then + vim.fn.mkdir(dir, 'p') + end + local f = io.open(path, 'w') + if not f then + return false + end + f:write(vim.json.encode(data)) + f:close() + vim.fn.setfperm(path, 'rw-------') + return true +end + +---@return pending.GtasksCredentials? +local function load_credentials() + local creds = load_json_file(credentials_path()) + if not creds then + return nil + end + if creds.installed then + return creds.installed --[[@as pending.GtasksCredentials]] + end + return creds --[[@as pending.GtasksCredentials]] +end + +---@return pending.GtasksTokens? +local function load_tokens() + return load_json_file(token_path()) --[[@as pending.GtasksTokens?]] +end + +---@param tokens pending.GtasksTokens +---@return boolean +local function save_tokens(tokens) + return save_json_file(token_path(), tokens) +end + +---@param str string +---@return string +local function url_encode(str) + return ( + str:gsub('([^%w%-%.%_%~])', function(c) + return string.format('%%%02X', string.byte(c)) + end) + ) +end + +---@param method string +---@param url string +---@param headers? string[] +---@param body? string +---@return table? result +---@return string? err +local function curl_request(method, url, headers, body) + local args = { 'curl', '-s', '-X', method } + for _, h in ipairs(headers or {}) do + table.insert(args, '-H') + table.insert(args, h) + end + if body then + table.insert(args, '-d') + table.insert(args, body) + end + table.insert(args, url) + local result = vim.system(args, { text = true }):wait() + if result.code ~= 0 then + return nil, 'curl failed: ' .. (result.stderr or '') + end + if not result.stdout or result.stdout == '' then + return {}, nil + end + local ok, decoded = pcall(vim.json.decode, result.stdout) + if not ok then + return nil, 'failed to parse response: ' .. result.stdout + end + if decoded.error then + return nil, 'API error: ' .. (decoded.error.message or vim.json.encode(decoded.error)) + end + return decoded, nil +end + +---@param access_token string +---@return string[] +local function auth_headers(access_token) + return { + 'Authorization: Bearer ' .. access_token, + 'Content-Type: application/json', + } +end + +---@param creds pending.GtasksCredentials +---@param tokens pending.GtasksTokens +---@return pending.GtasksTokens? +local function refresh_access_token(creds, tokens) + local body = 'client_id=' + .. url_encode(creds.client_id) + .. '&client_secret=' + .. url_encode(creds.client_secret) + .. '&grant_type=refresh_token' + .. '&refresh_token=' + .. url_encode(tokens.refresh_token) + local result = vim + .system({ + 'curl', + '-s', + '-X', + 'POST', + '-H', + 'Content-Type: application/x-www-form-urlencoded', + '-d', + body, + TOKEN_URL, + }, { text = true }) + :wait() + if result.code ~= 0 then + return nil + end + local ok, decoded = pcall(vim.json.decode, result.stdout or '') + if not ok or not decoded.access_token then + return nil + end + tokens.access_token = decoded.access_token --[[@as string]] + tokens.expires_in = decoded.expires_in --[[@as integer?]] + tokens.obtained_at = os.time() + save_tokens(tokens) + return tokens +end + +---@return string? +local function get_access_token() + local creds = load_credentials() + if not creds then + vim.notify( + 'pending.nvim: No Google credentials found at ' .. credentials_path(), + vim.log.levels.ERROR + ) + return nil + end + local tokens = load_tokens() + if not tokens or not tokens.refresh_token then + M.auth() + tokens = load_tokens() + if not tokens then + return nil + end + end + local now = os.time() + local obtained = tokens.obtained_at or 0 + local expires = tokens.expires_in or 3600 + if now - obtained > expires - 60 then + tokens = refresh_access_token(creds, tokens) + if not tokens then + vim.notify('pending.nvim: Failed to refresh access token.', vim.log.levels.ERROR) + return nil + end + end + return tokens.access_token +end + +function M.auth() + local creds = load_credentials() + if not creds then + vim.notify( + 'pending.nvim: No Google credentials found at ' .. credentials_path(), + vim.log.levels.ERROR + ) + return + end + + local port = 18393 + local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' + local verifier = {} + math.randomseed(os.time()) + for _ = 1, 64 do + local idx = math.random(1, #verifier_chars) + table.insert(verifier, verifier_chars:sub(idx, idx)) + end + local code_verifier = table.concat(verifier) + + local sha_pipe = vim + .system({ + 'sh', + '-c', + 'printf "%s" "' + .. code_verifier + .. '" | openssl dgst -sha256 -binary | openssl base64 -A | tr "+/" "-_" | tr -d "="', + }, { text = true }) + :wait() + local code_challenge = sha_pipe.stdout or '' + + local auth_url = AUTH_URL + .. '?client_id=' + .. url_encode(creds.client_id) + .. '&redirect_uri=' + .. url_encode('http://127.0.0.1:' .. port) + .. '&response_type=code' + .. '&scope=' + .. url_encode(SCOPE) + .. '&access_type=offline' + .. '&prompt=consent' + .. '&code_challenge=' + .. url_encode(code_challenge) + .. '&code_challenge_method=S256' + + vim.ui.open(auth_url) + vim.notify('pending.nvim: Opening browser for Google authorization...') + + local server = vim.uv.new_tcp() + server:bind('127.0.0.1', port) + server:listen(1, function(err) + if err then + return + end + local client = vim.uv.new_tcp() + server:accept(client) + client:read_start(function(read_err, data) + if read_err or not data then + return + end + local code = data:match('[?&]code=([^&%s]+)') + local response_body = code + and '

Authorization successful

You can close this tab.

' + or '

Authorization failed

' + local http_response = 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n' + .. response_body + client:write(http_response, function() + client:shutdown(function() + client:close() + end) + end) + server:close() + if code then + vim.schedule(function() + M._exchange_code(creds, code, code_verifier, port) + end) + end + end) + end) +end + +---@param creds pending.GtasksCredentials +---@param code string +---@param code_verifier string +---@param port integer +function M._exchange_code(creds, code, code_verifier, port) + local body = 'client_id=' + .. url_encode(creds.client_id) + .. '&client_secret=' + .. url_encode(creds.client_secret) + .. '&code=' + .. url_encode(code) + .. '&code_verifier=' + .. url_encode(code_verifier) + .. '&grant_type=authorization_code' + .. '&redirect_uri=' + .. url_encode('http://127.0.0.1:' .. port) + + local result = vim + .system({ + 'curl', + '-s', + '-X', + 'POST', + '-H', + 'Content-Type: application/x-www-form-urlencoded', + '-d', + body, + TOKEN_URL, + }, { text = true }) + :wait() + + if result.code ~= 0 then + vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR) + return + end + + local ok, decoded = pcall(vim.json.decode, result.stdout or '') + if not ok or not decoded.access_token then + vim.notify('pending.nvim: Invalid token response.', vim.log.levels.ERROR) + return + end + + decoded.obtained_at = os.time() + save_tokens(decoded) + vim.notify('pending.nvim: Google Tasks authorized successfully.') +end + +---@param access_token string +---@return table? name_to_id +---@return string? err +local function get_all_tasklists(access_token) + local data, err = curl_request('GET', BASE_URL .. '/users/@me/lists', auth_headers(access_token)) + if err then + return nil, err + end + local result = {} + for _, item in ipairs(data and data.items or {}) do + result[item.title] = item.id + end + return result, nil +end + +---@param access_token string +---@param name string +---@param existing table +---@return string? list_id +---@return string? err +local function find_or_create_tasklist(access_token, name, existing) + if existing[name] then + return existing[name], nil + end + local body = vim.json.encode({ title = name }) + local created, err = + curl_request('POST', BASE_URL .. '/users/@me/lists', auth_headers(access_token), body) + if err then + return nil, err + end + local id = created and created.id + if id then + existing[name] = id + end + return id, nil +end + +---@param access_token string +---@param list_id string +---@return table[]? items +---@return string? err +local function list_gtasks(access_token, list_id) + local url = BASE_URL + .. '/lists/' + .. url_encode(list_id) + .. '/tasks?showCompleted=true&showHidden=true' + local data, err = curl_request('GET', url, auth_headers(access_token)) + if err then + return nil, err + end + return data and data.items or {}, nil +end + +---@param access_token string +---@param list_id string +---@param body table +---@return string? task_id +---@return string? err +local function create_gtask(access_token, list_id, body) + local data, err = curl_request( + 'POST', + BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks', + auth_headers(access_token), + vim.json.encode(body) + ) + if err then + return nil, err + end + return data and data.id, nil +end + +---@param access_token string +---@param list_id string +---@param task_id string +---@param body table +---@return string? err +local function update_gtask(access_token, list_id, task_id, body) + local _, err = curl_request( + 'PATCH', + BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks/' .. url_encode(task_id), + auth_headers(access_token), + vim.json.encode(body) + ) + return err +end + +---@param access_token string +---@param list_id string +---@param task_id string +---@return string? err +local function delete_gtask(access_token, list_id, task_id) + local _, err = curl_request( + 'DELETE', + BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks/' .. url_encode(task_id), + auth_headers(access_token) + ) + return err +end + +---@param due string YYYY-MM-DD or YYYY-MM-DDThh:mm +---@return string RFC 3339 +local function due_to_rfc3339(due) + local date = due:match('^(%d%d%d%d%-%d%d%-%d%d)') + return (date or due) .. 'T00:00:00.000Z' +end + +---@param rfc string RFC 3339 from GTasks +---@return string YYYY-MM-DD +local function rfc3339_to_date(rfc) + return rfc:match('^(%d%d%d%d%-%d%d%-%d%d)') or rfc +end + +---@param task pending.Task +---@return string? +local function build_notes(task) + local parts = {} + if task.priority and task.priority > 0 then + table.insert(parts, 'pri:' .. task.priority) + end + if task.recur then + local spec = task.recur + if task.recur_mode == 'completion' then + spec = '!' .. spec + end + table.insert(parts, 'rec:' .. spec) + end + if #parts == 0 then + return nil + end + return table.concat(parts, ' ') +end + +---@param notes string? +---@return integer priority +---@return string? recur +---@return string? recur_mode +local function parse_notes(notes) + if not notes then + return 0, nil, nil + end + local priority = 0 + local recur = nil + local recur_mode = nil + local pri = notes:match('pri:(%d+)') + if pri then + priority = tonumber(pri) or 0 + end + local rec = notes:match('rec:(!?[%w]+)') + if rec then + if rec:sub(1, 1) == '!' then + recur = rec:sub(2) + recur_mode = 'completion' + else + recur = rec + end + end + return priority, recur, recur_mode +end + +---@param task pending.Task +---@return table +local function task_to_gtask(task) + local body = { + title = task.description, + status = task.status == 'done' and 'completed' or 'needsAction', + } + if task.due then + body.due = due_to_rfc3339(task.due) + end + local notes = build_notes(task) + if notes then + body.notes = notes + end + return body +end + +---@param gtask table +---@param category string +---@return table fields for store:add / store:update +local function gtask_to_fields(gtask, category) + local priority, recur, recur_mode = parse_notes(gtask.notes) + local fields = { + description = gtask.title or '', + category = category, + status = gtask.status == 'completed' and 'done' or 'pending', + priority = priority, + recur = recur, + recur_mode = recur_mode, + } + if gtask.due then + fields.due = rfc3339_to_date(gtask.due) + end + return fields +end + +---@param s pending.Store +---@return table +local function build_id_index(s) + ---@type table + local index = {} + for _, task in ipairs(s:tasks()) do + local extra = task._extra or {} + local gtid = extra['_gtasks_task_id'] --[[@as string?]] + if gtid then + index[gtid] = task + end + end + return index +end + +---@param access_token string +---@param tasklists table +---@param s pending.Store +---@param now_ts string +---@param by_gtasks_id table +---@return integer created +---@return integer updated +---@return integer deleted +local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local created, updated, deleted = 0, 0, 0 + for _, task in ipairs(s:tasks()) do + local extra = task._extra or {} + local gtid = extra['_gtasks_task_id'] --[[@as string?]] + local list_id = extra['_gtasks_list_id'] --[[@as string?]] + + if task.status == 'deleted' and gtid and list_id then + local err = delete_gtask(access_token, list_id, gtid) + if not err then + if not task._extra then + task._extra = {} + end + task._extra['_gtasks_task_id'] = nil + task._extra['_gtasks_list_id'] = nil + if next(task._extra) == nil then + task._extra = nil + end + task.modified = now_ts + deleted = deleted + 1 + 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 not err then + updated = updated + 1 + end + elseif task.status == 'pending' then + local cat = task.category or config.get().default_category + local lid, err = find_or_create_tasklist(access_token, cat, tasklists) + if not err and lid then + local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task)) + if not create_err and 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.modified = now_ts + by_gtasks_id[new_id] = task + created = created + 1 + end + end + end + end + end + return created, updated, deleted +end + +---@param access_token string +---@param tasklists table +---@param s pending.Store +---@param now_ts string +---@param by_gtasks_id table +---@return integer created +---@return integer updated +local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local created, updated = 0, 0 + for list_name, list_id in pairs(tasklists) do + local items, err = list_gtasks(access_token, list_id) + if err then + vim.notify( + 'pending.nvim: error fetching list ' .. list_name .. ': ' .. err, + vim.log.levels.WARN + ) + else + for _, gtask in ipairs(items or {}) do + local local_task = by_gtasks_id[gtask.id] + if local_task then + local gtask_updated = gtask.updated or '' + local local_modified = local_task.modified or '' + if gtask_updated > local_modified then + local fields = gtask_to_fields(gtask, list_name) + for k, v in pairs(fields) do + local_task[k] = v + end + local_task.modified = now_ts + updated = updated + 1 + end + else + local fields = gtask_to_fields(gtask, list_name) + fields._extra = { + _gtasks_task_id = gtask.id, + _gtasks_list_id = list_id, + } + local new_task = s:add(fields) + by_gtasks_id[gtask.id] = new_task + created = created + 1 + end + end + end + end + return created, updated +end + +---@return string? access_token +---@return table? tasklists +---@return pending.Store? store +---@return string? now_ts +local function sync_setup() + local access_token = get_access_token() + if not access_token then + return nil + end + local tasklists, tl_err = get_all_tasklists(access_token) + if tl_err or not tasklists then + vim.notify('pending.nvim: ' .. (tl_err or 'failed to fetch task lists'), vim.log.levels.ERROR) + return nil + end + local s = require('pending').store() + local now_ts = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + return access_token, tasklists, s, now_ts +end + +function M.push() + local access_token, tasklists, s, now_ts = sync_setup() + if not access_token then + return + end + ---@cast tasklists table + ---@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) + s:save() + require('pending')._recompute_counts() + vim.notify( + string.format('pending.nvim: Google Tasks pushed — +%d ~%d -%d', created, updated, deleted) + ) +end + +function M.pull() + local access_token, tasklists, s, now_ts = sync_setup() + if not access_token then + return + end + ---@cast tasklists table + ---@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) + s:save() + require('pending')._recompute_counts() + vim.notify( + string.format('pending.nvim: Google Tasks pulled — +%d ~%d', created, updated) + ) +end + +function M.sync() + local access_token, tasklists, s, now_ts = sync_setup() + if not access_token then + return + end + ---@cast tasklists table + ---@cast s pending.Store + ---@cast now_ts string + local by_gtasks_id = build_id_index(s) + local pushed_create, pushed_update, pushed_delete = + 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) + s:save() + require('pending')._recompute_counts() + vim.notify( + string.format( + 'pending.nvim: Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d', + pushed_create, + pushed_update, + pushed_delete, + pulled_create, + pulled_update + ) + ) +end + +M._due_to_rfc3339 = due_to_rfc3339 +M._rfc3339_to_date = rfc3339_to_date +M._build_notes = build_notes +M._parse_notes = parse_notes +M._task_to_gtask = task_to_gtask +M._gtask_to_fields = gtask_to_fields + +---@return nil +function M.health() + if vim.fn.executable('curl') == 1 then + vim.health.ok('curl found (required for gtasks sync)') + else + vim.health.warn('curl not found (needed for gtasks sync)') + end + if vim.fn.executable('openssl') == 1 then + vim.health.ok('openssl found (required for gtasks OAuth PKCE)') + else + vim.health.warn('openssl not found (needed for gtasks OAuth)') + end + local tokens = 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') + end +end + +return M diff --git a/spec/gtasks_spec.lua b/spec/gtasks_spec.lua new file mode 100644 index 0000000..19328d9 --- /dev/null +++ b/spec/gtasks_spec.lua @@ -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)