Skip to content

Instantly share code, notes, and snippets.

@mallomar
Last active December 8, 2025 21:11
Show Gist options
  • Select an option

  • Save mallomar/67349ac3c88e1950754ba39a4913c029 to your computer and use it in GitHub Desktop.

Select an option

Save mallomar/67349ac3c88e1950754ba39a4913c029 to your computer and use it in GitHub Desktop.
Automatically sync Reading Statistics, Vocabulary Builder and Highlight Sync at user configurable events (e.g. open, close, suspend) - requires HighlightSync plugin (https://github.com/gitalexcampos/koreader-Highlight-Sync)
-- 2-unified-autosync.lua - KOReader Auto Sync Patch
-- Place in /storage/emulated/0/koreader/patches/
--
-- Features:
-- - Syncs Highlightsync, Statistics, and Vocabulary Builder automatically
-- - Robust Highlightsync detection with fallback (from v2.5)
-- - Global cooldown prevents double-syncing (from v2.5)
-- - Stable Vocabulary sync (from v3.2)
-- - Smart reload detection prevents infinite loops
-- - Configurable triggers (open, close, suspend)
--
-- Version: 3.3 (Stable + Complete)
local log_file = "/storage/emulated/0/koreader/settings/unified-autosync-debug.txt"
local function log(msg)
local file = io.open(log_file, "a")
if file then
file:write(os.date("%Y-%m-%d %H:%M:%S") .. " - " .. msg .. "\n")
file:close()
end
end
log("========== UNIFIED AUTO SYNC PATCH LOADING (v3.3) ==========")
local UIManager = require("ui/uimanager")
local InfoMessage = require("ui/widget/infomessage")
local ReaderUI = require("apps/reader/readerui")
local ReaderMenu = require("apps/reader/modules/readermenu")
-- --- STATE TRACKING (from v2.5) ---
local sync_state = {
is_syncing = false,
last_sync_time = 0,
cooldown_seconds = 30, -- Global cooldown for ALL sync types
in_highlightsync_reload = false,
reload_timeout = nil,
}
-- Check if enough time has passed since last sync (applies to ALL sync types)
local function canSync()
-- Check if we're in a Highlightsync-triggered reload
if sync_state.in_highlightsync_reload then
log("Inside Highlightsync reload cycle, skipping sync")
return false
end
local current_time = os.time()
local time_since_last = current_time - sync_state.last_sync_time
if sync_state.is_syncing then
log("Sync already in progress, skipping")
return false
end
if time_since_last < sync_state.cooldown_seconds then
log(string.format("Cooldown active (%ds remaining), skipping",
sync_state.cooldown_seconds - time_since_last))
return false
end
return true
end
local function showMessage(text, timeout)
pcall(function()
UIManager:show(InfoMessage:new{ text = text, timeout = timeout or 3 })
end)
end
-- --- PLUGIN CONFIGURATION CHECKS ---
local function isStatsConfigured()
local settings = G_reader_settings:readSetting("statistics") or {}
return settings.sync_server ~= nil
end
local function isVocabConfigured()
local settings = G_reader_settings:readSetting("vocabulary_builder") or {}
return settings.server ~= nil
end
local function isHighlightConfigured()
local settings = G_reader_settings:readSetting("highlight_sync") or {}
return settings.sync_server ~= nil
end
-- --- SYNC FUNCTIONS ---
local function syncStatistics(immediate)
log("Attempting to sync Statistics...")
if not isStatsConfigured() then
log("Statistics not configured")
return false
end
local sync_func = function()
if ReaderUI.instance and ReaderUI.instance.statistics then
local success = pcall(function()
ReaderUI.instance.statistics:onSyncBookStats(true)
end)
if success then
log("✓ Statistics sync completed")
else
log("✗ Statistics sync failed")
end
end
end
if immediate then
sync_func()
else
UIManager:scheduleIn(0.1, sync_func)
end
log("Statistics sync " .. (immediate and "executed" or "scheduled"))
return true
end
local function syncVocabularyBuilder(immediate)
log("Attempting to sync Vocabulary Builder...")
if not isVocabConfigured() then
log("VocabBuilder not configured")
return false
end
-- Always schedule (safer than immediate execution)
UIManager:scheduleIn(0.2, function()
log("VocabBuilder: Starting...")
local success = pcall(function()
local SyncService = require("frontend/apps/cloudstorage/syncservice")
local DataStorage = require("datastorage")
-- Load DB using loadfile (v3.2 approach - more stable)
local vocab_db_path = "plugins/vocabbuilder.koplugin/db.lua"
local chunk = loadfile(vocab_db_path)
if not chunk then
log("✗ VocabBuilder: Could not load db.lua")
return
end
local ok, VocabDB = pcall(chunk)
if not ok or not VocabDB then
log("✗ VocabBuilder: Failed to execute db.lua")
return
end
-- Save pending words (v3.2's safer approach)
if ReaderUI.instance and ReaderUI.instance.vocabulary_builder then
local vb = ReaderUI.instance.vocabulary_builder
-- v3.2 checks for .widget, v2.5 didn't - this prevents crashes
if vb.widget and vb.widget.item_table then
pcall(VocabDB.batchUpdateItems, VocabDB, vb.widget.item_table)
log("VocabBuilder: Saved pending words")
elseif vb.item_table then
-- Fallback for different widget structure
pcall(VocabDB.batchUpdateItems, VocabDB, vb.item_table)
log("VocabBuilder: Saved pending words (fallback)")
end
end
local settings = G_reader_settings:readSetting("vocabulary_builder") or {}
local db_path = DataStorage:getSettingsDir() .. "/vocabulary_builder.sqlite3"
-- Sync without blocking
SyncService.sync(settings.server, db_path, VocabDB.onSync, true)
log("✓ Vocabulary sync completed")
end)
if not success then
log("✗ Vocabulary sync failed")
end
end)
log("Vocabulary sync scheduled")
return true
end
local function syncHighlightsync(immediate)
log("Attempting to sync Highlightsync...")
if G_reader_settings:readSetting("unified_autosync_include_highlights") == false then
log("Highlightsync disabled by user")
return false
end
if not isHighlightConfigured() then
log("Highlightsync not configured")
return false
end
if not ReaderUI.instance then
log("ReaderUI not ready")
return false
end
-- v2.5's ROBUST detection - try THREE variants
local highlightsync = ReaderUI.instance.highlight_sync or
ReaderUI.instance.highlightsync or
ReaderUI.instance.Highlightsync
if not (highlightsync and highlightsync.onSyncBookHighlights) then
-- v2.5's FALLBACK - try loading plugin directly
log("Highlightsync not in ReaderUI, trying direct load...")
local ok, plugin = pcall(require, "plugins/highlightsync.koplugin/main")
if ok and plugin and plugin.onSyncBookHighlights then
highlightsync = plugin
log("Highlightsync loaded directly")
else
log("✗ Highlightsync plugin not found")
return false
end
end
-- Set reload flag BEFORE triggering sync
sync_state.in_highlightsync_reload = true
log("Reload flag SET - will ignore next close/open events")
-- Clear any existing timeout
if sync_state.reload_timeout then
UIManager:unschedule(sync_state.reload_timeout)
end
-- Schedule flag clearing (safety timeout)
sync_state.reload_timeout = function()
sync_state.in_highlightsync_reload = false
sync_state.reload_timeout = nil
log("Reload flag CLEARED (timeout)")
end
UIManager:scheduleIn(8, sync_state.reload_timeout)
local sync_func = function()
local success = pcall(function()
highlightsync:onSyncBookHighlights(true)
end)
if success then
log("✓ Highlightsync completed")
-- Clear flag after successful sync
UIManager:scheduleIn(3, function()
if sync_state.in_highlightsync_reload then
sync_state.in_highlightsync_reload = false
log("Reload flag CLEARED (after successful sync)")
end
end)
else
log("✗ Highlightsync failed")
sync_state.in_highlightsync_reload = false
log("Reload flag CLEARED (sync failed)")
end
end
if immediate then
sync_func()
else
UIManager:scheduleIn(0.3, sync_func)
end
log("Highlightsync " .. (immediate and "executed" or "scheduled") .. " with reload protection")
return true
end
-- --- MAIN SYNC FUNCTION ---
local function performUnifiedSync(sync_type)
log("========== UNIFIED SYNC START (" .. sync_type .. ") ==========")
if not G_reader_settings:isTrue("unified_autosync_enabled") then
log("Auto sync disabled")
return
end
-- GLOBAL cooldown check (applies to ALL sync types)
if not canSync() then
return
end
-- Mark sync as in progress
sync_state.is_syncing = true
sync_state.last_sync_time = os.time()
-- Determine if we need immediate execution
local immediate = (sync_type == "on_close" or sync_type == "on_suspend" or sync_type == "manual")
log("Sync mode: " .. (immediate and "IMMEDIATE" or "SCHEDULED"))
local sync_scheduled = {}
-- Sync all plugins
if syncHighlightsync(immediate) then
table.insert(sync_scheduled, "Highlights")
end
if syncStatistics(immediate) then
table.insert(sync_scheduled, "Statistics")
end
if syncVocabularyBuilder(immediate) then
table.insert(sync_scheduled, "Vocabulary")
end
if #sync_scheduled > 0 then
log("Synced: " .. table.concat(sync_scheduled, ", "))
G_reader_settings:saveSetting("unified_autosync_last_sync", os.date("%H:%M:%S"))
if G_reader_settings:readSetting("unified_autosync_show_notifications") then
local notification_text = immediate and "Synced: " or "Syncing: "
UIManager:scheduleIn(0.5, function()
showMessage(notification_text .. table.concat(sync_scheduled, ", "), 2)
end)
end
-- Mark sync as complete
if immediate then
sync_state.is_syncing = false
log("Sync cycle completed (immediate)")
else
UIManager:scheduleIn(5, function()
sync_state.is_syncing = false
log("Sync cycle completed (scheduled)")
end)
end
else
sync_state.is_syncing = false
log("No plugins to sync")
end
log("========== UNIFIED SYNC " .. (immediate and "EXECUTED" or "SCHEDULED") .. " ==========")
end
-- --- MENU ---
local function createMenuItem()
return {
text = "Unified Auto Sync",
sub_item_table = {
{
text = "Enable Auto Sync",
checked_func = function()
return G_reader_settings:isTrue("unified_autosync_enabled")
end,
callback = function()
local val = not G_reader_settings:isTrue("unified_autosync_enabled")
G_reader_settings:saveSetting("unified_autosync_enabled", val)
showMessage("Auto-sync " .. (val and "enabled" or "disabled"))
end,
separator = true,
},
{
text = "Sync Now",
callback = function()
-- Clear cooldown for manual sync
sync_state.last_sync_time = 0
sync_state.is_syncing = false
showMessage("Starting sync...", 1)
performUnifiedSync("manual")
end
},
{
text = "Clear Cooldown",
keep_menu_open = true,
callback = function()
sync_state.last_sync_time = 0
sync_state.is_syncing = false
showMessage("Cooldown cleared - ready to sync")
end,
separator = true,
},
{
text = "Sync on Open",
checked_func = function()
return G_reader_settings:readSetting("unified_autosync_on_open") ~= false
end,
callback = function()
local val = G_reader_settings:readSetting("unified_autosync_on_open") ~= false
G_reader_settings:saveSetting("unified_autosync_on_open", not val)
showMessage("Sync on open " .. (not val and "enabled" or "disabled"))
end,
},
{
text = "Open Delay",
keep_menu_open = true,
sub_item_table = {
{
text = "1 second",
checked_func = function()
return G_reader_settings:readSetting("unified_autosync_open_delay") == 1
end,
callback = function()
G_reader_settings:saveSetting("unified_autosync_open_delay", 1)
showMessage("Open delay: 1 second")
end,
},
{
text = "3 seconds (default)",
checked_func = function()
local delay = G_reader_settings:readSetting("unified_autosync_open_delay")
return delay == nil or delay == 3
end,
callback = function()
G_reader_settings:saveSetting("unified_autosync_open_delay", 3)
showMessage("Open delay: 3 seconds")
end,
},
{
text = "5 seconds",
checked_func = function()
return G_reader_settings:readSetting("unified_autosync_open_delay") == 5
end,
callback = function()
G_reader_settings:saveSetting("unified_autosync_open_delay", 5)
showMessage("Open delay: 5 seconds")
end,
},
},
},
{
text = "Sync on Close",
checked_func = function()
return G_reader_settings:readSetting("unified_autosync_on_close") ~= false
end,
callback = function()
local val = G_reader_settings:readSetting("unified_autosync_on_close") ~= false
G_reader_settings:saveSetting("unified_autosync_on_close", not val)
showMessage("Sync on close " .. (not val and "enabled" or "disabled"))
end,
},
{
text = "Sync on Suspend",
checked_func = function()
return G_reader_settings:readSetting("unified_autosync_on_suspend") or false
end,
callback = function()
local val = G_reader_settings:readSetting("unified_autosync_on_suspend") or false
G_reader_settings:saveSetting("unified_autosync_on_suspend", not val)
showMessage("Sync on suspend " .. (not val and "enabled" or "disabled"))
end,
separator = true,
},
{
text = "Cooldown Duration",
keep_menu_open = true,
sub_item_table = {
{
text = "15 seconds",
checked_func = function() return sync_state.cooldown_seconds == 15 end,
callback = function()
sync_state.cooldown_seconds = 15
showMessage("Cooldown: 15 seconds")
end,
},
{
text = "30 seconds (default)",
checked_func = function() return sync_state.cooldown_seconds == 30 end,
callback = function()
sync_state.cooldown_seconds = 30
showMessage("Cooldown: 30 seconds")
end,
},
{
text = "60 seconds",
checked_func = function() return sync_state.cooldown_seconds == 60 end,
callback = function()
sync_state.cooldown_seconds = 60
showMessage("Cooldown: 60 seconds")
end,
},
{
text = "2 minutes",
checked_func = function() return sync_state.cooldown_seconds == 120 end,
callback = function()
sync_state.cooldown_seconds = 120
showMessage("Cooldown: 2 minutes")
end,
},
},
},
{
text = "Include Highlightsync",
checked_func = function()
return G_reader_settings:readSetting("unified_autosync_include_highlights") ~= false
end,
callback = function()
local val = G_reader_settings:readSetting("unified_autosync_include_highlights") ~= false
G_reader_settings:saveSetting("unified_autosync_include_highlights", not val)
showMessage("Highlightsync " .. (not val and "enabled" or "disabled"))
end,
help_text = "Enable/disable automatic Highlightsync. Uses smart reload detection.",
separator = true,
},
{
text = "Show Notifications",
checked_func = function()
return G_reader_settings:readSetting("unified_autosync_show_notifications") or false
end,
callback = function()
local val = G_reader_settings:readSetting("unified_autosync_show_notifications") or false
G_reader_settings:saveSetting("unified_autosync_show_notifications", not val)
showMessage("Notifications " .. (not val and "enabled" or "disabled"))
end,
},
{
text = "Show Startup Message",
checked_func = function()
return G_reader_settings:readSetting("unified_autosync_show_startup_message") or false
end,
callback = function()
local val = G_reader_settings:readSetting("unified_autosync_show_startup_message") or false
G_reader_settings:saveSetting("unified_autosync_show_startup_message", not val)
showMessage("Startup message " .. (not val and "enabled" or "disabled"))
end,
help_text = "Show a notification when the patch loads (for debugging)",
separator = true,
},
{
text = "View Status",
keep_menu_open = true,
callback = function()
local enabled = G_reader_settings:isTrue("unified_autosync_enabled") and "Enabled" or "Disabled"
local last_sync = G_reader_settings:readSetting("unified_autosync_last_sync") or "Never"
local on_open = G_reader_settings:readSetting("unified_autosync_on_open") ~= false
local on_close = G_reader_settings:readSetting("unified_autosync_on_close") ~= false
local on_suspend = G_reader_settings:readSetting("unified_autosync_on_suspend") or false
local stats_status = isStatsConfigured() and "✓" or "✗"
local vocab_status = isVocabConfigured() and "✓" or "✗"
local hl_enabled = G_reader_settings:readSetting("unified_autosync_include_highlights") ~= false
local hl_status = hl_enabled and (isHighlightConfigured() and "✓" or "✗") or "⊘"
local sync_status = sync_state.is_syncing and "Syncing..." or "Idle"
if sync_state.in_highlightsync_reload then
sync_status = "In reload (protected)"
end
local time_since_last = os.time() - sync_state.last_sync_time
local cooldown_remaining = math.max(0, sync_state.cooldown_seconds - time_since_last)
local cooldown_text = cooldown_remaining > 0
and string.format("cooldown: %ds", cooldown_remaining)
or "ready"
local status = string.format(
"Status: %s\nState: %s (%s)\nLast sync: %s\n\nTriggers:\n• Open: %s\n• Close: %s\n• Suspend: %s\n\nPlugins:\n• Highlights: %s\n• Statistics: %s\n• Vocabulary: %s",
enabled, sync_status, cooldown_text, last_sync,
on_open and "Yes" or "No",
on_close and "Yes" or "No",
on_suspend and "Yes" or "No",
hl_status, stats_status, vocab_status
)
showMessage(status, 8)
end
}
}
}
end
-- --- MENU HOOK ---
log("Attempting to hook ReaderMenu...")
local menu_hook_success, menu_hook_error = pcall(function()
log("ReaderMenu module available")
local original_setUpdateItemTable = ReaderMenu.setUpdateItemTable
if not original_setUpdateItemTable then
log("WARNING: ReaderMenu.setUpdateItemTable not found")
return
end
ReaderMenu.setUpdateItemTable = function(self)
log("setUpdateItemTable called")
-- Add our menu item BEFORE calling original function (critical!)
local add_success, add_error = pcall(function()
-- Try to get the menu order to insert into proper section
local ok_order, reader_order = pcall(require, "ui/elements/reader_menu_order")
self.menu_items = self.menu_items or {}
self.menu_items.unified_autosync = createMenuItem()
log("Menu item created and added to menu_items")
-- Add to menu order (try tools, then more_tools as fallback)
if ok_order and reader_order then
if reader_order.tools then
table.insert(reader_order.tools, "unified_autosync")
log("Added to reader_order.tools")
elseif reader_order.more_tools then
table.insert(reader_order.more_tools, "unified_autosync")
log("Added to reader_order.more_tools")
end
else
-- Fallback: add directly to self.menu_order
self.menu_order = self.menu_order or {}
self.menu_order.tools = self.menu_order.tools or {}
table.insert(self.menu_order.tools, "unified_autosync")
log("Added to self.menu_order.tools (fallback)")
end
end)
if not add_success then
log("ERROR adding menu item: " .. tostring(add_error))
end
-- NOW call the original function
original_setUpdateItemTable(self)
log("Original setUpdateItemTable called")
end
log("ReaderMenu hook installed")
end)
if not menu_hook_success then
log("ERROR hooking ReaderMenu: " .. tostring(menu_hook_error))
end
-- --- EVENT HOOKS ---
local original_onReaderReady = ReaderUI.onReaderReady
ReaderUI.onReaderReady = function(self, ...)
log("onReaderReady triggered")
if original_onReaderReady then
original_onReaderReady(self, ...)
end
if G_reader_settings:isTrue("unified_autosync_enabled") and
G_reader_settings:readSetting("unified_autosync_on_open") ~= false then
local delay = G_reader_settings:readSetting("unified_autosync_open_delay") or 3
log(string.format("Scheduling sync on document open (%ds delay)", delay))
UIManager:scheduleIn(delay, function()
performUnifiedSync("on_open")
end)
else
log("Sync on open disabled")
end
end
local original_onCloseDocument = ReaderUI.onCloseDocument
ReaderUI.onCloseDocument = function(self, ...)
log("onCloseDocument triggered")
if G_reader_settings:isTrue("unified_autosync_enabled") and
G_reader_settings:readSetting("unified_autosync_on_close") ~= false then
log("Triggering sync on document close")
performUnifiedSync("on_close")
else
log("Sync on close disabled")
end
if original_onCloseDocument then
original_onCloseDocument(self, ...)
end
end
local original_onSuspend = ReaderUI.onSuspend
ReaderUI.onSuspend = function(self, ...)
log("onSuspend triggered")
if G_reader_settings:isTrue("unified_autosync_enabled") and
G_reader_settings:readSetting("unified_autosync_on_suspend") then
log("Triggering sync on suspend")
performUnifiedSync("on_suspend")
else
log("Sync on suspend disabled")
end
if original_onSuspend then
original_onSuspend(self, ...)
end
end
-- --- SET DEFAULTS ---
if G_reader_settings:readSetting("unified_autosync_enabled") == nil then
log("First run: setting defaults")
G_reader_settings:saveSetting("unified_autosync_enabled", true)
G_reader_settings:saveSetting("unified_autosync_on_open", true)
G_reader_settings:saveSetting("unified_autosync_on_close", true)
G_reader_settings:saveSetting("unified_autosync_on_suspend", false)
G_reader_settings:saveSetting("unified_autosync_show_notifications", false)
G_reader_settings:saveSetting("unified_autosync_show_startup_message", false)
G_reader_settings:saveSetting("unified_autosync_open_delay", 3)
G_reader_settings:saveSetting("unified_autosync_include_highlights", true)
end
log("========== UNIFIED AUTO SYNC PATCH READY (v3.3) ==========")
-- Show a startup notification if enabled (for debugging)
if G_reader_settings:readSetting("unified_autosync_show_startup_message") then
log("Showing startup notification")
UIManager:scheduleIn(2, function()
showMessage("Unified Auto Sync loaded ✓", 2)
end)
end
return true
@bobby-sills
Copy link

Hi @mallomar , I love your patch. Is there a way to make it sync daily? For instance the sync clock resets at midnight, and the next time you open koreader it syncs. My Kindle is quite slow, so syncing on every open and close takes a lot of time.

@bobby-sills
Copy link

Would adding

text = "Cooldown Duration",
                keep_menu_open = true,
                sub_item_table = {
                    {
                        text = "24 hours",
                        checked_func = function() return sync_state.cooldown_hours == 24 end,
                        callback = function()
                            sync_state.cooldown_hours = 24
                            showMessage("Cooldown: 24 hours")
                        end,
                    },
                },

To the cooldown table work?

@mallomar
Copy link
Author

mallomar commented Dec 8, 2025

Would adding

text = "Cooldown Duration",
                keep_menu_open = true,
                sub_item_table = {
                    {
                        text = "24 hours",
                        checked_func = function() return sync_state.cooldown_hours == 24 end,
                        callback = function()
                            sync_state.cooldown_hours = 24
                            showMessage("Cooldown: 24 hours")
                        end,
                    },
                },

To the cooldown table work?

The version below should work for you:

-- 2-unified-autosync.lua - KOReader Auto Sync Patch
-- Place in /storage/emulated/0/koreader/patches/
--
-- Features:
-- - Syncs Statistics and Vocabulary Builder automatically (Highlightsync optional)
-- - Configurable cooldown: 15s, 30s, 1min, 2min, 1h, 6h, or 24h (daily)
-- - Robust Highlightsync detection with fallback (disabled by default for mobile hotspot)
-- - Global cooldown prevents double-syncing
-- - Stable Vocabulary sync (skipped on close/suspend for slow connections)
-- - Smart reload detection prevents infinite loops
-- - Configurable triggers (open, close, suspend)
--
-- MOBILE HOTSPOT SAFE: Highlightsync disabled by default (enable in menu if on WiFi)
--
-- Version: 3.7 (Daily Sync Support)

local log_file = "/storage/emulated/0/koreader/settings/unified-autosync-debug.txt"
local function log(msg)
    local file = io.open(log_file, "a")
    if file then
        file:write(os.date("%Y-%m-%d %H:%M:%S") .. " - " .. msg .. "\n")
        file:close()
    end
end

log("========== UNIFIED AUTO SYNC PATCH LOADING (v3.7) ==========")

local UIManager = require("ui/uimanager")
local InfoMessage = require("ui/widget/infomessage")
local ReaderUI = require("apps/reader/readerui")
local ReaderMenu = require("apps/reader/modules/readermenu")

-- --- STATE TRACKING ---
local sync_state = {
    is_syncing = false,
    last_sync_time = 0,
    cooldown_seconds = 30,  -- Default: 30s. Can be changed to 86400 for daily sync
    in_highlightsync_reload = false,
    reload_timeout = nil,
}

-- Check if enough time has passed since last sync
local function canSync()
    if sync_state.in_highlightsync_reload then
        log("Inside Highlightsync reload cycle, skipping sync")
        return false
    end
    
    local current_time = os.time()
    local time_since_last = current_time - sync_state.last_sync_time
    
    if sync_state.is_syncing then
        log("Sync already in progress, skipping")
        return false
    end
    
    if time_since_last < sync_state.cooldown_seconds then
        local remaining = sync_state.cooldown_seconds - time_since_last
        if remaining >= 3600 then
            log(string.format("Cooldown active (%.1fh remaining), skipping", remaining / 3600))
        elseif remaining >= 60 then
            log(string.format("Cooldown active (%dm remaining), skipping", math.floor(remaining / 60)))
        else
            log(string.format("Cooldown active (%ds remaining), skipping", remaining))
        end
        return false
    end
    
    return true
end

local function showMessage(text, timeout)
    pcall(function()
        UIManager:show(InfoMessage:new{ text = text, timeout = timeout or 3 })
    end)
end

-- --- PLUGIN CONFIGURATION CHECKS ---
local function isStatsConfigured()
    local settings = G_reader_settings:readSetting("statistics") or {}
    return settings.sync_server ~= nil
end

local function isVocabConfigured()
    local settings = G_reader_settings:readSetting("vocabulary_builder") or {}
    return settings.server ~= nil
end

local function isHighlightConfigured()
    local settings = G_reader_settings:readSetting("highlight_sync") or {}
    return settings.sync_server ~= nil
end

-- --- SYNC FUNCTIONS ---
local function syncStatistics(immediate)
    log("Attempting to sync Statistics...")
    if not isStatsConfigured() then
        log("Statistics not configured")
        return false
    end
    
    local sync_func = function()
        if ReaderUI.instance and ReaderUI.instance.statistics then
            local success = pcall(function() 
                ReaderUI.instance.statistics:onSyncBookStats(true) 
            end)
            if success then
                log("✓ Statistics sync completed")
            else
                log("✗ Statistics sync failed")
            end
        end
    end
    
    if immediate then
        sync_func()
    else
        UIManager:scheduleIn(0.1, sync_func)
    end
    
    log("Statistics sync " .. (immediate and "executed" or "scheduled"))
    return true
end

local function syncVocabularyBuilder(immediate)
    log("Attempting to sync Vocabulary Builder...")
    if not isVocabConfigured() then
        log("VocabBuilder not configured")
        return false
    end

    -- MOBILE HOTSPOT FIX: Skip VocabBuilder on immediate syncs (close/suspend)
    if immediate then
        log("VocabBuilder: Skipping on immediate sync (prevents crashes on slow connections)")
        return false
    end

    UIManager:scheduleIn(0.2, function()
        log("VocabBuilder: Starting...")
        local success = pcall(function()
            local SyncService = require("frontend/apps/cloudstorage/syncservice")
            local DataStorage = require("datastorage")
            
            local vocab_db_path = "plugins/vocabbuilder.koplugin/db.lua"
            local chunk = loadfile(vocab_db_path)
            if not chunk then 
                log("✗ VocabBuilder: Could not load db.lua")
                return 
            end
            
            local ok, VocabDB = pcall(chunk)
            if not ok or not VocabDB then 
                log("✗ VocabBuilder: Failed to execute db.lua")
                return 
            end
            
            if ReaderUI.instance and ReaderUI.instance.vocabulary_builder then
                local vb = ReaderUI.instance.vocabulary_builder
                if vb.widget and vb.widget.item_table then
                    pcall(VocabDB.batchUpdateItems, VocabDB, vb.widget.item_table)
                    log("VocabBuilder: Saved pending words")
                elseif vb.item_table then
                    pcall(VocabDB.batchUpdateItems, VocabDB, vb.item_table)
                    log("VocabBuilder: Saved pending words (fallback)")
                end
            end
            
            local settings = G_reader_settings:readSetting("vocabulary_builder") or {}
            local db_path = DataStorage:getSettingsDir() .. "/vocabulary_builder.sqlite3"
            
            SyncService.sync(settings.server, db_path, VocabDB.onSync, true)
            log("✓ Vocabulary sync completed")
        end)
        
        if not success then
            log("✗ Vocabulary sync failed")
        end
    end)
    
    log("Vocabulary sync scheduled")
    return true
end

local function syncHighlightsync(immediate)
    log("Attempting to sync Highlightsync...")
    
    if G_reader_settings:readSetting("unified_autosync_include_highlights") == false then
        log("Highlightsync disabled by user")
        return false
    end
    
    if not isHighlightConfigured() then
        log("Highlightsync not configured")
        return false
    end
    
    if not ReaderUI.instance then
        log("ReaderUI not ready")
        return false
    end
    
    local highlightsync = ReaderUI.instance.highlight_sync or 
                         ReaderUI.instance.highlightsync or
                         ReaderUI.instance.Highlightsync
    
    if not (highlightsync and highlightsync.onSyncBookHighlights) then
        log("Highlightsync not in ReaderUI, trying direct load...")
        local ok, plugin = pcall(require, "plugins/highlightsync.koplugin/main")
        if ok and plugin and plugin.onSyncBookHighlights then
            highlightsync = plugin
            log("Highlightsync loaded directly")
        else
            log("✗ Highlightsync plugin not found")
            return false
        end
    end
    
    sync_state.in_highlightsync_reload = true
    log("Reload flag SET - will ignore next close/open events")
    
    if sync_state.reload_timeout then
        UIManager:unschedule(sync_state.reload_timeout)
    end
    
    sync_state.reload_timeout = function()
        sync_state.in_highlightsync_reload = false
        sync_state.reload_timeout = nil
        log("Reload flag CLEARED (timeout)")
    end
    UIManager:scheduleIn(8, sync_state.reload_timeout)
    
    local sync_func = function()
        local success = pcall(function()
            highlightsync:onSyncBookHighlights(true)
        end)
        
        if success then
            log("✓ Highlightsync completed")
            UIManager:scheduleIn(3, function()
                if sync_state.in_highlightsync_reload then
                    sync_state.in_highlightsync_reload = false
                    log("Reload flag CLEARED (after successful sync)")
                end
            end)
        else
            log("✗ Highlightsync failed")
            sync_state.in_highlightsync_reload = false
            log("Reload flag CLEARED (sync failed)")
        end
    end
    
    if immediate then
        sync_func()
    else
        UIManager:scheduleIn(0.3, sync_func)
    end
    
    log("Highlightsync " .. (immediate and "executed" or "scheduled") .. " with reload protection")
    return true
end

-- --- MAIN SYNC FUNCTION ---
local function performUnifiedSync(sync_type)
    log("========== UNIFIED SYNC START (" .. sync_type .. ") ==========")
    
    if not G_reader_settings:isTrue("unified_autosync_enabled") then
        log("Auto sync disabled")
        return
    end
    
    if not canSync() then
        return
    end
    
    sync_state.is_syncing = true
    sync_state.last_sync_time = os.time()
    
    local immediate = (sync_type == "on_close" or sync_type == "on_suspend" or sync_type == "manual")
    log("Sync mode: " .. (immediate and "IMMEDIATE" or "SCHEDULED"))
    
    local sync_scheduled = {}
    
    if syncHighlightsync(immediate) then
        table.insert(sync_scheduled, "Highlights")
    end
    if syncStatistics(immediate) then
        table.insert(sync_scheduled, "Statistics")
    end
    if syncVocabularyBuilder(immediate) then
        table.insert(sync_scheduled, "Vocabulary")
    end
    
    if #sync_scheduled > 0 then
        log("Synced: " .. table.concat(sync_scheduled, ", "))
        G_reader_settings:saveSetting("unified_autosync_last_sync", os.date("%Y-%m-%d %H:%M:%S"))
        
        if G_reader_settings:readSetting("unified_autosync_show_notifications") then
            local notification_text = immediate and "Synced: " or "Syncing: "
            UIManager:scheduleIn(0.5, function()
                showMessage(notification_text .. table.concat(sync_scheduled, ", "), 2)
            end)
        end
        
        if immediate then
            sync_state.is_syncing = false
            log("Sync cycle completed (immediate)")
        else
            UIManager:scheduleIn(5, function()
                sync_state.is_syncing = false
                log("Sync cycle completed (scheduled)")
            end)
        end
    else
        sync_state.is_syncing = false
        log("No plugins to sync")
    end
    
    log("========== UNIFIED SYNC " .. (immediate and "EXECUTED" or "SCHEDULED") .. " ==========")
end

-- --- MENU ---
local function createMenuItem()
    return {
        text = "Unified Auto Sync",
        sub_item_table = {
            {
                text = "Enable Auto Sync",
                checked_func = function() 
                    return G_reader_settings:isTrue("unified_autosync_enabled") 
                end,
                callback = function()
                    local val = not G_reader_settings:isTrue("unified_autosync_enabled")
                    G_reader_settings:saveSetting("unified_autosync_enabled", val)
                    showMessage("Auto-sync " .. (val and "enabled" or "disabled"))
                end,
                separator = true,
            },
            {
                text = "Sync Now",
                callback = function() 
                    sync_state.last_sync_time = 0
                    sync_state.is_syncing = false
                    showMessage("Starting sync...", 1)
                    performUnifiedSync("manual")
                end
            },
            {
                text = "Clear Cooldown",
                keep_menu_open = true,
                callback = function()
                    sync_state.last_sync_time = 0
                    sync_state.is_syncing = false
                    showMessage("Cooldown cleared - ready to sync")
                end,
                separator = true,
            },
            {
                text = "Sync on Open",
                checked_func = function() 
                    return G_reader_settings:readSetting("unified_autosync_on_open") ~= false 
                end,
                callback = function()
                    local val = G_reader_settings:readSetting("unified_autosync_on_open") ~= false
                    G_reader_settings:saveSetting("unified_autosync_on_open", not val)
                    showMessage("Sync on open " .. (not val and "enabled" or "disabled"))
                end,
            },
            {
                text = "Open Delay",
                keep_menu_open = true,
                sub_item_table = {
                    {
                        text = "1 second",
                        checked_func = function()
                            return G_reader_settings:readSetting("unified_autosync_open_delay") == 1
                        end,
                        callback = function()
                            G_reader_settings:saveSetting("unified_autosync_open_delay", 1)
                            showMessage("Open delay: 1 second")
                        end,
                    },
                    {
                        text = "3 seconds (default)",
                        checked_func = function()
                            local delay = G_reader_settings:readSetting("unified_autosync_open_delay")
                            return delay == nil or delay == 3
                        end,
                        callback = function()
                            G_reader_settings:saveSetting("unified_autosync_open_delay", 3)
                            showMessage("Open delay: 3 seconds")
                        end,
                    },
                    {
                        text = "5 seconds",
                        checked_func = function()
                            return G_reader_settings:readSetting("unified_autosync_open_delay") == 5
                        end,
                        callback = function()
                            G_reader_settings:saveSetting("unified_autosync_open_delay", 5)
                            showMessage("Open delay: 5 seconds")
                        end,
                    },
                },
            },
            {
                text = "Sync on Close",
                checked_func = function() 
                    return G_reader_settings:readSetting("unified_autosync_on_close") ~= false 
                end,
                callback = function()
                    local val = G_reader_settings:readSetting("unified_autosync_on_close") ~= false
                    G_reader_settings:saveSetting("unified_autosync_on_close", not val)
                    showMessage("Sync on close " .. (not val and "enabled" or "disabled"))
                end,
            },
            {
                text = "Sync on Suspend",
                checked_func = function() 
                    return G_reader_settings:readSetting("unified_autosync_on_suspend") or false 
                end,
                callback = function()
                    local val = G_reader_settings:readSetting("unified_autosync_on_suspend") or false
                    G_reader_settings:saveSetting("unified_autosync_on_suspend", not val)
                    showMessage("Sync on suspend " .. (not val and "enabled" or "disabled"))
                end,
                separator = true,
            },
            {
                text = "Cooldown Duration",
                keep_menu_open = true,
                sub_item_table = {
                    {
                        text = "15 seconds",
                        checked_func = function() return sync_state.cooldown_seconds == 15 end,
                        callback = function()
                            sync_state.cooldown_seconds = 15
                            showMessage("Cooldown: 15 seconds")
                        end,
                    },
                    {
                        text = "30 seconds (default)",
                        checked_func = function() return sync_state.cooldown_seconds == 30 end,
                        callback = function()
                            sync_state.cooldown_seconds = 30
                            showMessage("Cooldown: 30 seconds")
                        end,
                    },
                    {
                        text = "1 minute",
                        checked_func = function() return sync_state.cooldown_seconds == 60 end,
                        callback = function()
                            sync_state.cooldown_seconds = 60
                            showMessage("Cooldown: 1 minute")
                        end,
                    },
                    {
                        text = "2 minutes",
                        checked_func = function() return sync_state.cooldown_seconds == 120 end,
                        callback = function()
                            sync_state.cooldown_seconds = 120
                            showMessage("Cooldown: 2 minutes")
                        end,
                    },
                    {
                        text = "1 hour",
                        checked_func = function() return sync_state.cooldown_seconds == 3600 end,
                        callback = function()
                            sync_state.cooldown_seconds = 3600
                            showMessage("Cooldown: 1 hour")
                        end,
                    },
                    {
                        text = "6 hours",
                        checked_func = function() return sync_state.cooldown_seconds == 21600 end,
                        callback = function()
                            sync_state.cooldown_seconds = 21600
                            showMessage("Cooldown: 6 hours")
                        end,
                    },
                    {
                        text = "24 hours (daily sync)",
                        checked_func = function() return sync_state.cooldown_seconds == 86400 end,
                        callback = function()
                            sync_state.cooldown_seconds = 86400
                            showMessage("Cooldown: 24 hours\nSyncs once per day")
                        end,
                    },
                },
                help_text = "Minimum time between syncs. Set to 24 hours for daily sync.",
            },
            {
                text = "Include Highlightsync",
                checked_func = function() 
                    return G_reader_settings:readSetting("unified_autosync_include_highlights") ~= false 
                end,
                callback = function()
                    local val = G_reader_settings:readSetting("unified_autosync_include_highlights") ~= false
                    G_reader_settings:saveSetting("unified_autosync_include_highlights", not val)
                    showMessage("Highlightsync " .. (not val and "enabled" or "disabled"))
                end,
                help_text = "Enable/disable automatic Highlightsync. Uses smart reload detection.",
                separator = true,
            },
            {
                text = "Show Notifications",
                checked_func = function() 
                    return G_reader_settings:readSetting("unified_autosync_show_notifications") or false 
                end,
                callback = function()
                    local val = G_reader_settings:readSetting("unified_autosync_show_notifications") or false
                    G_reader_settings:saveSetting("unified_autosync_show_notifications", not val)
                    showMessage("Notifications " .. (not val and "enabled" or "disabled"))
                end,
            },
            {
                text = "Show Startup Message",
                checked_func = function()
                    return G_reader_settings:readSetting("unified_autosync_show_startup_message") or false
                end,
                callback = function()
                    local val = G_reader_settings:readSetting("unified_autosync_show_startup_message") or false
                    G_reader_settings:saveSetting("unified_autosync_show_startup_message", not val)
                    showMessage("Startup message " .. (not val and "enabled" or "disabled"))
                end,
                help_text = "Show a notification when the patch loads (for debugging)",
                separator = true,
            },
            {
                text = "View Status",
                keep_menu_open = true,
                callback = function() 
                    local enabled = G_reader_settings:isTrue("unified_autosync_enabled") and "Enabled" or "Disabled"
                    local last_sync = G_reader_settings:readSetting("unified_autosync_last_sync") or "Never"
                    local on_open = G_reader_settings:readSetting("unified_autosync_on_open") ~= false
                    local on_close = G_reader_settings:readSetting("unified_autosync_on_close") ~= false
                    local on_suspend = G_reader_settings:readSetting("unified_autosync_on_suspend") or false
                    
                    local stats_status = isStatsConfigured() and "✓" or "✗"
                    local vocab_status = isVocabConfigured() and "✓" or "✗"
                    local hl_enabled = G_reader_settings:readSetting("unified_autosync_include_highlights") ~= false
                    local hl_status = hl_enabled and (isHighlightConfigured() and "✓" or "✗") or "⊘"
                    
                    local sync_status = sync_state.is_syncing and "Syncing..." or "Idle"
                    if sync_state.in_highlightsync_reload then
                        sync_status = "In reload (protected)"
                    end
                    
                    local time_since_last = os.time() - sync_state.last_sync_time
                    local cooldown_remaining = math.max(0, sync_state.cooldown_seconds - time_since_last)
                    local cooldown_text
                    if cooldown_remaining >= 3600 then
                        cooldown_text = string.format("cooldown: %.1fh", cooldown_remaining / 3600)
                    elseif cooldown_remaining >= 60 then
                        cooldown_text = string.format("cooldown: %dm", math.floor(cooldown_remaining / 60))
                    elseif cooldown_remaining > 0 then
                        cooldown_text = string.format("cooldown: %ds", cooldown_remaining)
                    else
                        cooldown_text = "ready"
                    end
                    
                    local status = string.format(
                        "Status: %s\nState: %s (%s)\nLast sync: %s\n\nTriggers:\n• Open: %s\n• Close: %s\n• Suspend: %s\n\nPlugins:\n• Highlights: %s\n• Statistics: %s\n• Vocabulary: %s",
                        enabled, sync_status, cooldown_text, last_sync,
                        on_open and "Yes" or "No",
                        on_close and "Yes" or "No",
                        on_suspend and "Yes" or "No",
                        hl_status, stats_status, vocab_status
                    )
                    showMessage(status, 8)
                end
            }
        }
    }
end

-- --- MENU HOOK ---
log("Attempting to hook ReaderMenu...")
local menu_hook_success, menu_hook_error = pcall(function()
    log("ReaderMenu module available")
    
    local original_setUpdateItemTable = ReaderMenu.setUpdateItemTable
    if not original_setUpdateItemTable then
        log("WARNING: ReaderMenu.setUpdateItemTable not found")
        return
    end
    
    ReaderMenu.setUpdateItemTable = function(self)
        log("setUpdateItemTable called")
        
        local add_success, add_error = pcall(function()
            local ok_order, reader_order = pcall(require, "ui/elements/reader_menu_order")
            
            self.menu_items = self.menu_items or {}
            self.menu_items.unified_autosync = createMenuItem()
            log("Menu item created and added to menu_items")
            
            if ok_order and reader_order then
                if reader_order.tools then
                    table.insert(reader_order.tools, "unified_autosync")
                    log("Added to reader_order.tools")
                elseif reader_order.more_tools then
                    table.insert(reader_order.more_tools, "unified_autosync")
                    log("Added to reader_order.more_tools")
                end
            else
                self.menu_order = self.menu_order or {}
                self.menu_order.tools = self.menu_order.tools or {}
                table.insert(self.menu_order.tools, "unified_autosync")
                log("Added to self.menu_order.tools (fallback)")
            end
        end)
        
        if not add_success then
            log("ERROR adding menu item: " .. tostring(add_error))
        end
        
        original_setUpdateItemTable(self)
        log("Original setUpdateItemTable called")
    end
    
    log("ReaderMenu hook installed")
end)

if not menu_hook_success then
    log("ERROR hooking ReaderMenu: " .. tostring(menu_hook_error))
end

-- --- EVENT HOOKS ---
local original_onReaderReady = ReaderUI.onReaderReady
ReaderUI.onReaderReady = function(self, ...)
    log("========== onReaderReady triggered ==========")
    log(string.format("Reload flag state: %s", tostring(sync_state.in_highlightsync_reload)))
    
    if original_onReaderReady then 
        log("Calling original onReaderReady")
        original_onReaderReady(self, ...) 
        log("Original onReaderReady completed")
    end
    
    if G_reader_settings:isTrue("unified_autosync_enabled") and 
       G_reader_settings:readSetting("unified_autosync_on_open") ~= false then
        local delay = G_reader_settings:readSetting("unified_autosync_open_delay") or 3
        log(string.format("Scheduling sync on document open (%ds delay)", delay))
        UIManager:scheduleIn(delay, function() 
            performUnifiedSync("on_open") 
        end)
    else
        log("Sync on open disabled")
    end
end

local original_onCloseDocument = ReaderUI.onCloseDocument
ReaderUI.onCloseDocument = function(self, ...)
    log("========== onCloseDocument triggered ==========")
    log(string.format("Reload flag state: %s", tostring(sync_state.in_highlightsync_reload)))
    log(string.format("Time since last sync: %ds", os.time() - sync_state.last_sync_time))
    
    local debug_info = debug.getinfo(2, "Sl")
    if debug_info then
        log(string.format("Called from: %s:%d", debug_info.short_src or "unknown", debug_info.currentline or 0))
    else
        log("Could not get caller info")
    end
    
    if G_reader_settings:isTrue("unified_autosync_enabled") and 
       G_reader_settings:readSetting("unified_autosync_on_close") ~= false then
        log("Triggering sync on document close")
        performUnifiedSync("on_close")
    else
        log("Sync on close disabled")
    end
    
    if original_onCloseDocument then 
        log("Calling original onCloseDocument")
        original_onCloseDocument(self, ...)
        log("Original onCloseDocument completed")
    end
end

local original_onSuspend = ReaderUI.onSuspend
ReaderUI.onSuspend = function(self, ...)
    log("========== onSuspend triggered ==========")
    log(string.format("Reload flag state: %s", tostring(sync_state.in_highlightsync_reload)))
    log(string.format("Time since last sync: %ds", os.time() - sync_state.last_sync_time))
    
    if G_reader_settings:isTrue("unified_autosync_enabled") and 
       G_reader_settings:readSetting("unified_autosync_on_suspend") then
        log("Triggering sync on suspend")
        performUnifiedSync("on_suspend")
    else
        log("Sync on suspend disabled")
    end
    
    if original_onSuspend then 
        log("Calling original onSuspend")
        original_onSuspend(self, ...) 
        log("Original onSuspend completed")
    end
end

-- --- SET DEFAULTS ---
if G_reader_settings:readSetting("unified_autosync_enabled") == nil then
    log("First run: setting defaults")
    G_reader_settings:saveSetting("unified_autosync_enabled", true)
    G_reader_settings:saveSetting("unified_autosync_on_open", true)
    G_reader_settings:saveSetting("unified_autosync_on_close", true)
    G_reader_settings:saveSetting("unified_autosync_on_suspend", false)
    G_reader_settings:saveSetting("unified_autosync_show_notifications", false)
    G_reader_settings:saveSetting("unified_autosync_show_startup_message", false)
    G_reader_settings:saveSetting("unified_autosync_open_delay", 3)
    G_reader_settings:saveSetting("unified_autosync_include_highlights", false)
end

if G_reader_settings:readSetting("unified_autosync_include_highlights") == nil then
    log("Highlightsync not configured, disabling by default for stability")
    G_reader_settings:saveSetting("unified_autosync_include_highlights", false)
end

log("========== UNIFIED AUTO SYNC PATCH READY (v3.7) ==========")

if G_reader_settings:readSetting("unified_autosync_show_startup_message") then
    log("Showing startup notification")
    UIManager:scheduleIn(2, function()
        showMessage("Unified Auto Sync loaded ✓", 2)
    end)
end

return true

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment