Ctrl K
ComunidadGuías…

koreader-plugin/bookorbit.koplugin/bookorbit_book_sync.lua

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
--[[--
Per-book snapshot sync: pushes progress, highlights, status/rating and page
stats for ONE book, sourced from live memory instead of the sidecar file, so
mid-session data is never stale.

capture() runs synchronously while the ReaderUI is alive and returns plain
tables only; run() uploads afterwards and never touches reader objects, so the
close path can finish after the document is gone. Watermarks share the exact
ack-gated semantics of the full sweep, and both write the same state file
under mutual exclusion, so sweep and snapshot never double-upload.
]]

local Toast = setmetatable({}, { __index = function(_t, key) return require("bookorbit_toast")[key] end })
local Trapper = require("ui/trapper")
local UIManager = require("ui/uimanager")
local logger = require("logger")
local lfs = require("libs/libkoreader-lfs")
local ffiutil = require("ffi/util")
local T = ffiutil.template
local _ = require("gettext")

local BookOrbitAnnotations = require("bookorbit_annotations")
local BookOrbitApi = require("bookorbit_api")
local BookOrbitBookmarks = require("bookorbit_bookmarks")
local BookOrbitHighlightSummary = require("bookorbit_highlight_summary")
local BookOrbitSidecar = require("bookorbit_sidecar")
local BookOrbitState = require("bookorbit_state")
local BookOrbitStateManager = require("bookorbit_state_manager")
local BookOrbitStatsReader = require("bookorbit_stats_reader")
local BookOrbitSweep = require("bookorbit_sweep")

local STEP_DELAY = 0.1
local STATS_BATCH = 500
local ANN_BATCH_TOTAL = 50

local BookOrbitBookSync = {
    running = false,
}

local function titleFromFile(file)
    if not file or file == "" then return nil end
    local name = file:gsub(".*/", "")
    if name == "" then return nil end
    local title = name:gsub("%.[^%.]+$", "")
    if title == "" then return name end
    return title
end

function BookOrbitBookSync.isRunning()
    return BookOrbitBookSync.running
end

local function elapsedMs(started)
    local ok, seconds, micros = pcall(ffiutil.gettime)
    if ok and type(seconds) == "number" then
        return (seconds * 1000 + (micros or 0) / 1000) - started
    end
    return os.clock() * 1000 - started
end

local function nowMs()
    return elapsedMs(0)
end

-- Synchronous snapshot of the open book from live memory. The statistics
-- flush writes pending page stats to statistics.sqlite3, which run() reads
-- later; the DB outlives the document.
function BookOrbitBookSync.capture(plugin)
    local ui = plugin.ui
    if not ui or not ui.document then return nil end

    local digest = plugin:getDocumentDigest()
    if not digest then return nil end

    local started_ms = nowMs()
    local live_annotations = ui.annotation and ui.annotation.annotations
    local annotations, ann_max, ann_signature = BookOrbitSidecar.normalizeAnnotations(live_annotations)
    local bookmarks, _bm_max, bm_signature = BookOrbitSidecar.normalizeBookmarks(live_annotations)
    local summary = BookOrbitSidecar.normalizeSummary(ui.doc_settings:readSetting("summary"))

    local stats = ui.statistics
    if stats and stats.settings and stats.settings.is_enabled then
        pcall(stats.insertDB, stats)
    end

    local file = ui.document.file
    local ts = os.time()
    -- Identity is primed after reader ready, so the close and suspend handlers
    -- normally open no database at all. A cold cache still resolves here, and
    -- a cached miss (no statistics row yet) falls back at drain time.
    local metadata, cached = BookOrbitStatsReader.cachedIdentity(digest)
    if not cached then
        metadata = BookOrbitStatsReader.primeIdentity(digest)
    end
    metadata = metadata or {}
    local current_stats_id = stats and tonumber(stats.id_curr_book) or nil
    local stats_ambiguous = current_stats_id == nil and metadata.stats_ambiguous == true
    local current_stats_row
    for _, row in ipairs(metadata.rows or {}) do
        if row.id == current_stats_id then
            current_stats_row = row
            break
        end
    end
    local stats_ids = {}
    if current_stats_id then
        table.insert(stats_ids, current_stats_id)
    else
        for _, id in ipairs(metadata.ids or {}) do
            table.insert(stats_ids, id)
        end
    end
    logger.dbg(string.format("BookOrbit: lifecycle capture identity=%s elapsedMs=%.1f",
        cached and "cached" or "uncached", elapsedMs(started_ms)))
    return {
        digest = digest,
        file = file,
        title = stats_ambiguous and titleFromFile(file) or (metadata.title or titleFromFile(file)),
        authors = stats_ambiguous and nil or metadata.authors,
        last_open = metadata.last_open or ts,
        metadata_ambiguous = false,
        stats_metadata_ambiguous = stats_ambiguous,
        stats_row_ambiguous = current_stats_row ~= nil and metadata.stats_ambiguous == true,
        stats_row = current_stats_row,
        stats_identity_repaired = plugin.bookorbit_document_digest
            and plugin.bookorbit_document_digest.repaired == true or false,
        stats_ids = stats_ids,
        percentage = plugin:getLastPercent(),
        progress = plugin:getLastProgress(),
        status = summary.status,
        status_modified = summary.status_modified,
        rating = summary.rating,
        review_note = summary.review_note,
        review_modified = summary.status_modified,
        annotations = annotations,
        ann_count = #annotations,
        ann_max_datetime = ann_max,
        ann_signature = ann_signature,
        bookmarks = bookmarks,
        bm_signature = bm_signature,
        mtime_at_capture = file and BookOrbitSidecar.sidecarMtime(file) or nil,
        ts = ts,
    }
