From 9de53f2bb35932f06deffd43bd097f2964e36dc6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:12:53 -0500 Subject: [PATCH] feat(sync): add opt-in remote deletion for gcal and gtasks (#85) Problem: push/sync permanently deleted remote Google Calendar events and Google Tasks entries whenever a local task was marked deleted, done, or de-due'd. There was no opt-out, so a misfire could silently cause irreversible data loss on the remote side. Solution: add a `remote_delete` boolean to the config (default `false`). A unified flag at `sync.remote_delete` sets the base; per-backend overrides at `sync.gcal.remote_delete` / `sync.gtasks.remote_delete` take precedence when non-nil. When disabled, `_extra` remote IDs are cleared silently (unlinking) so stale IDs don't accumulate. --- lua/pending/config.lua | 3 +++ lua/pending/sync/gcal.lua | 51 ++++++++++++++++++++++++++++--------- lua/pending/sync/gtasks.lua | 50 ++++++++++++++++++++++++++---------- 3 files changed, 78 insertions(+), 26 deletions(-) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 592ef67..9f1c760 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -7,16 +7,19 @@ ---@field category string ---@class pending.GcalConfig +---@field remote_delete? boolean ---@field credentials_path? string ---@field client_id? string ---@field client_secret? string ---@class pending.GtasksConfig +---@field remote_delete? boolean ---@field credentials_path? string ---@field client_id? string ---@field client_secret? string ---@class pending.SyncConfig +---@field remote_delete? boolean ---@field gcal? pending.GcalConfig ---@field gtasks? pending.GtasksConfig diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 1fe8557..6dddaa6 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -129,6 +129,31 @@ local function delete_event(access_token, calendar_id, event_id) return err end +---@return boolean +local function allow_remote_delete() + local cfg = config.get() + local sync = cfg.sync or {} + local per = (sync.gcal or {}) --[[@as pending.GcalConfig]] + if per.remote_delete ~= nil then + return per.remote_delete == true + end + return sync.remote_delete == true +end + +---@param task pending.Task +---@param extra table +---@param now_ts string +local function unlink_remote(task, extra, now_ts) + extra['_gcal_event_id'] = nil + extra['_gcal_calendar_id'] = nil + if next(extra) == nil then + task._extra = nil + else + task._extra = extra + end + task.modified = now_ts +end + ---@param callback fun(access_token: string): nil local function with_token(callback) oauth.async(function() @@ -150,6 +175,7 @@ function M.push() end local s = require('pending').store() + local now_ts = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] local created, updated, deleted = 0, 0, 0 for _, task in ipairs(s:tasks()) do @@ -166,19 +192,20 @@ function M.push() ) if should_delete then - local del_err = - delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]]) - if del_err then - log.warn('gcal delete failed: ' .. del_err) - else - extra['_gcal_event_id'] = nil - extra['_gcal_calendar_id'] = nil - if next(extra) == nil then - task._extra = nil + if allow_remote_delete() then + local del_err = + delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]]) + if del_err then + log.warn('gcal delete failed: ' .. del_err) else - task._extra = extra + unlink_remote(task, extra, now_ts) + deleted = deleted + 1 end - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + else + log.debug( + 'gcal: remote delete skipped (remote_delete disabled) — unlinked task #' .. task.id + ) + unlink_remote(task, extra, now_ts) deleted = deleted + 1 end elseif task.status == 'pending' and task.due then @@ -204,7 +231,7 @@ function M.push() end task._extra['_gcal_event_id'] = new_id task._extra['_gcal_calendar_id'] = lid - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + task.modified = now_ts created = created + 1 end end diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index 6c8bef3..7337030 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -172,6 +172,29 @@ local function parse_notes(notes) return priority, recur, recur_mode end +---@return boolean +local function allow_remote_delete() + local cfg = config.get() + local sync = cfg.sync or {} + local per = (sync.gtasks or {}) --[[@as pending.GtasksConfig]] + if per.remote_delete ~= nil then + return per.remote_delete == true + end + return sync.remote_delete == true +end + +---@param task pending.Task +---@param now_ts string +local function unlink_remote(task, now_ts) + 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 +end + ---@param task pending.Task ---@return table local function task_to_gtask(task) @@ -240,21 +263,20 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) 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 err then - log.warn('gtasks delete failed: ' .. err) - failed = failed + 1 + if allow_remote_delete() then + local err = delete_gtask(access_token, list_id, gtid) + if err then + log.warn('gtasks delete failed: ' .. err) + failed = failed + 1 + else + unlink_remote(task, now_ts) + deleted = deleted + 1 + end 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 - task.modified = now_ts + log.debug( + 'gtasks: remote delete skipped (remote_delete disabled) — unlinked task #' .. task.id + ) + unlink_remote(task, now_ts) deleted = deleted + 1 end elseif task.status ~= 'deleted' then