--[[-- 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