end

local function isAuthError(err)
    return err == 401 or err == 403
end

-- Numbers are HTTP statuses (server reachable, keep going); anything else is
-- a transport failure, where firing more doomed requests would stack timeouts.
local function isTransportError(err)
    return type(err) ~= "number"
end

local function isUnmatched(body, md5)
    for _, hash in ipairs(body.unmatched or {}) do
        if hash == md5 then return true end
    end
    return false
end

local function finish(ctx, err)
    BookOrbitBookSync.running = false

    -- The sidecar-clean marker is only safe on the close path: ReaderUI
    -- flushed the sidecar before our handler captured, so memory equals disk
    -- unless the book was reopened mid-upload (mtime check guards that).
    -- Lifecycle entries drain as "recovery", so the origin recorded when the
    -- snapshot was captured decides. Suspend stays excluded: ReaderUI flushes
    -- before CloseDocument, not before Suspend.
    local close_origin = ctx.reason == "close" or ctx.origin == "close"
    if not err and not ctx.had_errors and close_origin
            and ctx.snap.file and ctx.snap.mtime_at_capture then
        local book = ctx.state:getBook(ctx.snap.digest)
        if book and BookOrbitSidecar.sidecarMtime(ctx.snap.file) == ctx.snap.mtime_at_capture then
            book.sidecarMtime = ctx.snap.mtime_at_capture
        end
    end

    ctx.state:flush()

    if ctx.plugin and ctx.plugin.recordSyncError then
        if err == "auth" or err == "network" then
            ctx.plugin:recordSyncError("book_sync", err)
        elseif err == "outbox" then
            ctx.plugin:recordSyncError("book_sync", "outbox_persistence")
        elseif not err and ctx.had_errors then
            ctx.plugin:recordSyncError("book_sync", "partial_failure")
        elseif not err and ctx.plugin.recordSyncSuccess then
            ctx.plugin:recordSyncSuccess(
                "book_sync",
                T(_("%1 reading events, %2 highlights uploaded"),
                    ctx.counts.page_stats, ctx.counts.annotations))
        end
    end

    if ctx.plugin and ctx.plugin.recordHighlightSync and ctx.highlight_summary
            and BookOrbitHighlightSummary.hasCounts(ctx.highlight_summary) then
        local highlight_err = ctx.highlight_unsupported and "unsupported_server" or nil
        if ctx.had_errors and not highlight_err then
            highlight_err = "partial_failure"
        end
        ctx.plugin:recordHighlightSync(ctx.highlight_summary, highlight_err)
    end

    if err == "auth" then
        if not ctx.quiet then
            Toast.error(_("BookOrbit sign-in failed. Check your account"))
        end
    elseif err == "network" then
        if ctx.interactive and not ctx.quiet then
            Toast.error(_("BookOrbit server not reachable"))
        end
        logger.dbg("BookOrbit: book sync aborted, server not reachable")
    elseif err == "unmatched" then
        if ctx.interactive and not ctx.quiet then
            Toast.show(_("This book is not in your BookOrbit library"), { icon = "info" })
        end
        logger.dbg("BookOrbit: book sync skipped, book unmatched:", ctx.snap.digest)
    elseif err == "outbox" then
        logger.err("BookOrbit: lifecycle sync stopped because its durable state could not be updated")
    else
        if ctx.interactive and not ctx.quiet then
            local text = T(_("%1 reading events."), ctx.counts.page_stats)
            if ctx.highlight_summary and BookOrbitHighlightSummary.hasCounts(ctx.highlight_summary) then
                text = text .. "\n" .. BookOrbitHighlightSummary.message(ctx.highlight_summary)
            else
                text = text .. "\n" .. T(_("Highlights synced: %1 uploaded, %2 applied, %3 deleted."),
                    ctx.counts.annotations, ctx.counts.ann_applied or 0, ctx.counts.ann_deleted or 0)
            end
            if ctx.highlight_unsupported then
                text = text .. "\n" .. _("BookOrbit server needs an update for two-way highlights.")
            end
            if ctx.highlight_disabled_reported then
                text = text .. "\n" .. _("Two-way highlight and bookmark sync is disabled.")
            end
            if ctx.skip_progress then
                text = text .. "\n" .. _("Progress was not changed.")
            end
            if ctx.had_errors then
                text = text .. "\n" .. _("Some uploads failed and will retry on the next sync.")
            end
            require("bookorbit_dialog_ui").notice{ title = _("Book synced"), text = text }
        end
        logger.info(string.format(
            "BookOrbit: book sync done reason=%s pageStats=%d annotations=%d applied=%d deleted=%d errors=%s",
            ctx.reason, ctx.counts.page_stats, ctx.counts.annotations,
            ctx.counts.ann_applied or 0, ctx.counts.ann_deleted or 0, tostring(ctx.had_errors)))
    end

    if ctx.on_finish then
        pcall(ctx.on_finish, err)
    end
