Problem: log messages in `oauth.lua`, `gcal.lua`, `gtasks.lua`, and `s3.lua` were inconsistent — some lacked a backend prefix, others used sentence-case or bare error strings without identifying the source. Solution: prefix all user-facing log messages with their backend name (`gcal:`, `gtasks:`, `S3:`, `Google:`). Capitalize `S3` and `Google` display names. Normalize casing and separator style (em dash) across all sync log sites.
470 lines
14 KiB
Lua
470 lines
14 KiB
Lua
local log = require('pending.log')
|
|
local util = require('pending.sync.util')
|
|
|
|
local M = {}
|
|
|
|
M.name = 's3'
|
|
|
|
---@return pending.S3Config?
|
|
local function get_config()
|
|
local cfg = require('pending.config').get()
|
|
return cfg.sync and cfg.sync.s3
|
|
end
|
|
|
|
---@return string[]
|
|
local function base_cmd()
|
|
local s3cfg = get_config() or {}
|
|
local cmd = { 'aws' }
|
|
if s3cfg.profile then
|
|
table.insert(cmd, '--profile')
|
|
table.insert(cmd, s3cfg.profile)
|
|
end
|
|
if s3cfg.region then
|
|
table.insert(cmd, '--region')
|
|
table.insert(cmd, s3cfg.region)
|
|
end
|
|
return cmd
|
|
end
|
|
|
|
---@param task pending.Task
|
|
---@return string
|
|
local function ensure_sync_id(task)
|
|
if not task._extra then
|
|
task._extra = {}
|
|
end
|
|
local sync_id = task._extra['_s3_sync_id']
|
|
if not sync_id then
|
|
local bytes = {}
|
|
math.randomseed(vim.uv.hrtime())
|
|
for i = 1, 16 do
|
|
bytes[i] = math.random(0, 255)
|
|
end
|
|
bytes[7] = bit.bor(bit.band(bytes[7], 0x0f), 0x40)
|
|
bytes[9] = bit.bor(bit.band(bytes[9], 0x3f), 0x80)
|
|
sync_id = string.format(
|
|
'%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x',
|
|
bytes[1],
|
|
bytes[2],
|
|
bytes[3],
|
|
bytes[4],
|
|
bytes[5],
|
|
bytes[6],
|
|
bytes[7],
|
|
bytes[8],
|
|
bytes[9],
|
|
bytes[10],
|
|
bytes[11],
|
|
bytes[12],
|
|
bytes[13],
|
|
bytes[14],
|
|
bytes[15],
|
|
bytes[16]
|
|
)
|
|
task._extra['_s3_sync_id'] = sync_id
|
|
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
|
|
end
|
|
return sync_id
|
|
end
|
|
|
|
local function create_bucket()
|
|
local name = util.input({ prompt = 'S3 bucket name (pending.nvim): ' })
|
|
if not name then
|
|
log.info('S3: bucket creation cancelled')
|
|
return
|
|
end
|
|
if name == '' then
|
|
name = 'pending.nvim'
|
|
end
|
|
|
|
local region_cmd = base_cmd()
|
|
vim.list_extend(region_cmd, { 'configure', 'get', 'region' })
|
|
local region_result = util.system(region_cmd, { text = true })
|
|
local default_region = 'us-east-1'
|
|
if region_result.code == 0 and region_result.stdout then
|
|
local detected = vim.trim(region_result.stdout)
|
|
if detected ~= '' then
|
|
default_region = detected
|
|
end
|
|
end
|
|
|
|
local region = util.input({ prompt = 'AWS region (' .. default_region .. '): ' })
|
|
if not region or region == '' then
|
|
region = default_region
|
|
end
|
|
|
|
local cmd = base_cmd()
|
|
vim.list_extend(cmd, { 's3api', 'create-bucket', '--bucket', name, '--region', region })
|
|
if region ~= 'us-east-1' then
|
|
vim.list_extend(cmd, { '--create-bucket-configuration', 'LocationConstraint=' .. region })
|
|
end
|
|
|
|
local result = util.system(cmd, { text = true })
|
|
if result.code == 0 then
|
|
log.info(
|
|
's3: bucket created. Add to your pending.nvim config:\n sync = { s3 = { bucket = "'
|
|
.. name
|
|
.. '", region = "'
|
|
.. region
|
|
.. '" } }'
|
|
)
|
|
else
|
|
log.error('S3: bucket creation failed — ' .. (result.stderr or 'unknown error'))
|
|
end
|
|
end
|
|
|
|
---@param args? string
|
|
---@return nil
|
|
function M.auth(args)
|
|
if args == 'profile' then
|
|
vim.ui.input({ prompt = 'AWS profile name: ' }, function(input)
|
|
if not input or input == '' then
|
|
local s3cfg = get_config()
|
|
if s3cfg and s3cfg.profile then
|
|
log.info('S3: current profile: ' .. s3cfg.profile)
|
|
else
|
|
log.info('S3: no profile configured (using default)')
|
|
end
|
|
return
|
|
end
|
|
log.info('S3: set profile in your config: sync = { s3 = { profile = "' .. input .. '" } }')
|
|
end)
|
|
return
|
|
end
|
|
|
|
util.async(function()
|
|
local cmd = base_cmd()
|
|
vim.list_extend(cmd, { 'sts', 'get-caller-identity', '--output', 'json' })
|
|
local result = util.system(cmd, { text = true })
|
|
if result.code == 0 then
|
|
local ok, data = pcall(vim.json.decode, result.stdout or '')
|
|
if ok and data then
|
|
log.info('S3: authenticated as ' .. (data.Arn or data.Account or 'unknown'))
|
|
else
|
|
log.info('S3: credentials valid')
|
|
end
|
|
local s3cfg = get_config()
|
|
if not s3cfg or not s3cfg.bucket then
|
|
create_bucket()
|
|
end
|
|
else
|
|
local stderr = result.stderr or ''
|
|
if stderr:find('SSO') or stderr:find('sso') then
|
|
log.info('S3: SSO session expired — running login...')
|
|
local login_cmd = base_cmd()
|
|
vim.list_extend(login_cmd, { 'sso', 'login' })
|
|
local login_result = util.system(login_cmd, { text = true })
|
|
if login_result.code == 0 then
|
|
log.info('S3: SSO login successful')
|
|
else
|
|
log.error('S3: SSO login failed — ' .. (login_result.stderr or ''))
|
|
end
|
|
elseif
|
|
stderr:find('Unable to locate credentials') or stderr:find('NoCredentialProviders')
|
|
then
|
|
log.error('S3: no AWS credentials configured. See :h pending-s3')
|
|
else
|
|
log.error('S3: ' .. stderr)
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
|
|
---@return string[]
|
|
function M.auth_complete()
|
|
return { 'profile' }
|
|
end
|
|
|
|
function M.push()
|
|
util.async(function()
|
|
util.with_guard('S3', function()
|
|
local s3cfg = get_config()
|
|
if not s3cfg or not s3cfg.bucket then
|
|
log.error('S3: bucket is required. Set sync.s3.bucket in config.')
|
|
return
|
|
end
|
|
local key = s3cfg.key or 'pending.json'
|
|
local s = require('pending').store()
|
|
|
|
for _, task in ipairs(s:tasks()) do
|
|
ensure_sync_id(task)
|
|
end
|
|
|
|
local tmpfile = vim.fn.tempname() .. '.json'
|
|
s:save()
|
|
|
|
local store = require('pending.store')
|
|
local tmp_store = store.new(s.path)
|
|
tmp_store:load()
|
|
|
|
local f = io.open(s.path, 'r')
|
|
if not f then
|
|
log.error('S3: failed to read store file')
|
|
return
|
|
end
|
|
local content = f:read('*a')
|
|
f:close()
|
|
|
|
local tf = io.open(tmpfile, 'w')
|
|
if not tf then
|
|
log.error('S3: failed to create temp file')
|
|
return
|
|
end
|
|
tf:write(content)
|
|
tf:close()
|
|
|
|
local cmd = base_cmd()
|
|
vim.list_extend(cmd, { 's3', 'cp', tmpfile, 's3://' .. s3cfg.bucket .. '/' .. key })
|
|
local result = util.system(cmd, { text = true })
|
|
os.remove(tmpfile)
|
|
|
|
if result.code ~= 0 then
|
|
log.error('S3: push failed — ' .. (result.stderr or 'unknown error'))
|
|
return
|
|
end
|
|
|
|
util.finish(s)
|
|
log.info('S3: push uploaded to s3://' .. s3cfg.bucket .. '/' .. key)
|
|
end)
|
|
end)
|
|
end
|
|
|
|
function M.pull()
|
|
util.async(function()
|
|
util.with_guard('S3', function()
|
|
local s3cfg = get_config()
|
|
if not s3cfg or not s3cfg.bucket then
|
|
log.error('S3: bucket is required. Set sync.s3.bucket in config.')
|
|
return
|
|
end
|
|
local key = s3cfg.key or 'pending.json'
|
|
local tmpfile = vim.fn.tempname() .. '.json'
|
|
|
|
local cmd = base_cmd()
|
|
vim.list_extend(cmd, { 's3', 'cp', 's3://' .. s3cfg.bucket .. '/' .. key, tmpfile })
|
|
local result = util.system(cmd, { text = true })
|
|
|
|
if result.code ~= 0 then
|
|
os.remove(tmpfile)
|
|
log.error('S3: pull failed — ' .. (result.stderr or 'unknown error'))
|
|
return
|
|
end
|
|
|
|
local store = require('pending.store')
|
|
local s_remote = store.new(tmpfile)
|
|
local load_ok = pcall(function()
|
|
s_remote:load()
|
|
end)
|
|
if not load_ok then
|
|
os.remove(tmpfile)
|
|
log.error('S3: pull failed — could not parse remote store')
|
|
return
|
|
end
|
|
|
|
local s = require('pending').store()
|
|
local created, updated, unchanged = 0, 0, 0
|
|
|
|
local local_by_sync_id = {}
|
|
for _, task in ipairs(s:tasks()) do
|
|
local extra = task._extra or {}
|
|
local sid = extra['_s3_sync_id']
|
|
if sid then
|
|
local_by_sync_id[sid] = task
|
|
end
|
|
end
|
|
|
|
for _, remote_task in ipairs(s_remote:tasks()) do
|
|
local r_extra = remote_task._extra or {}
|
|
local r_sid = r_extra['_s3_sync_id']
|
|
if not r_sid then
|
|
goto continue
|
|
end
|
|
|
|
local local_task = local_by_sync_id[r_sid]
|
|
if local_task then
|
|
local r_mod = remote_task.modified or ''
|
|
local l_mod = local_task.modified or ''
|
|
if r_mod > l_mod then
|
|
local_task.description = remote_task.description
|
|
local_task.status = remote_task.status
|
|
local_task.category = remote_task.category
|
|
local_task.priority = remote_task.priority
|
|
local_task.due = remote_task.due
|
|
local_task.recur = remote_task.recur
|
|
local_task.recur_mode = remote_task.recur_mode
|
|
local_task['end'] = remote_task['end']
|
|
local_task._extra = local_task._extra or {}
|
|
local_task._extra['_s3_sync_id'] = r_sid
|
|
local_task.modified = remote_task.modified
|
|
updated = updated + 1
|
|
else
|
|
unchanged = unchanged + 1
|
|
end
|
|
else
|
|
s:add({
|
|
description = remote_task.description,
|
|
status = remote_task.status,
|
|
category = remote_task.category,
|
|
priority = remote_task.priority,
|
|
due = remote_task.due,
|
|
recur = remote_task.recur,
|
|
recur_mode = remote_task.recur_mode,
|
|
_extra = { _s3_sync_id = r_sid },
|
|
})
|
|
created = created + 1
|
|
end
|
|
|
|
::continue::
|
|
end
|
|
|
|
os.remove(tmpfile)
|
|
util.finish(s)
|
|
log.info('S3: pull ' .. util.fmt_counts({
|
|
{ created, 'added' },
|
|
{ updated, 'updated' },
|
|
{ unchanged, 'unchanged' },
|
|
}))
|
|
end)
|
|
end)
|
|
end
|
|
|
|
function M.sync()
|
|
util.async(function()
|
|
util.with_guard('S3', function()
|
|
local s3cfg = get_config()
|
|
if not s3cfg or not s3cfg.bucket then
|
|
log.error('S3: bucket is required. Set sync.s3.bucket in config.')
|
|
return
|
|
end
|
|
local key = s3cfg.key or 'pending.json'
|
|
local tmpfile = vim.fn.tempname() .. '.json'
|
|
|
|
local cmd = base_cmd()
|
|
vim.list_extend(cmd, { 's3', 'cp', 's3://' .. s3cfg.bucket .. '/' .. key, tmpfile })
|
|
local result = util.system(cmd, { text = true })
|
|
|
|
local s = require('pending').store()
|
|
local created, updated = 0, 0
|
|
|
|
if result.code == 0 then
|
|
local store = require('pending.store')
|
|
local s_remote = store.new(tmpfile)
|
|
local load_ok = pcall(function()
|
|
s_remote:load()
|
|
end)
|
|
|
|
if load_ok then
|
|
local local_by_sync_id = {}
|
|
for _, task in ipairs(s:tasks()) do
|
|
local extra = task._extra or {}
|
|
local sid = extra['_s3_sync_id']
|
|
if sid then
|
|
local_by_sync_id[sid] = task
|
|
end
|
|
end
|
|
|
|
for _, remote_task in ipairs(s_remote:tasks()) do
|
|
local r_extra = remote_task._extra or {}
|
|
local r_sid = r_extra['_s3_sync_id']
|
|
if not r_sid then
|
|
goto continue
|
|
end
|
|
|
|
local local_task = local_by_sync_id[r_sid]
|
|
if local_task then
|
|
local r_mod = remote_task.modified or ''
|
|
local l_mod = local_task.modified or ''
|
|
if r_mod > l_mod then
|
|
local_task.description = remote_task.description
|
|
local_task.status = remote_task.status
|
|
local_task.category = remote_task.category
|
|
local_task.priority = remote_task.priority
|
|
local_task.due = remote_task.due
|
|
local_task.recur = remote_task.recur
|
|
local_task.recur_mode = remote_task.recur_mode
|
|
local_task['end'] = remote_task['end']
|
|
local_task._extra = local_task._extra or {}
|
|
local_task._extra['_s3_sync_id'] = r_sid
|
|
local_task.modified = remote_task.modified
|
|
updated = updated + 1
|
|
end
|
|
else
|
|
s:add({
|
|
description = remote_task.description,
|
|
status = remote_task.status,
|
|
category = remote_task.category,
|
|
priority = remote_task.priority,
|
|
due = remote_task.due,
|
|
recur = remote_task.recur,
|
|
recur_mode = remote_task.recur_mode,
|
|
_extra = { _s3_sync_id = r_sid },
|
|
})
|
|
created = created + 1
|
|
end
|
|
|
|
::continue::
|
|
end
|
|
end
|
|
end
|
|
os.remove(tmpfile)
|
|
|
|
for _, task in ipairs(s:tasks()) do
|
|
ensure_sync_id(task)
|
|
end
|
|
s:save()
|
|
|
|
local f = io.open(s.path, 'r')
|
|
if not f then
|
|
log.error('S3: sync failed — could not read store file')
|
|
return
|
|
end
|
|
local content = f:read('*a')
|
|
f:close()
|
|
|
|
local push_tmpfile = vim.fn.tempname() .. '.json'
|
|
local tf = io.open(push_tmpfile, 'w')
|
|
if not tf then
|
|
log.error('S3: sync failed — could not create temp file')
|
|
return
|
|
end
|
|
tf:write(content)
|
|
tf:close()
|
|
|
|
local push_cmd = base_cmd()
|
|
vim.list_extend(push_cmd, { 's3', 'cp', push_tmpfile, 's3://' .. s3cfg.bucket .. '/' .. key })
|
|
local push_result = util.system(push_cmd, { text = true })
|
|
os.remove(push_tmpfile)
|
|
|
|
if push_result.code ~= 0 then
|
|
log.error('S3: sync push failed — ' .. (push_result.stderr or 'unknown error'))
|
|
util.finish(s)
|
|
return
|
|
end
|
|
|
|
util.finish(s)
|
|
log.info('S3: sync ' .. util.fmt_counts({
|
|
{ created, 'added' },
|
|
{ updated, 'updated' },
|
|
}) .. ' | push uploaded')
|
|
end)
|
|
end)
|
|
end
|
|
|
|
---@return nil
|
|
function M.health()
|
|
if vim.fn.executable('aws') == 1 then
|
|
vim.health.ok('aws CLI found')
|
|
else
|
|
vim.health.error('aws CLI not found (required for S3 sync)')
|
|
end
|
|
|
|
local s3cfg = get_config()
|
|
if s3cfg and s3cfg.bucket then
|
|
vim.health.ok('S3 bucket configured: ' .. s3cfg.bucket)
|
|
else
|
|
vim.health.warn('S3 bucket not configured — set sync.s3.bucket')
|
|
end
|
|
end
|
|
|
|
M._ensure_sync_id = ensure_sync_id
|
|
|
|
return M
|