end

-- Local sync state must hit disk before the phase is acknowledged, otherwise a
-- crash would skip a phase whose watermarks were never saved. Phases that only
-- short-circuit pass state_changed = false and skip the (expensive) flush.
local function acknowledge(ctx, phase, state_changed)
    if not ctx.on_phase_ack then return true end
    if state_changed ~= false then ctx.state:flush() end
    local ok, result = pcall(ctx.on_phase_ack, phase)
    if not ok or result == false then
        ctx.had_errors = true
        logger.err("BookOrbit: could not persist lifecycle phase acknowledgement:", phase, result)
        finish(ctx, "outbox")
        return false
    end
    return true
end

local function step(ctx, fn)
    UIManager:scheduleIn(STEP_DELAY, function()
        Trapper:wrap(function()
            local ok, err = pcall(fn, ctx)
            if not ok then
                logger.err("BookOrbit: book sync step failed:", err)
                ctx.had_errors = true
                finish(ctx)
            end
        end)
    end)
end

local stepMatch, stepStats, stepAnnotations, stepAnnotationsLegacy, stepBookmarks, stepState, stepProgress

local function bindStatsRow(ctx, book)
    local row = ctx.snap.stats_row
    if not row or not ctx.snap.stats_row_ambiguous then return nil end
    return ctx.state:bindStatsRow(
        row.id, ctx.snap.digest, row.title, row.authors, book.fileId, book.bookId)
end

local function matchedFileCandidate(ctx, book)
    if not ctx.snap.stats_row_ambiguous then return book and book.fileId or nil end
    local row = ctx.snap.stats_row
    if not row then return nil end
    local target = ctx.state:getStatsRow(row.id, ctx.snap.digest, row.title, row.authors)
    return target and target.bookFileId or nil
end

stepMatch = function(ctx)
    if ctx.acknowledged.match then
        return step(ctx, stepStats)
    end
    local book = ctx.state:getBook(ctx.snap.digest)
    if book and ctx.snap.file and not book.file then
        book.file = ctx.snap.file
    end

    if not ctx.force_match
            and not ctx.snap.stats_row_ambiguous
            and BookOrbitState.isMatchFresh(book, ctx.state.global) then
        bindStatsRow(ctx, book)
        logger.dbg("BookOrbit: book sync match skipped, verified match still fresh")
        if not acknowledge(ctx, "match", false) then return end
        return step(ctx, stepStats)
    end

    local body, err = ctx.client:matchCheck({ ctx.snap.digest }, {
        [ctx.snap.digest] = {
            title = ctx.snap.title,
            authors = ctx.snap.authors,
            last_open = ctx.snap.last_open,
            source = "current_file",
            book_file_id = matchedFileCandidate(ctx, book),
            metadata_ambiguous = ctx.snap.metadata_ambiguous,
        },
    })
    if not body then
        if isAuthError(err) then return finish(ctx, "auth") end
        return finish(ctx, "network")
    end

    -- The token is applied before the match is stamped, so this book is
    -- verified against the version the response carried rather than the one it
    -- just invalidated.
    BookOrbitState.applyLibraryVersion(ctx.state, body.libraryVersion)

    for _, match in ipairs(body.matches or {}) do
        if match.hash == ctx.snap.digest then
            ctx.state:setMatched(match.hash, match.bookFileId, match.bookId, ctx.snap.file, nil, match.positionCompatible)
            bindStatsRow(ctx, ctx.state:getBook(ctx.snap.digest))
            if not acknowledge(ctx, "match") then return end
            return step(ctx, stepStats)
        end
    end

    ctx.state:setUnmatched(ctx.snap.digest)
    return finish(ctx, "unmatched")
end

stepStats = function(ctx)
    if ctx.acknowledged.stats then
        return step(ctx, stepAnnotations)
    end
    local book = ctx.state:getBook(ctx.snap.digest)
    if not book then return finish(ctx, "unmatched") end

    if ctx.snap.stats_metadata_ambiguous then
        if not acknowledge(ctx, "stats", false) then return end
        return step(ctx, stepAnnotations)
    end

    -- Snapshot ids are captured at close time; fall back to a live lookup when
    -- the stats row did not exist yet, so a deferred replay still uploads.
    if ctx.stat_ids == nil then
        local ids = ctx.snap.stats_ids
        if not ids or #ids == 0 then
            ids = BookOrbitStatsReader.getBookIds(ctx.snap.digest)
        end
        ctx.stat_ids = ids
    end
    if #ctx.stat_ids == 0 then
        if not acknowledge(ctx, "stats", false) then return end
        return step(ctx, stepAnnotations)
    end

    local stats_target = bindStatsRow(ctx, book) or book
    if ctx.stats_watermark == nil then
        ctx.stats_watermark = ctx.snap.stats_identity_repaired and 0 or (stats_target.statsWatermark or 0)
    end
    local watermark = ctx.stats_watermark
    local events = BookOrbitStatsReader.getEventsAfter(ctx.stat_ids, watermark, STATS_BATCH)
    if not events or #events == 0 then
        if not acknowledge(ctx, "stats", false) then return end
        return step(ctx, stepAnnotations)
    end

    local upload = { hash = ctx.snap.digest, events = events }
    if ctx.snap.stats_row_ambiguous then upload.bookFileId = stats_target.bookFileId end
    local body, err = ctx.client:uploadPageStats({ upload })
    if not body then
        if isAuthError(err) then return finish(ctx, "auth") end
        ctx.had_errors = true
        if isTransportError(err) then return finish(ctx, "network") end
        logger.dbg("BookOrbit: book sync page stats upload failed:", err)
        return step(ctx, stepAnnotations)
    end
    if isUnmatched(body, ctx.snap.digest) then
        ctx.state:setUnmatched(ctx.snap.digest)
        return finish(ctx, "unmatched")
    end

    local more = BookOrbitState.applyStatsAck(stats_target, events, body, ctx.snap.digest, STATS_BATCH, watermark)
    ctx.stats_watermark = stats_target.statsWatermark or watermark
    ctx.counts.page_stats = ctx.counts.page_stats + #events
    if more then
        return step(ctx, stepStats)
    end
    if not acknowledge(ctx, "stats") then return end
    return step(ctx, stepAnnotations)
end

-- Two-way exchange: uploads the local delta, reports the full key set for
-- deletion detection, applies server-side changes (live for the manual sync
-- while the book is open, sidecar on the close path) and acks them.
local function currentAnnotationKeys(ctx, kind)
    local ui = ctx.plugin and ctx.plugin.ui
    if ui and ui.document and ui.document.file == ctx.snap.file and ui.annotation then
        if kind == "bookmarks" then return BookOrbitSidecar.normalizeBookmarks(ui.annotation.annotations), true end
        return BookOrbitSidecar.normalizeAnnotations(ui.annotation.annotations), true
    end
    if ctx.snap.file and lfs.attributes(ctx.snap.file, "mode") == "file" then
        local extract = BookOrbitSidecar.extract(ctx.snap.file)
        if extract then return extract[kind] or {}, true end
    end
    -- Queued snapshots preserve uploads, but absence in an old snapshot cannot
    -- delete entries that a later sync has already delivered.
    return nil, false
end

-- Read once per sync: the marker is written only after both exchanges, and a
-- dogear exchange that saw it early would read an empty new sidecar as deletions.
local function sidecarState(ctx)
    if not ctx.sidecar_checked then
        ctx.sidecar_checked = true
        local ui = ctx.plugin and ctx.plugin.ui
        if ui and ui.document and ui.document.file == ctx.snap.file and ui.doc_settings then
            ctx.sidecar_marked = BookOrbitSidecar.isMarked(ui.doc_settings)
            ctx.sidecar_entries = #(ui.annotation and ui.annotation.annotations or {})
        elseif ctx.snap.file and lfs.attributes(ctx.snap.file, "mode") == "file" then
            local extract = BookOrbitSidecar.extract(ctx.snap.file)
            if extract then
                ctx.sidecar_marked = extract.marked
                ctx.sidecar_entries = (extract.annotations_count or 0) + (extract.bookmarks_count or 0)
            else
                ctx.sidecar_marked, ctx.sidecar_entries = false, 0
            end
        end
    end
    return BookOrbitSidecar.sidecarState(ctx.sidecar_marked, ctx.sidecar_entries)
end

-- Marks the sidecar once both exchanges went through, so later syncs report it
-- as known and a reader's real deletions count again.
local function markSidecar(ctx)
    if not (ctx.sidecar_ann_done and ctx.sidecar_bm_done) or ctx.sidecar_marked ~= false then return end
    local ui = ctx.plugin and ctx.plugin.ui
    if ui and ui.document and ui.document.file == ctx.snap.file and ui.doc_settings then
        BookOrbitSidecar.markLive(ui.doc_settings, ctx.snap.ts)
    else
        BookOrbitSidecar.markFile(ctx.snap.file, ctx.snap.ts)
    end
    ctx.sidecar_marked = true
end

stepAnnotations = function(ctx)
    if ctx.acknowledged.annotations then
        return step(ctx, stepBookmarks)
    end
    local book = ctx.state:getBook(ctx.snap.digest)
    if not book then return finish(ctx, "unmatched") end

    local apply_mode = "skip"
    if ctx.annotation_sync then
        if ctx.plugin and ctx.plugin.ui and ctx.plugin.ui.document
                and ctx.plugin.ui.document.file == ctx.snap.file then
            apply_mode = "live"
        elseif (ctx.reason == "close" or ctx.reason == "suspend" or ctx.reason == "recovery")
                and ctx.snap.file and lfs.attributes(ctx.snap.file, "mode") == "file" then
            apply_mode = "sidecar"
        end
    elseif ctx.interactive then
        ctx.highlight_disabled_reported = true
        ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, nil, { skipped = 1 })
    end

    -- Skipping the exchange also skips the complete-key deletion check and the
    -- only channel that delivers web-created highlights, so it needs an
    -- unchanged local set, nothing pending, a recent successful exchange, and
    -- a user who did not ask for this sync explicitly.
    local pending = ctx.remote_pending and ctx.remote_pending.annotations
    if not pending and not ctx.interactive
            and BookOrbitAnnotations.canSkipExchange(book, ctx.snap.ann_signature) then
        logger.dbg("BookOrbit: book sync annotation exchange skipped, local set unchanged")
        if not acknowledge(ctx, "annotations", false) then return end
        return step(ctx, stepBookmarks)
    end

    if pending and apply_mode ~= "skip" then
        local applied, deleted
        if apply_mode == "live" then
            applied, deleted = BookOrbitAnnotations.applyLive(ctx.plugin.ui, pending.to_apply or {})
        else
            applied, deleted = BookOrbitAnnotations.applySidecar(ctx.snap.file, pending.to_apply or {})
        end
        local ack_body, ack_err = ctx.client:exchangeAck({
            { hash = ctx.snap.digest, applied = applied, deleted = deleted },
        })
        if not ack_body then
            if isAuthError(ack_err) then return finish(ctx, "auth") end
            return finish(ctx, "network")
        end
        if ctx.on_remote_applied then
            local ok, cleared = pcall(ctx.on_remote_applied, "annotations")
            if not ok or cleared == false then return finish(ctx, "outbox") end
        end
        ctx.remote_pending.annotations = nil
    end

    local key_annotations, keys_complete = currentAnnotationKeys(ctx, "annotations")
    local result, err = BookOrbitAnnotations.exchangeBook({
        client = ctx.client,
        state = ctx.state,
        digest = ctx.snap.digest,
        annotations = ctx.snap.annotations,
        key_annotations = key_annotations,
        keys_complete = keys_complete,
        ann_max_datetime = ctx.snap.ann_max_datetime,
        ann_signature = ctx.snap.ann_signature,
        captured_at = ctx.snap.ts,
        sidecar_state = sidecarState(ctx),
        apply_mode = apply_mode,
        ui = apply_mode == "live" and ctx.plugin.ui or nil,
        file = ctx.snap.file,
    })
    if not result then
        if err == "auth" then return finish(ctx, "auth") end
        if err == "network" then
            ctx.had_errors = true
            return finish(ctx, "network")
        end
        if err == "unmatched" then return finish(ctx, "unmatched") end
        if err == "unsupported_server" then
            ctx.highlight_unsupported = true
            ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, nil, { skipped = 1 })
            return step(ctx, stepAnnotationsLegacy)
        end
        ctx.had_errors = true
        ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { had_errors = true })
        return step(ctx, stepBookmarks)
    end

    if result.had_errors then ctx.had_errors = true end
    ctx.counts.annotations = ctx.counts.annotations + result.uploaded
    ctx.counts.ann_applied = (ctx.counts.ann_applied or 0) + result.applied
    ctx.counts.ann_deleted = (ctx.counts.ann_deleted or 0) + result.deleted
    ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, result, {
        closed_book = apply_mode == "sidecar",
    })
    if not result.had_errors then
        -- An incompatible copy exchanged nothing, so its sidecar is not marked as known.
        if not result.incompatible then ctx.sidecar_ann_done = true end
        book.annCount = ctx.snap.ann_count
        -- Only park remote changes when the user actually wants them applied;
        -- with two-way highlights off there is nothing to come back for.
        if result.remote_pending and ctx.annotation_sync and ctx.on_remote_pending then
            local ok, recorded = pcall(ctx.on_remote_pending, "annotations", result.remote_pending)
            if not ok or recorded == false then
                ctx.had_errors = true
                return finish(ctx, "outbox")
            end
        elseif not acknowledge(ctx, "annotations") then
            return
        end
    end
    return step(ctx, stepBookmarks)
end

-- One-way upload for servers without the 0.4 exchange endpoint.
stepAnnotationsLegacy = function(ctx)
    local book = ctx.state:getBook(ctx.snap.digest)
    if not book then return finish(ctx, "unmatched") end

    if ctx.ann_delta == nil then
        ctx.ann_delta = {}
        ctx.ann_cursor = 1
        ctx.ann_failed = false
        local watermark = BookOrbitAnnotations.readWatermark(book, os.date("%Y-%m-%d %H:%M:%S"))
        for _, annotation in ipairs(ctx.snap.annotations) do
            local effective = annotation.datetimeUpdated or annotation.datetime
            if effective > watermark then
                table.insert(ctx.ann_delta, annotation)
            end
        end
    end

    if ctx.ann_cursor > #ctx.ann_delta then
        if not ctx.ann_failed then
            -- No exchange stamp here: the legacy endpoint never receives the
            -- key set, so a later sync must not skip the first real exchange.
            BookOrbitAnnotations.advanceWatermark(book, ctx.snap.ann_max_datetime, os.date("%Y-%m-%d %H:%M:%S"))
            book.annCount = ctx.snap.ann_count
            if not acknowledge(ctx, "annotations") then return end
        end
        return step(ctx, stepBookmarks)
    end

    -- Consumed through a head cursor: removing from the front of a Lua array
    -- shifts every remaining element, which turns a long delta into O(n^2).
    local chunk = {}
    while ctx.ann_cursor <= #ctx.ann_delta and #chunk < ANN_BATCH_TOTAL do
        table.insert(chunk, ctx.ann_delta[ctx.ann_cursor])
        ctx.ann_cursor = ctx.ann_cursor + 1
    end

    local body, err = ctx.client:uploadAnnotations({ { hash = ctx.snap.digest, annotations = chunk } })
    if not body then
        if isAuthError(err) then return finish(ctx, "auth") end
        ctx.had_errors = true
        ctx.ann_failed = true
        ctx.ann_cursor = #ctx.ann_delta + 1
        ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { had_errors = true })
        if isTransportError(err) then return finish(ctx, "network") end
        logger.dbg("BookOrbit: book sync annotations upload failed:", err)
        return step(ctx, stepBookmarks)
    end
    if isUnmatched(body, ctx.snap.digest) then
        ctx.state:setUnmatched(ctx.snap.digest)
        return finish(ctx, "unmatched")
    end

    ctx.counts.annotations = ctx.counts.annotations + #chunk
    ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { uploaded = #chunk })
    return step(ctx, stepAnnotationsLegacy)
end

-- Two-way dogear exchange. It shares the annotations phase acknowledgement, so
-- an outbox entry queued before bookmark sync existed still drains: only the
-- free-form remote_pending kind is new.
stepBookmarks = function(ctx)
    if not ctx.annotation_sync or not BookOrbitBookmarks.enabled(ctx.client, true) then
        ctx.sidecar_bm_done = true
        return step(ctx, stepState)
    end
    local book = ctx.state:getBook(ctx.snap.digest)
    if not book then return finish(ctx, "unmatched") end

    local apply_mode = "skip"
    if ctx.plugin and ctx.plugin.ui and ctx.plugin.ui.document
            and ctx.plugin.ui.document.file == ctx.snap.file then
        apply_mode = "live"
    elseif (ctx.reason == "close" or ctx.reason == "suspend" or ctx.reason == "recovery")
            and ctx.snap.file and lfs.attributes(ctx.snap.file, "mode") == "file" then
        apply_mode = "sidecar"
    end

    local pending = ctx.remote_pending and ctx.remote_pending.bookmarks
    if not pending and not ctx.interactive
            and BookOrbitBookmarks.canSkipExchange(book, ctx.snap.bm_signature) then
        logger.dbg("BookOrbit: book sync bookmark exchange skipped, local set unchanged")
        ctx.sidecar_bm_done = true
        return step(ctx, stepState)
    end

    if pending and apply_mode ~= "skip" then
        local applied, deleted
        if apply_mode == "live" then
            applied, deleted = BookOrbitBookmarks.applyLive(ctx.plugin.ui, pending.to_apply or {})
        else
            applied, deleted = BookOrbitBookmarks.applySidecar(ctx.snap.file, pending.to_apply or {})
        end
        local ack_body, ack_err = ctx.client:exchangeBookmarksAck({
            { hash = ctx.snap.digest, applied = applied, deleted = deleted },
        })
        if not ack_body then
            if isAuthError(ack_err) then return finish(ctx, "auth") end
            return finish(ctx, "network")
        end
        if ctx.on_remote_applied then
            local ok, cleared = pcall(ctx.on_remote_applied, "bookmarks")
            if not ok or cleared == false then return finish(ctx, "outbox") end
        end
        ctx.remote_pending.bookmarks = nil
    end

    local key_bookmarks, keys_complete = currentAnnotationKeys(ctx, "bookmarks")
    local result, err = BookOrbitBookmarks.exchangeBook({
        client = ctx.client,
        state = ctx.state,
        digest = ctx.snap.digest,
        bookmarks = ctx.snap.bookmarks,
        key_bookmarks = key_bookmarks,
        keys_complete = keys_complete,
        bm_signature = ctx.snap.bm_signature,
        sidecar_state = sidecarState(ctx),
        apply_mode = apply_mode,
        ui = apply_mode == "live" and ctx.plugin.ui or nil,
        file = ctx.snap.file,
    })
    if not result then
        if err == "auth" then return finish(ctx, "auth") end
        if err == "network" then
            ctx.had_errors = true
            return finish(ctx, "network")
        end
        if err == "unmatched" then return finish(ctx, "unmatched") end
        if err == "unsupported_server" then
            BookOrbitBookmarks.markUnsupported(ctx.client)
            ctx.sidecar_bm_done = true
            return step(ctx, stepState)
        end
        ctx.had_errors = true
        ctx.highlight_summary = BookOrbitHighlightSummary.addBookmarks(ctx.highlight_summary, { had_errors = true })
        return step(ctx, stepState)
    end

    if result.had_errors then
        ctx.had_errors = true
    elseif not result.incompatible then
        ctx.sidecar_bm_done = true
    end
    ctx.counts.bookmarks = (ctx.counts.bookmarks or 0) + result.uploaded
    ctx.highlight_summary = BookOrbitHighlightSummary.addBookmarks(ctx.highlight_summary, result)
    if not result.had_errors and result.remote_pending and ctx.on_remote_pending then
        local ok, recorded = pcall(ctx.on_remote_pending, "bookmarks", result.remote_pending)
        if not ok or recorded == false then
            ctx.had_errors = true
            return finish(ctx, "outbox")
        end
    end
    return step(ctx, stepState)
end

stepState = function(ctx)
    markSidecar(ctx)
    if ctx.acknowledged.state then
        return step(ctx, stepProgress)
    end
    local book = ctx.state:getBook(ctx.snap.digest)
    if not book then return finish(ctx, "unmatched") end

    local pending_state = ctx.remote_pending and ctx.remote_pending.state
    if pending_state then
        if not ctx.snap.file or lfs.attributes(ctx.snap.file, "mode") ~= "file" then
            return step(ctx, stepProgress)
        end
        local ui = ctx.plugin and ctx.plugin.ui
        if ui and ui.document and ui.document.file == ctx.snap.file then
            BookOrbitSidecar.applyServerStateLive(ui, pending_state)
        else
            BookOrbitSidecar.applyServerStateSidecar(ctx.snap.file, pending_state)
        end
        BookOrbitSidecar.rememberServerState(book, pending_state)
        if ctx.on_remote_applied then
            local ok, cleared = pcall(ctx.on_remote_applied, "state")
            if not ok or cleared == false then return finish(ctx, "outbox") end
        end
        ctx.remote_pending.state = nil
        if not acknowledge(ctx, "state") then return end
        return step(ctx, stepProgress)
    end

    -- A forced pull is a request even when nothing changed locally, so it is
    -- reserved for a sync the user asked for and for the bounded refresh that
    -- keeps web-side ratings and reviews arriving.
    local force_pull = ctx.interactive or ctx.reason == "manual"
        or BookOrbitSidecar.needsStatePull(book)
    local payload = BookOrbitSidecar.buildStatePayload(ctx.snap.digest, book, {
        status = ctx.snap.status,
        status_modified = ctx.snap.status_modified,
        rating = ctx.snap.rating,
        review_note = ctx.snap.review_note,
    }, force_pull)
    if not payload then
        if not acknowledge(ctx, "state", false) then return end
        return step(ctx, stepProgress)
    end

    local body, err = ctx.client:uploadBookStates({ payload })
    if not body then
        if isAuthError(err) then return finish(ctx, "auth") end
        ctx.had_errors = true
        if isTransportError(err) then return finish(ctx, "network") end
        logger.dbg("BookOrbit: book sync state upload failed:", err)
        return step(ctx, stepProgress)
    end
    if isUnmatched(body, ctx.snap.digest) then
        ctx.state:setUnmatched(ctx.snap.digest)
        return finish(ctx, "unmatched")
    end

    -- A server-kept tie still counts as synced: the device value was considered.
    book.statusSyncedModified = payload.statusModified or book.statusSyncedModified
    if force_pull then
        BookOrbitSidecar.markStatePulled(book)
    end
    local result = body.results and body.results[1] or nil
    local server_state = BookOrbitSidecar.stateFromServerResult(result)
    if server_state then
        if not server_state.status_known then
            BookOrbitSidecar.rememberUploadedState(book, {}, { status = payload.status, statusModified = payload.statusModified })
        end
        if ctx.reason == "close" or ctx.reason == "suspend" or ctx.reason == "recovery" then
            if not ctx.snap.file or lfs.attributes(ctx.snap.file, "mode") ~= "file" then
                if ctx.on_remote_pending then
                    local ok, recorded = pcall(ctx.on_remote_pending, "state", server_state)
                    if not ok or recorded == false then
                        ctx.had_errors = true
                        return finish(ctx, "outbox")
                    end
                    return step(ctx, stepProgress)
                end
            else
                local ui = ctx.plugin and ctx.plugin.ui
                if ui and ui.document and ui.document.file == ctx.snap.file then
                    BookOrbitSidecar.applyServerStateLive(ui, server_state)
                else
                    BookOrbitSidecar.applyServerStateSidecar(ctx.snap.file, server_state)
                end
            end
        else
            BookOrbitSidecar.applyServerStateLive(ctx.plugin and ctx.plugin.ui or nil, server_state)
        end
        BookOrbitSidecar.rememberServerState(book, server_state)
    else
        BookOrbitSidecar.rememberUploadedState(book, {
            rating = ctx.snap.rating,
            review_note = ctx.snap.review_note,
        }, payload)
    end
    if not acknowledge(ctx, "state") then return end
    return step(ctx, stepProgress)
end

stepProgress = function(ctx)
    if ctx.acknowledged.progress then
        return finish(ctx)
    end
    local book = ctx.state:getBook(ctx.snap.digest)
    if not book then return finish(ctx, "unmatched") end

    if ctx.skip_progress then
        if not acknowledge(ctx, "progress", false) then return end
        return finish(ctx)
    end

    local pct = ctx.snap.percentage
    if type(pct) ~= "number" then
        if not acknowledge(ctx, "progress", false) then return end
        return finish(ctx)
    end
    -- The live PUT carries device/device_id/timestamp, which is what the
    -- pull-conflict logic on other devices consumes; the bulk endpoint is the
    -- sweep's many-books channel with sidecar freshness semantics.
    local body, err = ctx.client:updateProgress(ctx.snap.digest, pct, ctx.snap.progress, ctx.snap.ts)
    if ctx.plugin then
        ctx.plugin.push_timestamp = UIManager:getElapsedTimeSinceBoot()
    end
    if not body then
        if isAuthError(err) then return finish(ctx, "auth") end
        logger.dbg("BookOrbit: book sync progress push failed:", err)
        ctx.had_errors = true
        return finish(ctx)
    end

    book.progressPushedPct = pct
    if not acknowledge(ctx, "progress") then return end
    return finish(ctx)
end

function BookOrbitBookSync.run(opts)
    if BookOrbitBookSync.running or BookOrbitSweep.isRunning() then
        if opts.interactive then
            Toast.show(_("BookOrbit sync is already running"), { icon = "hourglass" })
        end
        return false
    end

    local snap = opts.snap
    if not snap then return false end

    local client = BookOrbitApi.new(opts.api)
    if not client:isConfigured() then
        if opts.interactive then
            Toast.error(_("Sign in to BookOrbit first"))
        end
        return false
    end

    BookOrbitBookSync.running = true

    local ctx = {
        client = client,
        state = BookOrbitStateManager.session({
            digests = { snap.digest },
            files = { snap.file },
            statsRows = snap.stats_ids,
        }),
        snap = snap,
        reason = opts.reason or "manual",
        origin = opts.origin,
        force_match = opts.force_match == true,
        interactive = opts.interactive or false,
        quiet = opts.quiet == true,
        annotation_sync = opts.annotation_sync ~= false,
        plugin = opts.plugin,
        on_finish = opts.on_finish,
        counts = { page_stats = 0, annotations = 0, ann_applied = 0, ann_deleted = 0 },
        highlight_summary = BookOrbitHighlightSummary.normalize{
            event = "book_sync",
            reason = opts.reason or "manual",
        },
        had_errors = false,
        skip_progress = opts.skip_progress == true,
        on_phase_ack = opts.on_phase_ack,
        on_remote_pending = opts.on_remote_pending,
        on_remote_applied = opts.on_remote_applied,
        acknowledged = opts.acknowledged or {},
        remote_pending = opts.remote_pending or {},
    }

    ctx.state:rememberFile(snap.file, snap.digest)
    logger.dbg("BookOrbit: book sync started, reason:", ctx.reason)

    step(ctx, stepMatch)
    return true
end

return BookOrbitBookSync