feat(reader): serialize and coordinate sync jobs in KOReader plugin

Introduce a sync coordinator to serialize and prioritize KOReader sync jobs (such as progress sync, highlight sync, library sweep, and update check). Coalesce pending jobs by family to prevent race conditions and redundant network requests. Use a dedicated scheduler for open annotations to handle highlight sync and matching asynchronously on book open. Add a diagnostics menu in the settings to track detailed sync status and provide clear error messages for debugging.
This commit is contained in:
neon
2026-07-08 17:55:40 +00:00
parent 512f16e288
commit 36bfc6e83d
26 changed files with 3140 additions and 473 deletions
@@ -76,14 +76,58 @@ local function normalizeText(text)
return util.trim(text:gsub("%s+", " "))
end
local function findByDatetime(annotations, datetime)
for index, a in ipairs(annotations or {}) do
if a.datetime == datetime then
return index, a
local ackApplied
local applyEditFields
local function normalizePos(pos)
if type(pos) ~= "string" then return "" end
return util.trim(pos)
end
local function sameRangeAndText(annotation, entry)
local text = normalizeText(entry.text)
if text == "" or normalizePos(entry.pos0) == "" then return false end
if normalizeText(annotation.text) ~= text then return false end
if normalizePos(annotation.pos0) ~= normalizePos(entry.pos0) then return false end
local entry_pos1 = normalizePos(entry.pos1)
if entry_pos1 ~= "" and normalizePos(annotation.pos1) ~= entry_pos1 then return false end
return true
end
local function findByIdentity(annotations, entry)
local found_index, found_annotation, found_count = nil, nil, 0
if type(entry.datetime) == "string" and entry.datetime ~= "" then
for index, annotation in ipairs(annotations or {}) do
if annotation.datetime == entry.datetime then
found_index, found_annotation = index, annotation
found_count = found_count + 1
end
end
if found_count == 1 then return found_index, found_annotation end
end
for index, annotation in ipairs(annotations or {}) do
if sameRangeAndText(annotation, entry) then
return index, annotation
end
end
end
local function applyIdentityFields(annotation, entry)
if type(entry.datetime) == "string" and entry.datetime ~= "" then
annotation.datetime = entry.datetime
end
applyEditFields(annotation, entry)
end
local function ackExisting(entry, annotation, verified)
return ackApplied(entry, {
verified = verified == true,
pos0 = annotation.pos0 or entry.pos0,
pos1 = annotation.pos1 or entry.pos1,
pageno = type(annotation.pageno) == "number" and annotation.pageno or entry.pageno,
})
end
-- crengine verification: both endpoints must resolve and, when the server
-- knows the highlighted text, the extracted range must match it.
local function verifyRange(document, pos0, pos1, text)
@@ -133,7 +177,7 @@ local function repairRange(document, entry)
return nil
end
local function ackApplied(entry, opts)
ackApplied = function(entry, opts)
return {
serverId = entry.serverId,
version = entry.version,
@@ -147,7 +191,7 @@ local function ackApplied(entry, opts)
}
end
local function applyEditFields(annotation, entry)
applyEditFields = function(annotation, entry)
annotation.drawer = entry.drawer or annotation.drawer
annotation.color = entry.color or annotation.color
if entry.text and entry.text ~= "" then annotation.text = entry.text end
@@ -163,12 +207,15 @@ function BookOrbitAnnotations.applyLive(ui, to_apply)
local document = ui.document
local annotations = ui.annotation.annotations
local touched = 0
local deleted_touched = 0
for _, entry in ipairs(to_apply.add or {}) do
local existing_index = findByDatetime(annotations, entry.datetime)
local existing_index, existing_annotation = findByIdentity(annotations, entry)
if existing_index then
-- Idempotent retry of an earlier apply that lost its ack.
table.insert(applied, ackApplied(entry, { verified = true, pos0 = entry.pos0, pos1 = entry.pos1 }))
applyIdentityFields(existing_annotation, entry)
ui:handleEvent(Event:new("AnnotationsModified", { existing_annotation, index_modified = existing_index }))
touched = touched + 1
table.insert(applied, ackExisting(entry, existing_annotation, true))
elseif entry.posFormat ~= "xpointer" or not ui.rolling then
-- PDF adds from the web are out of scope; never expected here.
table.insert(applied, ackApplied(entry, { failed = true }))
@@ -200,8 +247,15 @@ function BookOrbitAnnotations.applyLive(ui, to_apply)
pos0 = pos0,
pos1 = pos1,
}
ui.annotation:addItem(item)
ui:handleEvent(Event:new("AnnotationsModified", { item, nb_highlights_added = 1 }))
local index = ui.annotation:addItem(item)
if ui.view and ui.view.footer and ui.view.footer.maybeUpdateFooter then
pcall(ui.view.footer.maybeUpdateFooter, ui.view.footer)
end
ui:handleEvent(Event:new("AnnotationsModified", {
item,
nb_highlights_added = 1,
index_modified = index,
}))
touched = touched + 1
table.insert(applied, ackApplied(entry, {
verified = true,
@@ -215,7 +269,7 @@ function BookOrbitAnnotations.applyLive(ui, to_apply)
end
for _, entry in ipairs(to_apply.edit or {}) do
local index, annotation = findByDatetime(annotations, entry.datetime)
local index, annotation = findByIdentity(annotations, entry)
if not index then
logger.dbg("BookOrbit: annotation edit target missing:", entry.serverId)
table.insert(applied, ackApplied(entry, { failed = true }))
@@ -228,13 +282,14 @@ function BookOrbitAnnotations.applyLive(ui, to_apply)
end
for _, entry in ipairs(to_apply.delete or {}) do
local index, annotation = findByDatetime(annotations, entry.datetime)
local index, annotation = findByIdentity(annotations, entry)
if index then
if ui.paging then
pcall(ui.highlight.writePdfAnnotation, ui.highlight, "delete", annotation)
end
ui.bookmark:removeItemByIndex(index)
touched = touched + 1
deleted_touched = deleted_touched + 1
end
-- Missing entries were already deleted locally; ack either way.
table.insert(deleted, { serverId = entry.serverId, status = "applied" })
@@ -243,7 +298,7 @@ function BookOrbitAnnotations.applyLive(ui, to_apply)
if touched > 0 then
UIManager:setDirty("all", "ui")
end
return applied, deleted, touched
return applied, deleted, touched, deleted_touched
end
-- Merges server changes into the sidecar of a CLOSED book and raises
@@ -253,13 +308,17 @@ function BookOrbitAnnotations.applySidecar(file, to_apply)
local doc_settings = DocSettings:open(file)
local annotations = doc_settings:readSetting("annotations") or {}
local touched = 0
local deleted_touched = 0
for _, entry in ipairs(to_apply.add or {}) do
if entry.posFormat ~= "xpointer" then
table.insert(applied, ackApplied(entry, { failed = true }))
else
local existing = findByDatetime(annotations, entry.datetime)
if not existing then
local existing_index, existing = findByIdentity(annotations, entry)
if existing_index then
applyIdentityFields(existing, entry)
touched = touched + 1
else
table.insert(annotations, {
datetime = entry.datetime,
datetime_updated = entry.datetimeUpdated,
@@ -276,12 +335,16 @@ function BookOrbitAnnotations.applySidecar(file, to_apply)
touched = touched + 1
end
-- Unverified until the book is opened; the server keeps it pending.
table.insert(applied, ackApplied(entry, { pos0 = entry.pos0, pos1 = entry.pos1, pageno = entry.pageno }))
table.insert(applied, ackExisting(entry, existing or {
pos0 = entry.pos0,
pos1 = entry.pos1,
pageno = entry.pageno,
}, false))
end
end
for _, entry in ipairs(to_apply.edit or {}) do
local index, annotation = findByDatetime(annotations, entry.datetime)
local index, annotation = findByIdentity(annotations, entry)
if not index then
table.insert(applied, ackApplied(entry, { failed = true }))
else
@@ -292,10 +355,11 @@ function BookOrbitAnnotations.applySidecar(file, to_apply)
end
for _, entry in ipairs(to_apply.delete or {}) do
local index = findByDatetime(annotations, entry.datetime)
local index = findByIdentity(annotations, entry)
if index then
table.remove(annotations, index)
touched = touched + 1
deleted_touched = deleted_touched + 1
end
table.insert(deleted, { serverId = entry.serverId, status = "applied" })
end
@@ -305,7 +369,7 @@ function BookOrbitAnnotations.applySidecar(file, to_apply)
doc_settings:makeTrue("annotations_externally_modified")
doc_settings:flush()
end
return applied, deleted, touched
return applied, deleted, touched, deleted_touched
end
local function hasPending(to_apply)
@@ -396,16 +460,18 @@ function BookOrbitAnnotations.exchangeBook(opts)
local rounds = 0
while response and hasPending(response.toApply) and rounds < MAX_PULL_ROUNDS do
rounds = rounds + 1
local applied, deleted, touched
local applied, deleted, touched, deleted_touched
if opts.apply_mode == "live" and opts.ui and opts.ui.document then
applied, deleted, touched = BookOrbitAnnotations.applyLive(opts.ui, response.toApply)
applied, deleted, touched, deleted_touched = BookOrbitAnnotations.applyLive(opts.ui, response.toApply)
elseif opts.apply_mode == "sidecar" and opts.file then
applied, deleted, touched = BookOrbitAnnotations.applySidecar(opts.file, response.toApply)
applied, deleted, touched, deleted_touched = BookOrbitAnnotations.applySidecar(opts.file, response.toApply)
else
break
end
result.applied = result.applied + touched
deleted_touched = deleted_touched or 0
result.applied = result.applied + math.max(0, touched - deleted_touched)
result.deleted = result.deleted + deleted_touched
for _, entry in ipairs(applied) do
if entry.status == "failed" then result.failed = result.failed + 1 end
end
@@ -31,8 +31,22 @@ local function scrubNulls(value)
return value
end
local function decodeResponse(parts)
local raw = table.concat(parts or {})
if raw == "" then
return {}
end
local ok, decoded, decode_err = pcall(rapidjson.decode, raw)
if not ok or decoded == nil then
logger.dbg("BookOrbit: invalid JSON response:", ok and decode_err or decoded)
return nil, "invalid_json"
end
return scrubNulls(decoded) or {}
end
local BookOrbitApi = {}
BookOrbitApi.__index = BookOrbitApi
BookOrbitApi.decodeResponse = decodeResponse
-- Normalizes a user-entered server address to the API base, e.g.
-- "https://books.example.com/" -> "https://books.example.com/api/v1".
@@ -100,15 +114,16 @@ function BookOrbitApi:request(method, path, body)
return nil, tostring(status or code or "network_error")
end
local decoded = nil
if sink[1] then
decoded = scrubNulls(rapidjson.decode(table.concat(sink)))
end
local decoded, decode_err = decodeResponse(sink)
if code < 200 or code >= 300 then
return nil, code, decoded
end
if decode_err then
return nil, decode_err
end
return decoded or {}
end
@@ -19,6 +19,7 @@ local _ = require("gettext")
local BookOrbitAnnotations = require("bookorbit_annotations")
local BookOrbitApi = require("bookorbit_api")
local BookOrbitHighlightSummary = require("bookorbit_highlight_summary")
local BookOrbitSidecar = require("bookorbit_sidecar")
local BookOrbitState = require("bookorbit_state")
local BookOrbitStatsReader = require("bookorbit_stats_reader")
@@ -133,6 +134,28 @@ local function finish(ctx, err)
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 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 ctx.interactive then
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync: login failed. Check your credentials."), timeout = 4 })
@@ -151,8 +174,19 @@ local function finish(ctx, err)
logger.dbg("BookOrbit: book sync skipped, book unmatched:", ctx.snap.digest)
else
if ctx.interactive then
local text = T(_("BookOrbit: book synced. %1 reading events, %2 highlights up, %3 applied."),
ctx.counts.page_stats, ctx.counts.annotations, ctx.counts.ann_applied or 0)
local text = T(_("BookOrbit: book synced. %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 sync is disabled.")
end
if ctx.skip_progress then
text = text .. "\n" .. _("Progress was not changed.")
end
@@ -162,8 +196,9 @@ local function finish(ctx, err)
UIManager:show(InfoMessage:new{ text = text, timeout = 4 })
end
logger.info(string.format(
"BookOrbit: book sync done reason=%s pageStats=%d annotations=%d applied=%d errors=%s",
ctx.reason, ctx.counts.page_stats, ctx.counts.annotations, ctx.counts.ann_applied or 0, tostring(ctx.had_errors)))
"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
@@ -280,6 +315,9 @@ stepAnnotations = function(ctx)
elseif ctx.reason == "manual" and ctx.plugin and ctx.plugin.ui and ctx.plugin.ui.document then
apply_mode = "live"
end
elseif ctx.interactive then
ctx.highlight_disabled_reported = true
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, nil, { skipped = 1 })
end
local result, err = BookOrbitAnnotations.exchangeBook({
@@ -300,15 +338,22 @@ stepAnnotations = function(ctx)
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, stepState)
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
book.annCount = ctx.snap.ann_count
end
@@ -351,6 +396,7 @@ stepAnnotationsLegacy = function(ctx)
ctx.had_errors = true
ctx.ann_failed = true
ctx.ann_delta = {}
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, stepState)
@@ -361,6 +407,7 @@ stepAnnotationsLegacy = function(ctx)
end
ctx.counts.annotations = ctx.counts.annotations + #chunk
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { uploaded = #chunk })
return step(ctx, stepAnnotationsLegacy)
end
@@ -451,18 +498,18 @@ function BookOrbitBookSync.run(opts)
if opts.interactive then
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync is already running."), timeout = 2 })
end
return
return false
end
local snap = opts.snap
if not snap then return end
if not snap then return false end
local client = BookOrbitApi.new(opts.api)
if not client:isConfigured() then
if opts.interactive then
UIManager:show(InfoMessage:new{ text = _("Please configure the BookOrbit server and login first."), timeout = 3 })
end
return
return false
end
BookOrbitBookSync.running = true
@@ -477,7 +524,11 @@ function BookOrbitBookSync.run(opts)
annotation_sync = opts.annotation_sync ~= false,
plugin = opts.plugin,
on_finish = opts.on_finish,
counts = { page_stats = 0, annotations = 0, ann_applied = 0 },
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,
}
@@ -495,6 +546,7 @@ function BookOrbitBookSync.run(opts)
else
step(ctx, stepMatch)
end
return true
end
return BookOrbitBookSync
@@ -0,0 +1,67 @@
local T = require("ffi/util").template
local _ = require("gettext")
local HighlightDiagnostics = {}
local function count(value)
value = tonumber(value)
if not value or value < 0 then return 0 end
return value
end
local function hasOpenBook(ctx)
return ctx and ctx.has_open_book == true
end
function HighlightDiagnostics.lastSyncText(summary, format_time)
if type(summary) ~= "table" then return _("never") end
local text = T(_("%1 uploaded, %2 applied, %3 deleted, %4 failed, %5 skipped"),
count(summary.uploaded), count(summary.applied), count(summary.deleted),
count(summary.failed), count(summary.skipped))
local at = tonumber(summary.at)
if at and at > 0 then
local when = format_time and format_time(at) or os.date("%H:%M", at)
text = T(_("%1 at %2"), text, when)
end
return text
end
function HighlightDiagnostics.retryText(status)
if type(status) == "table" and status.pending then
return _("pending")
end
return _("none")
end
function HighlightDiagnostics.openHighlightsText(ctx)
ctx = ctx or {}
if not ctx.annotation_sync then
return _("two-way sync off")
end
if not hasOpenBook(ctx) then
return _("no reader book")
end
local pending = ctx.scheduler_status
local matched = ctx.matched == true
if type(pending) == "table" then
if matched then
return _("matched, sync scheduled")
end
if pending.phase == "matching" then
return _("unmatched, retrying match")
end
return _("unmatched, sync scheduled")
end
if matched then
return _("matched, idle")
end
local last = ctx.last_highlight_sync
if type(last) == "table" and count(last.skipped) > 0 then
return _("unmatched, skipped")
end
return _("unmatched, idle")
end
return HighlightDiagnostics
@@ -0,0 +1,106 @@
--[[--
Highlight sync summary helpers for BookOrbit.
Keeps the persisted diagnostic shape, count aggregation and concise user
messages consistent across open-book exchange, per-book sync and sweep.
]]
local T = require("ffi/util").template
local _ = require("gettext")
local HighlightSummary = {}
local COUNT_FIELDS = {
"uploaded",
"applied",
"deleted",
"failed",
"skipped",
"touched_books",
}
local ACTIONABLE_ERRORS = {
auth = true,
network = true,
unsupported_server = true,
partial_failure = true,
}
local function numberValue(value)
value = tonumber(value)
if not value or value < 0 then return 0 end
return value
end
function HighlightSummary.normalize(summary)
summary = summary or {}
local normalized = {
event = summary.event,
reason = summary.reason,
at = summary.at or os.time(),
message = summary.message,
}
for _, field in ipairs(COUNT_FIELDS) do
normalized[field] = numberValue(summary[field])
end
return normalized
end
function HighlightSummary.add(summary, result, opts)
summary = HighlightSummary.normalize(summary)
result = result or {}
opts = opts or {}
for _, field in ipairs(COUNT_FIELDS) do
summary[field] = summary[field] + numberValue(result[field])
end
if opts.skipped then
summary.skipped = summary.skipped + numberValue(opts.skipped)
end
if opts.closed_book and numberValue(result.applied) + numberValue(result.deleted) > 0 then
summary.touched_books = summary.touched_books + 1
end
if result.had_errors and numberValue(result.failed) == 0 then
summary.failed = summary.failed + 1
end
return summary
end
function HighlightSummary.hasCounts(summary)
summary = HighlightSummary.normalize(summary)
for _, field in ipairs(COUNT_FIELDS) do
if summary[field] > 0 then return true end
end
return false
end
function HighlightSummary.hasRemoteChanges(summary)
summary = HighlightSummary.normalize(summary)
return summary.applied + summary.deleted > 0
end
function HighlightSummary.actionableError(summary, err)
if ACTIONABLE_ERRORS[err] then return err end
summary = HighlightSummary.normalize(summary)
if summary.failed > 0 then return "partial_failure" end
end
function HighlightSummary.message(summary)
summary = HighlightSummary.normalize(summary)
local text = T(_("Highlights synced: %1 uploaded, %2 applied, %3 deleted."),
summary.uploaded, summary.applied, summary.deleted)
if summary.failed > 0 or summary.skipped > 0 then
text = text .. "\n" .. T(_("Failed: %1. Skipped: %2."), summary.failed, summary.skipped)
end
if summary.touched_books > 0 then
text = text .. "\n" .. T(_("%1 closed book(s) updated."), summary.touched_books)
end
return text
end
function HighlightSummary.diagnosticsText(summary)
if type(summary) ~= "table" then return _("none") end
return HighlightSummary.message(summary)
end
return HighlightSummary
@@ -1,9 +1,9 @@
--[[--
Main menu mixin for the BookOrbit plugin.
Builds the BookOrbit entry in KOReader's Tools menu (and the dashboard
hamburger menu that mirrors it), plus the account dialogs: server address,
login/logout. Installed onto the plugin controller as regular methods.
Builds the BookOrbit entry in KOReader's Tools menu and the dashboard menu that
mirrors it, plus the account dialogs: server address, login/logout. Installed
onto the plugin controller as regular methods.
]]
local Device = require("device")
@@ -19,7 +19,7 @@ local T = require("ffi/util").template
local _ = require("gettext")
local BookOrbitApi = require("bookorbit_api")
local BookOrbitBookSync = require("bookorbit_book_sync")
local BookOrbitHighlightDiagnostics = require("bookorbit_highlight_diagnostics")
local BookOrbitSweep = require("bookorbit_sweep")
-- Assigned from the plugin class on install so menu labels can name the
@@ -81,186 +81,407 @@ function MainMenu:catalogAutoOpenMenu()
}
end
local function formatBool(value)
return value and _("On") or _("Off")
end
local function formatTime(ts)
ts = tonumber(ts)
if not ts or ts <= 0 then return _("never") end
return os.date("%Y-%m-%d %H:%M", ts)
end
local function formatShortTime(ts)
ts = tonumber(ts)
if not ts or ts <= 0 then return _("never") end
return os.date("%H:%M", ts)
end
local function deviceIdPrefix(device_id)
if type(device_id) ~= "string" or device_id == "" then return _("unknown") end
return device_id:sub(1, 8)
end
local function versionText(version)
if type(version) ~= "string" or version == "" then return _("unknown") end
return "v" .. version:gsub("^v", "")
end
local function recordText(entry)
if type(entry) ~= "table" then return _("none") end
local when = formatTime(entry.at)
local message = entry.message or entry.event or _("unknown")
return T(_("%1 at %2"), tostring(message), when)
end
local function highlightRecordText(entry)
return BookOrbitHighlightDiagnostics.lastSyncText(entry, formatShortTime)
end
local function jobText(job)
if type(job) ~= "table" then return _("none") end
return tostring(job.label or job.family or _("unknown"))
end
local function jobStartedText(job)
if type(job) ~= "table" then return _("none") end
return formatTime(job.started_at)
end
local function safeDocumentDigest(plugin, has_open_book)
if not has_open_book or not plugin.getDocumentDigest then return nil end
local ok, digest = pcall(plugin.getDocumentDigest, plugin)
if ok then return digest end
end
local function hasReaderBook(plugin)
return plugin.ui and plugin.ui.document ~= nil
end
local function hasCredentials(settings)
return settings.server_url ~= nil and settings.username ~= nil and settings.userkey ~= nil
end
function MainMenu:diagnosticsRows()
local status = BookOrbitSweep.syncStatus()
local sync_status = self.getSyncCoordinatorStatus and self:getSyncCoordinatorStatus() or { pending_count = 0 }
local has_open_book = hasReaderBook(self)
local digest = safeDocumentDigest(self, has_open_book)
local scheduler = self.open_annotation_scheduler and self.open_annotation_scheduler:status() or nil
local logged_in = self:isLoggedIn()
local open_highlight_text = BookOrbitHighlightDiagnostics.openHighlightsText{
annotation_sync = self.settings.annotation_sync,
has_open_book = has_open_book,
matched = self.isOpenBookMatched and self:isOpenBookMatched(digest) or false,
scheduler_status = scheduler,
last_highlight_sync = self.settings.last_highlight_sync,
}
local rows = {
{ _("Server"), self.settings.server_url or _("not set") },
{ _("Username"), self.settings.username or _("not set") },
{ _("Login"), self:isLoggedIn() and _("configured") or _("not configured") },
{ _("Device"), Device.model or _("unknown") },
{ _("Device ID"), deviceIdPrefix(self.device_id) },
{ _("Plugin"), versionText(self.PLUGIN_VERSION) },
{ _("Latest plugin"), versionText(self.settings.update_latest_version) },
}
if not logged_in then
if self.settings.last_error then
table.insert(rows, "----------------------------")
table.insert(rows, { _("Last error"), recordText(self.settings.last_error) })
end
table.insert(rows, "----------------------------")
if hasCredentials(self.settings) then
table.insert(rows, {
_("Test connection"),
_("Run"),
callback = function()
self:testConnection()
end,
})
end
return rows
end
table.insert(rows, "----------------------------")
table.insert(rows, { _("Auto sync"), formatBool(self.settings.auto_sync) })
table.insert(rows, { _("Two-way highlights"), formatBool(self.settings.annotation_sync) })
table.insert(rows, { _("Open highlights"), open_highlight_text })
table.insert(rows, { _("Highlight apply retry"), BookOrbitHighlightDiagnostics.retryText(self.open_highlight_retry_status) })
table.insert(rows, { _("Skip offline auto-sync"), formatBool(self.settings.skip_sync_when_offline) })
table.insert(rows, { _("Current sync"), jobText(sync_status.current) })
table.insert(rows, { _("Current sync started"), jobStartedText(sync_status.current) })
table.insert(rows, { _("Pending syncs"), tostring(sync_status.pending_count or 0) })
table.insert(rows, { _("Next sync"), jobText(sync_status.next) })
table.insert(rows, { _("Last sweep"), formatTime(status.lastSweepAt) })
table.insert(rows, { _("Matched local books"), tostring(status.matched or 0) })
table.insert(rows, { _("Unmatched local books"), tostring(status.unmatched or 0) })
table.insert(rows, "----------------------------")
table.insert(rows, { _("Last sync"), recordText(self.settings.last_sync) })
table.insert(rows, { _("Last highlight sync"), highlightRecordText(self.settings.last_highlight_sync) })
table.insert(rows, { _("Last error"), recordText(self.settings.last_error) })
table.insert(rows, "----------------------------")
if has_open_book then
if self.settings.annotation_sync then
table.insert(rows, {
_("Retry highlight sync"),
_("Run"),
callback = function()
self:retryOpenHighlightSync()
end,
})
end
table.insert(rows, {
_("Retry open-book match"),
_("Run"),
callback = function()
self:retryOpenBookMatch()
end,
})
end
table.insert(rows, {
_("Test connection"),
_("Run"),
callback = function()
self:testConnection()
end,
})
table.insert(rows, {
_("Check for update"),
_("Run"),
callback = function()
self:checkForUpdate()
end,
})
return rows
end
function MainMenu:showDiagnostics()
local KeyValuePage = require("ui/widget/keyvaluepage")
UIManager:show(KeyValuePage:new{
title = _("BookOrbit diagnostics"),
kv_pairs = self:diagnosticsRows(),
value_overflow_align = "right",
})
end
function MainMenu:testConnection()
if not self.settings.server_url then
UIManager:show(InfoMessage:new{ text = _("Set the BookOrbit server address first."), timeout = 3 })
return
end
if not self:isLoggedIn() then
self:promptLogin()
return
end
NetworkMgr:runWhenConnected(function()
local checking = InfoMessage:new{ text = _("Testing BookOrbit connection...") }
UIManager:show(checking)
local body, err = self:newClient():auth()
UIManager:close(checking)
if body then
self:recordSyncSuccess("connection_test", _("Connection test succeeded"))
UIManager:show(InfoMessage:new{ text = _("BookOrbit connection works."), timeout = 3 })
else
self:recordSyncError("connection_test", err)
UIManager:show(InfoMessage:new{
text = T(_("BookOrbit connection failed: %1"), self:errorLabel(err)),
timeout = 4,
})
end
end)
end
function MainMenu:dashboardSettingsMenu()
return {
{
text_func = function()
return T(_("Open dashboard on startup (%1)"), self:catalogAutoOpenLabel())
end,
sub_item_table = self:catalogAutoOpenMenu(),
},
}
end
function MainMenu:syncSettingsMenu(has_open_book)
local items = {}
table.insert(items, {
text = _("Skip auto-sync when offline"),
checked_func = function() return self.settings.skip_sync_when_offline end,
enabled_func = function() return self.settings.auto_sync end,
help_text = _([[When enabled, automatic sync on book open is skipped if the device is not already online. Prevents the e-reader from stalling while trying to connect to Wi-Fi.]]),
callback = function()
self.settings.skip_sync_when_offline = not self.settings.skip_sync_when_offline
end,
})
table.insert(items, {
text_func = function()
return T(_("Periodically sync every # pages (%1)"), self:getSyncPeriod())
end,
enabled_func = function() return self.settings.auto_sync end,
keep_menu_open = true,
separator = true,
callback = function(touchmenu_instance)
local SpinWidget = require("ui/widget/spinwidget")
local spin = SpinWidget:new{
text = _([[This value determines how many page turns it takes to push book progress.
If set to 0, updating progress based on page turns will be disabled.]]),
value = self.settings.pages_before_update or 0,
value_min = 0,
value_max = 999,
value_step = 1,
value_hold_step = 10,
ok_text = _("Set"),
title_text = _("Number of pages before update"),
default_value = 10,
callback = function(widget)
self:setPagesBeforeUpdate(widget.value)
if touchmenu_instance then touchmenu_instance:updateItems() end
end,
}
UIManager:show(spin)
end,
})
table.insert(items, {
text = _("Two-way highlight sync"),
checked_func = function() return self.settings.annotation_sync end,
help_text = _([[Also applies highlights, notes and deletions made in BookOrbit to this device: on book open, after the manual book sync, and during the full sweep for closed books. Turning this off keeps uploads only.]]),
callback = function()
self.settings.annotation_sync = not self.settings.annotation_sync
end,
})
table.insert(items, {
text_func = function()
return T(_("Sync to a newer state (%1)"), getNameStrategy(self.settings.sync_forward))
end,
sub_item_table = self:strategyMenu(
function() return self.settings.sync_forward end,
function(value) self.settings.sync_forward = value end
),
})
table.insert(items, {
text_func = function()
return T(_("Sync to an older state (%1)"), getNameStrategy(self.settings.sync_backward))
end,
sub_item_table = self:strategyMenu(
function() return self.settings.sync_backward end,
function(value) self.settings.sync_backward = value end
),
})
return items
end
function MainMenu:pluginSettingsMenu()
return {
{
id = "plugin_update",
text_func = function()
return self:updateCheckMenuText()
end,
keep_menu_open = true,
callback = function()
self:checkForUpdate()
end,
},
}
end
function MainMenu:settingsMenu(has_open_book, opts)
opts = opts or {}
local items = {
{
text = _("Dashboard"),
sub_item_table = self:dashboardSettingsMenu(),
},
{
text = _("Sync"),
sub_item_table = self:syncSettingsMenu(has_open_book),
},
}
if opts.include_plugin ~= false then
table.insert(items, {
text = _("Plugin"),
sub_item_table = self:pluginSettingsMenu(),
})
end
return items
end
function MainMenu:addToMainMenu(menu_items)
menu_items.bookorbit = {
text = _("BookOrbit"),
-- Fallback placement only: BookOrbitMenuPin normally pins this entry
-- right below calibre on the first page of the Tools menu.
sorting_hint = "tools",
sub_item_table = {
{
text = _("Open dashboard"),
enabled_func = function()
return self:isLoggedIn()
end,
callback = function()
self:browseCatalog()
end,
},
{
text_func = function()
return self:updateCheckMenuText()
end,
enabled_func = function()
return self:isLoggedIn()
and not BookOrbitSweep.isRunning()
and not BookOrbitBookSync.isRunning()
end,
keep_menu_open = true,
callback = function()
self:checkForUpdate()
end,
},
{
text_func = function()
local status = BookOrbitSweep.syncStatus()
local when = (status.lastSweepAt == 0) and _("never")
or os.date("%Y-%m-%d %H:%M", status.lastSweepAt)
if status.unmatched > 0 then
return T(_("Last sync: %1 (%2 linked, %3 unmatched)"), when, status.matched, status.unmatched)
elseif status.matched > 0 then
return T(_("Last sync: %1 (%2 linked)"), when, status.matched)
end
return T(_("Last sync: %1"), when)
end,
enabled = false,
separator = true,
},
{
text = _("Sync this book now"),
enabled_func = function()
return self:isLoggedIn() and self.ui.document ~= nil
and not BookOrbitSweep.isRunning() and not BookOrbitBookSync.isRunning()
end,
local logged_in = self:isLoggedIn()
local has_open_book = hasReaderBook(self)
local items = {}
if logged_in then
table.insert(items, {
id = "open_dashboard",
text = _("Open dashboard"),
callback = function()
self:browseCatalog()
end,
separator = true,
})
if has_open_book then
table.insert(items, {
id = "sync_current_book",
text = _("Sync current book now"),
callback = function()
self:onBookOrbitSyncBook()
end,
},
{
text = _("Sync all books now"),
enabled_func = function()
return self:isLoggedIn() and not BookOrbitSweep.isRunning() and not BookOrbitBookSync.isRunning()
end,
callback = function()
self:startSweep()
end,
separator = true,
},
{
text = _("Auto sync this book"),
})
table.insert(items, {
id = "auto_sync_current_book",
text = _("Auto sync current book"),
checked_func = function() return self.settings.auto_sync end,
help_text = _([[Pulls progress when a book is opened; pushes progress, highlights, status, rating and reading time when it is closed and on suspend.]]),
callback = function()
self:onBookOrbitToggleAutoSync(nil, true)
end,
},
{
text = _("Two-way highlight sync"),
checked_func = function() return self.settings.annotation_sync end,
help_text = _([[Also applies highlights, notes and deletions made in BookOrbit to this device: on book open, after the manual book sync, and during the full sweep for closed books. Turning this off keeps uploads only.]]),
callback = function()
self.settings.annotation_sync = not self.settings.annotation_sync
end,
},
{
text = _("Skip auto-sync when offline"),
checked_func = function() return self.settings.skip_sync_when_offline end,
enabled_func = function() return self.settings.auto_sync end,
help_text = _([[When enabled, automatic sync on book open is skipped if the device is not already online. Prevents the e-reader from stalling while trying to connect to Wi-Fi.]]),
callback = function()
self.settings.skip_sync_when_offline = not self.settings.skip_sync_when_offline
end,
separator = true,
})
end
table.insert(items, {
text = _("Sync all books now"),
callback = function()
self:startSweep()
end,
separator = true,
})
table.insert(items, {
id = "settings",
text = _("Settings"),
separator = true,
sub_item_table = self:settingsMenu(has_open_book),
})
end
table.insert(items, {
text = _("Account & setup"),
sub_item_table = {
{
text = _("BookOrbit server address"),
keep_menu_open = true,
callback = function()
self:setServerAddress()
end,
},
{
text = _("Sync settings"),
sub_item_table = {
{
text_func = function()
return T(_("Open dashboard on startup (%1)"), self:catalogAutoOpenLabel())
end,
sub_item_table = self:catalogAutoOpenMenu(),
},
{
text_func = function()
return T(_("Periodically sync every # pages (%1)"), self:getSyncPeriod())
end,
enabled_func = function() return self.settings.auto_sync end,
keep_menu_open = true,
callback = function(touchmenu_instance)
local SpinWidget = require("ui/widget/spinwidget")
local items = SpinWidget:new{
text = _([[This value determines how many page turns it takes to push book progress.
If set to 0, updating progress based on page turns will be disabled.]]),
value = self.settings.pages_before_update or 0,
value_min = 0,
value_max = 999,
value_step = 1,
value_hold_step = 10,
ok_text = _("Set"),
title_text = _("Number of pages before update"),
default_value = 10,
callback = function(spin)
self:setPagesBeforeUpdate(spin.value)
if touchmenu_instance then touchmenu_instance:updateItems() end
end,
}
UIManager:show(items)
end,
},
{
text_func = function()
return T(_("Sync to a newer state (%1)"), getNameStrategy(self.settings.sync_forward))
end,
sub_item_table = self:strategyMenu(
function() return self.settings.sync_forward end,
function(value) self.settings.sync_forward = value end
),
},
{
text_func = function()
return T(_("Sync to an older state (%1)"), getNameStrategy(self.settings.sync_backward))
end,
sub_item_table = self:strategyMenu(
function() return self.settings.sync_backward end,
function(value) self.settings.sync_backward = value end
),
},
},
text_func = function()
return self.settings.userkey and _("Logout") or _("Login")
end,
enabled_func = function()
return self.settings.server_url ~= nil
end,
keep_menu_open = true,
callback_func = function()
if self.settings.userkey then
return function(menu)
self:logout(menu)
end
else
return function(menu)
self:login(menu)
end
end
end,
},
{
text = _("Account & setup"),
sub_item_table = {
{
text = _("BookOrbit server address"),
keep_menu_open = true,
callback = function()
self:setServerAddress()
end,
},
{
text_func = function()
return self.settings.userkey and _("Logout") or _("Login")
end,
enabled_func = function()
return self.settings.server_url ~= nil
end,
keep_menu_open = true,
callback_func = function()
if self.settings.userkey then
return function(menu)
self:logout(menu)
end
else
return function(menu)
self:login(menu)
end
end
end,
},
},
text = _("Diagnostics"),
callback = function()
self:showDiagnostics()
end,
},
},
})
menu_items.bookorbit = {
text = _("BookOrbit"),
-- Fallback placement only: BookOrbitMenuPin normally pins this entry
-- right below calibre on the first page of the Tools menu.
sorting_hint = "tools",
sub_item_table = items,
}
end
@@ -271,9 +492,23 @@ function MainMenu:dashboardMenuItems(catalog)
local items = {
icon = "appbar.menu",
}
for index, item in ipairs(bookorbit_items) do
if index ~= 1 then
table.insert(items, item)
if self:isLoggedIn() then
local plugin_update = self:pluginSettingsMenu()[1]
plugin_update.separator = true
table.insert(items, plugin_update)
end
for _index, item in ipairs(bookorbit_items) do
if item.id ~= "open_dashboard" and item.id ~= "sync_current_book" and item.id ~= "auto_sync_current_book" then
if item.id == "settings" then
table.insert(items, {
id = "settings",
text = _("Settings"),
separator = item.separator,
sub_item_table = self:settingsMenu(false, { include_plugin = false }),
})
else
table.insert(items, item)
end
end
end
if catalog then
@@ -427,7 +662,11 @@ function MainMenu:doLogin(username, password, menu)
if menu then menu:updateItems() end
UIManager:show(InfoMessage:new{ text = _("Logged in to BookOrbit.") })
UIManager:scheduleIn(1, function()
self:maybeCheckForUpdate(false)
if self.requestUpdateCheck then
self:requestUpdateCheck(false, "login")
else
self:maybeCheckForUpdate(false)
end
end)
elseif err == 401 or err == 403 then
UIManager:show(InfoMessage:new{
@@ -0,0 +1,95 @@
local OpenAnnotationScheduler = {}
OpenAnnotationScheduler.__index = OpenAnnotationScheduler
function OpenAnnotationScheduler.new(opts)
opts = opts or {}
return setmetatable({
delay = opts.delay or 2,
schedule_in = opts.schedule_in,
token = 0,
pending = nil,
}, OpenAnnotationScheduler)
end
function OpenAnnotationScheduler:clear(token)
if self.pending and self.pending.token == token then
self.pending = nil
end
end
function OpenAnnotationScheduler:schedule(plugin, digest, reason, opts)
if not digest or digest == "" then return false end
opts = opts or {}
self.token = self.token + 1
local token = self.token
self.pending = {
token = token,
digest = digest,
reason = reason or "annotation_open",
match_retried = opts.match_retried == true,
phase = "scheduled",
}
self.schedule_in(self.delay, function()
self:run(plugin, token)
end)
return true
end
function OpenAnnotationScheduler:recordUnmatched(plugin, reason)
if plugin.recordOpenAnnotationUnmatched then
plugin:recordOpenAnnotationUnmatched(reason)
end
end
function OpenAnnotationScheduler:bookStillOpen(plugin, digest)
return not plugin.getDocumentDigest or plugin:getDocumentDigest() == digest
end
function OpenAnnotationScheduler:run(plugin, token)
local pending = self.pending
if not pending or pending.token ~= token then return end
local digest = pending.digest
local reason = pending.reason
if not self:bookStillOpen(plugin, digest) then
self:clear(token)
return
end
if plugin.shouldSkipAutoSyncOffline and plugin:shouldSkipAutoSyncOffline(reason) then
self:clear(token)
return
end
if plugin.isOpenBookMatched and plugin:isOpenBookMatched(digest) then
self:clear(token)
plugin:requestAnnotationExchange(reason)
return
end
if pending.match_retried or not plugin.matchOpenBookForAutoSync then
self:clear(token)
self:recordUnmatched(plugin, reason)
return
end
pending.phase = "matching"
plugin:matchOpenBookForAutoSync(function(matched)
local current = self.pending
if not current or current.token ~= token then return end
if not self:bookStillOpen(plugin, digest) then
self:clear(token)
return
end
self:clear(token)
if matched then
self:schedule(plugin, digest, reason, { match_retried = true })
else
self:recordUnmatched(plugin, reason)
end
end)
end
function OpenAnnotationScheduler:status()
return self.pending
end
return OpenAnnotationScheduler
@@ -53,14 +53,18 @@ end
-- Always the binary partial MD5: BookOrbit matches on the scanner-computed
-- partial MD5 of the file, so the filename checksum method does not exist here.
function ProgressSync:getDocumentDigest()
local digest = self.ui.doc_settings:readSetting("partial_md5_checksum")
if not self.ui or not self.ui.document then return nil end
local doc_settings = self.ui.doc_settings
local digest = doc_settings and doc_settings:readSetting("partial_md5_checksum") or nil
if digest then return digest end
local file = self.ui.document.file
if not file then return nil end
local ok, computed = pcall(util.partialMD5, file)
if not ok or not computed then return nil end
self.ui.doc_settings:saveSetting("partial_md5_checksum", computed)
if doc_settings then
doc_settings:saveSetting("partial_md5_checksum", computed)
end
return computed
end
@@ -99,12 +103,12 @@ end
function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
if not self.ui or not self.ui.document then
UIManager:show(InfoMessage:new{ text = _("Open a book to sync it."), timeout = 2 })
return
UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
return false
end
if self:getDocumentDigest() ~= digest then
UIManager:show(InfoMessage:new{ text = _("The open book changed. Start the sync again."), timeout = 3 })
return
return false
end
local client = self:newClient()
@@ -115,17 +119,17 @@ function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
logger.dbg("BookOrbit: progress check before book sync failed:", err)
UIManager:show(InfoMessage:new{ text = _("Could not check latest progress. Syncing book data without changing progress."), timeout = 4 })
on_done(true)
return
return true
end
if not body.percentage then
on_done(false)
return
return true
end
if body.device == Device.model and body.device_id == self.device_id then
on_done(false)
return
return true
end
body.percentage = Math.roundPercent(body.percentage)
@@ -134,7 +138,7 @@ function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
if local_percentage == body.percentage or body.progress == local_progress then
on_done(false)
return
return true
end
local remote_newer = self:remoteProgressIsNewer(body, local_percentage)
@@ -148,12 +152,13 @@ function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
else
on_done(false)
end
return
return true
end
local strategy = remote_newer and self.settings.sync_forward or self.settings.sync_backward
if strategy == SYNC_STRATEGY.SILENT then
self:applyRemoteProgress(body, on_done)
return true
elseif strategy == SYNC_STRATEGY.PROMPT then
local template = remote_newer and _("Sync to latest location %1% from device '%2' before uploading this book?")
or _("Sync to previous location %1% from device '%2' before uploading this book?")
@@ -166,14 +171,17 @@ function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
on_done(remote_newer)
end,
})
return true
elseif remote_newer then
UIManager:show(InfoMessage:new{
text = _("BookOrbit has newer progress. Syncing book data without changing progress."),
timeout = 4,
})
on_done(true)
return true
else
on_done(false)
return true
end
end
@@ -182,7 +190,12 @@ function ProgressSync:updateProgress(ensure_networking, interactive, on_suspend)
if interactive then self:promptLogin() end
return
end
if not self.ui or not self.ui.document then return end
if not self.ui or not self.ui.document then
if interactive then
UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
end
return
end
local now = UIManager:getElapsedTimeSinceBoot()
if not interactive and now - self.push_timestamp <= API_CALL_DEBOUNCE_DELAY then
@@ -201,15 +214,37 @@ function ProgressSync:updateProgress(ensure_networking, interactive, on_suspend)
local body, err = client:updateProgress(digest, self:getLastPercent(), self:getLastProgress(), os.time())
if interactive then
if body then
if self.recordSyncSuccess then
self:recordSyncSuccess("progress_push", _("Progress pushed"))
end
UIManager:show(InfoMessage:new{ text = _("Progress has been pushed to BookOrbit."), timeout = 3 })
if not on_suspend then self:maybeCheckForUpdate(false) end
if not on_suspend then
if self.requestUpdateCheck then
self:requestUpdateCheck(false, "progress_push")
else
self:maybeCheckForUpdate(false)
end
end
else
if self.recordSyncError then
self:recordSyncError("progress_push", err)
end
showSyncError()
end
elseif not body then
if self.recordSyncError then
self:recordSyncError("progress_push", err)
end
logger.dbg("BookOrbit: push failed:", err)
elseif not on_suspend then
self:maybeCheckForUpdate(false)
if self.recordSyncSuccess then
self:recordSyncSuccess("progress_push", _("Progress pushed"))
end
if self.requestUpdateCheck then
self:requestUpdateCheck(false, "progress_push")
else
self:maybeCheckForUpdate(false)
end
end
if on_suspend and Device:hasWifiManager() then
@@ -224,7 +259,12 @@ function ProgressSync:getProgress(ensure_networking, interactive)
if interactive then self:promptLogin() end
return
end
if not self.ui or not self.ui.document then return end
if not self.ui or not self.ui.document then
if interactive then
UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
end
return
end
local now = UIManager:getElapsedTimeSinceBoot()
if not interactive and now - self.pull_timestamp <= API_CALL_DEBOUNCE_DELAY then
@@ -245,10 +285,17 @@ function ProgressSync:getProgress(ensure_networking, interactive)
if not body then
if interactive then showSyncError() end
if self.recordSyncError then
self:recordSyncError("progress_pull", err)
end
logger.dbg("BookOrbit: pull failed:", err)
return
end
self:maybeCheckForUpdate(false)
if self.requestUpdateCheck then
self:requestUpdateCheck(false, "progress_pull")
else
self:maybeCheckForUpdate(false)
end
if not body.percentage then
if interactive then
@@ -277,6 +324,9 @@ function ProgressSync:getProgress(ensure_networking, interactive)
if interactive then
self:syncToProgress(body.progress)
if self.recordSyncSuccess then
self:recordSyncSuccess("progress_pull", _("Progress pulled"))
end
UIManager:show(InfoMessage:new{ text = _("Progress has been synchronized."), timeout = 3 })
return
end
@@ -291,6 +341,9 @@ function ProgressSync:getProgress(ensure_networking, interactive)
local strategy = self_older and self.settings.sync_forward or self.settings.sync_backward
if strategy == SYNC_STRATEGY.SILENT then
self:syncToProgress(body.progress)
if self.recordSyncSuccess then
self:recordSyncSuccess("progress_pull", _("Progress pulled"))
end
UIManager:show(InfoMessage:new{ text = _("Progress has been synchronized."), timeout = 3 })
elseif strategy == SYNC_STRATEGY.PROMPT then
local template = self_older and _("Sync to latest location %1% from device '%2'?")
@@ -299,6 +352,9 @@ function ProgressSync:getProgress(ensure_networking, interactive)
text = T(template, Math.round(body.percentage * 100), body.device),
ok_callback = function()
self:syncToProgress(body.progress)
if self.recordSyncSuccess then
self:recordSyncSuccess("progress_pull", _("Progress pulled"))
end
end,
})
end
@@ -23,6 +23,7 @@ local _ = require("gettext")
local BookOrbitAnnotations = require("bookorbit_annotations")
local BookOrbitApi = require("bookorbit_api")
local BookOrbitHighlightSummary = require("bookorbit_highlight_summary")
local BookOrbitSidecar = require("bookorbit_sidecar")
local BookOrbitState = require("bookorbit_state")
local BookOrbitStatsReader = require("bookorbit_stats_reader")
@@ -97,8 +98,27 @@ local function finish(ctx, err)
ctx.progress_msg = nil
end
ctx.state:flush()
if ctx.on_finish then
pcall(ctx.on_finish, err)
if ctx.plugin and ctx.plugin.recordSyncError then
if err == "auth" or err == "network" then
ctx.plugin:recordSyncError("sweep", err)
elseif not err and ctx.had_errors then
ctx.plugin:recordSyncError("sweep", "partial_failure")
elseif not err and ctx.plugin.recordSyncSuccess then
ctx.plugin:recordSyncSuccess(
"sweep",
T(_("%1 books, %2 reading events, %3 highlights"),
ctx.counts.books_matched, 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
@@ -107,6 +127,9 @@ local function finish(ctx, err)
else
Notification:notify(_("BookOrbit sync: login failed"))
end
if ctx.on_finish then
pcall(ctx.on_finish, err)
end
return
end
@@ -115,20 +138,37 @@ local function finish(ctx, err)
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync: server not reachable."), timeout = 4 })
end
logger.dbg("BookOrbit: sweep aborted, server not reachable")
if ctx.on_finish then
pcall(ctx.on_finish, err)
end
return
end
if ctx.interactive then
local text = T(_("BookOrbit sync done: %1 books matched, %2 reading events, %3 highlights."),
ctx.counts.books_matched, ctx.counts.page_stats, ctx.counts.annotations)
if ctx.highlight_summary and BookOrbitHighlightSummary.hasRemoteChanges(ctx.highlight_summary) then
text = text .. "\n" .. T(_("Highlights updated: %1 applied, %2 deleted, %3 closed book(s)."),
ctx.highlight_summary.applied, ctx.highlight_summary.deleted, ctx.highlight_summary.touched_books)
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 sync is disabled.")
end
if ctx.had_errors then
text = text .. "\n" .. _("Some books failed and will retry on the next sync.")
end
UIManager:show(InfoMessage:new{ text = text, timeout = 5 })
end
logger.info(string.format(
"BookOrbit: sweep done matched=%d pageStats=%d annotations=%d errors=%s",
ctx.counts.books_matched, ctx.counts.page_stats, ctx.counts.annotations, tostring(ctx.had_errors)))
"BookOrbit: sweep done matched=%d pageStats=%d annotations=%d applied=%d deleted=%d errors=%s",
ctx.counts.books_matched, ctx.counts.page_stats, ctx.counts.annotations,
ctx.counts.ann_applied or 0, ctx.counts.ann_deleted or 0, tostring(ctx.had_errors)))
if ctx.on_finish then
pcall(ctx.on_finish, err)
end
end
local function step(ctx, fn)
@@ -479,6 +519,9 @@ local function stepAnnotationsNext(ctx)
local apply_mode = "skip"
if ctx.annotation_sync and entry.file ~= currentlyOpenFile() then
apply_mode = "sidecar"
elseif not ctx.annotation_sync and ctx.interactive and not ctx.highlight_disabled_reported then
ctx.highlight_disabled_reported = true
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, nil, { skipped = 1 })
end
local result, err = BookOrbitAnnotations.exchangeBook({
@@ -496,11 +539,14 @@ local function stepAnnotationsNext(ctx)
-- Re-queue everything, including this entry, for the legacy path.
table.insert(ctx.ann_queue, 1, entry)
ctx.use_legacy_annotations = true
ctx.highlight_unsupported = true
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, nil, { skipped = 1 })
buildLegacyAnnotationChunks(ctx)
return step(ctx, ctx.steps.annotationsLegacyNext)
end
ctx.had_errors = true
if pending then pending.failed = true end
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { had_errors = true })
logger.dbg("BookOrbit: annotation exchange failed for", entry.md5, err)
return step(ctx, ctx.steps.annotationsNext)
end
@@ -513,6 +559,10 @@ local function stepAnnotationsNext(ctx)
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",
})
return step(ctx, ctx.steps.annotationsNext)
end
@@ -565,6 +615,7 @@ local function stepAnnotationsLegacyNext(ctx)
if isAuthError(err) then return finish(ctx, "auth") end
logger.dbg("BookOrbit: annotations upload failed:", err)
ctx.had_errors = true
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { had_errors = true })
for _, book in ipairs(books) do
local pending = ctx.pending_books[book.hash]
if pending then pending.failed = true end
@@ -586,6 +637,9 @@ local function stepAnnotationsLegacyNext(ctx)
elseif pending then
pending.ann_chunks_left = pending.ann_chunks_left - 1
ctx.counts.annotations = ctx.counts.annotations + #book.annotations
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, {
uploaded = #book.annotations,
})
end
end
@@ -747,7 +801,7 @@ function BookOrbitSweep.run(opts)
if opts.interactive then
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync is already running."), timeout = 2 })
end
return
return false
end
local BookOrbitBookSync = require("bookorbit_book_sync")
@@ -755,7 +809,7 @@ function BookOrbitSweep.run(opts)
if opts.interactive then
UIManager:show(InfoMessage:new{ text = _("BookOrbit is syncing the current book, try again shortly."), timeout = 2 })
end
return
return false
end
local client = BookOrbitApi.new(opts.api)
@@ -763,7 +817,7 @@ function BookOrbitSweep.run(opts)
if opts.interactive then
UIManager:show(InfoMessage:new{ text = _("Please configure the BookOrbit server and login first."), timeout = 3 })
end
return
return false
end
BookOrbitSweep.running = true
@@ -781,9 +835,14 @@ function BookOrbitSweep.run(opts)
state = state,
interactive = opts.interactive or false,
annotation_sync = opts.annotation_sync ~= false,
plugin = opts.plugin,
full_recheck = opts.interactive or state.global.needsFullRecheck or false,
on_finish = opts.on_finish,
counts = { books_matched = 0, page_stats = 0, annotations = 0, ann_applied = 0 },
counts = { books_matched = 0, page_stats = 0, annotations = 0, ann_applied = 0, ann_deleted = 0 },
highlight_summary = BookOrbitHighlightSummary.normalize{
event = "sweep",
reason = opts.interactive and "manual" or "auto",
},
had_errors = false,
}
ctx.steps = {
@@ -803,6 +862,7 @@ function BookOrbitSweep.run(opts)
logger.info("BookOrbit: sweep started, interactive:", ctx.interactive)
step(ctx, ctx.steps.enumerate)
return true
end
return BookOrbitSweep
@@ -0,0 +1,146 @@
--[[--
Small sync job coordinator for the BookOrbit plugin.
It serializes sync work, coalesces pending jobs by family and lets callers
export a compact status snapshot for diagnostics. The coordinator deliberately
does not know about KOReader widgets or BookOrbit APIs; jobs provide the work.
]]
local SyncCoordinator = {}
SyncCoordinator.__index = SyncCoordinator
SyncCoordinator.PRIORITY = {
auto = 100,
manual = 200,
lifecycle = 300,
}
function SyncCoordinator.new(opts)
opts = opts or {}
return setmetatable({
now = opts.now or os.time,
current = nil,
pending = {},
pending_count = 0,
next_seq = 0,
}, SyncCoordinator)
end
local function copyJobStatus(job, now)
if not job then return nil end
local started_at = job.started_at
local status = {
family = job.family,
label = job.label or job.family,
source = job.source,
interactive = job.interactive == true,
priority = job.priority,
created_at = job.created_at,
started_at = started_at,
}
if started_at then
status.age = math.max(0, now - started_at)
end
return status
end
function SyncCoordinator:isBusy()
return self.current ~= nil
end
function SyncCoordinator:queuedCount()
return self.pending_count
end
function SyncCoordinator:coalesce(job)
local existing = self.pending[job.family]
if existing and existing.priority > job.priority then
return "kept", existing
end
if not existing then
self.pending_count = self.pending_count + 1
end
self.pending[job.family] = job
return "queued", job
end
function SyncCoordinator:nextPending()
local best_family, best_job
for family, job in pairs(self.pending) do
if not best_job
or job.priority > best_job.priority
or (job.priority == best_job.priority and job.seq < best_job.seq) then
best_family = family
best_job = job
end
end
return best_family, best_job
end
function SyncCoordinator:startNext()
local family, job = self:nextPending()
if not job then return end
self.pending[family] = nil
self.pending_count = self.pending_count - 1
self:start(job)
end
function SyncCoordinator:start(job)
self.current = job
job.started_at = self.now()
local finished = false
local function done()
if finished then return end
finished = true
if self.current == job then
self.current = nil
self:startNext()
end
end
local ok, started = pcall(job.run, done, job)
if not ok then
if job.on_error then
pcall(job.on_error, started)
end
done()
return
end
if job.async and started == false then
done()
elseif not job.async then
done()
end
end
function SyncCoordinator:submit(job)
assert(type(job) == "table", "sync job must be a table")
assert(type(job.family) == "string" and job.family ~= "", "sync job family required")
assert(type(job.run) == "function", "sync job run function required")
self.next_seq = self.next_seq + 1
job.seq = self.next_seq
job.created_at = self.now()
job.priority = job.priority or SyncCoordinator.PRIORITY.auto
job.label = job.label or job.family
if self.current then
return self:coalesce(job)
end
self:start(job)
return "started", job
end
function SyncCoordinator:status()
local now = self.now()
local _, next_job = self:nextPending()
return {
current = copyJobStatus(self.current, now),
pending_count = self.pending_count,
next = copyJobStatus(next_job, now),
}
end
return SyncCoordinator
@@ -145,6 +145,10 @@ function UpdateCheck:updateCheckMenuText()
end
function UpdateCheck:checkForUpdate()
if self.requestUpdateCheck then
self:requestUpdateCheck(true, "manual")
return
end
if not self:isLoggedIn() then
self:promptLogin()
return
@@ -168,6 +172,9 @@ function UpdateCheck:maybeCheckForUpdate(interactive)
self._checking_update = false
if not body then
if self.recordSyncError then
self:recordSyncError("update_check", err)
end
if interactive then
UIManager:show(InfoMessage:new{
text = T(_("Could not check for update: %1"), tostring(err or "network error")),
@@ -190,6 +197,9 @@ function UpdateCheck:doCheckForUpdate()
UIManager:close(checking)
if not body then
if self.recordSyncError then
self:recordSyncError("update_check", err)
end
UIManager:show(InfoMessage:new{
text = T(_("Could not check for update: %1"), tostring(err or "network error")),
timeout = 4,
@@ -207,6 +217,9 @@ function UpdateCheck:handleUpdateVersionResponse(body, interactive, prompt_allow
if type(plugin_latest) ~= "string" or plugin_latest == "unknown" then
G_reader_settings:flush()
if self.recordSyncError then
self:recordSyncError("update_check", "unsupported_server", _("latest plugin version unavailable"))
end
if interactive then
UIManager:show(InfoMessage:new{
text = _("Could not determine the latest plugin version from the server."),
+577 -97
View File
@@ -31,7 +31,10 @@ local BookOrbitAnnotations = require("bookorbit_annotations")
local BookOrbitApi = require("bookorbit_api")
local BookOrbitBookSync = require("bookorbit_book_sync")
local BookOrbitCatalog = require("bookorbit_catalog")
local BookOrbitHighlightSummary = require("bookorbit_highlight_summary")
local BookOrbitOpenAnnotationScheduler = require("bookorbit_open_annotation_scheduler")
local BookOrbitState = require("bookorbit_state")
local BookOrbitSyncCoordinator = require("bookorbit_sync_coordinator")
local BookOrbitMainMenu = require("bookorbit_main_menu")
local BookOrbitMenuPin = require("bookorbit_menu_pin")
local BookOrbitProgressSync = require("bookorbit_progress_sync")
@@ -46,6 +49,20 @@ local SYNC_STRATEGY = {
DISABLE = 3,
}
local LAST_ERROR_LABELS = {
auth = _("login failed"),
network = _("server not reachable"),
invalid_json = _("invalid server response"),
unsupported_server = _("server update required"),
body_too_large = _("request too large"),
partial_failure = _("partial sync failure"),
}
local SYNC_JOB_PRIORITY = BookOrbitSyncCoordinator.PRIORITY
local OPEN_ANNOTATION_SYNC_DELAY = 2
local OPEN_HIGHLIGHT_RETRY_DELAY = 8
local OPEN_HIGHLIGHT_MAX_RETRIES = 2
local BookOrbit = WidgetContainer:extend{
name = "bookorbit",
title = _("Login to BookOrbit"),
@@ -104,7 +121,7 @@ function BookOrbit:init()
-- Push only, no pull, no network nagging: relies on the connection
-- being already up, like the stock kosync periodic push.
if self.settings.auto_sync and (self.settings.pages_before_update or 0) > 0 then
self:updateProgress(false, false)
self:requestProgressPush(false, false, "periodic")
end
end
@@ -161,7 +178,7 @@ function BookOrbit:init()
self.ui.menu:registerToMainMenu(self)
self:onStart()
UIManager:scheduleIn(5, function()
self:maybeCheckForUpdate(false)
self:requestUpdateCheck(false, "startup")
end)
end
@@ -230,6 +247,419 @@ function BookOrbit:isLoggedIn()
return self.settings.server_url ~= nil and self.settings.username ~= nil and self.settings.userkey ~= nil
end
function BookOrbit:errorLabel(err, fallback)
if LAST_ERROR_LABELS[err] then return LAST_ERROR_LABELS[err] end
if err == 401 or err == 403 then return LAST_ERROR_LABELS.auth end
if type(err) == "number" then
return T(_("server error %1"), err)
end
return fallback or tostring(err or _("unknown error"))
end
function BookOrbit:recordSyncSuccess(event, message)
self.settings.last_sync = {
event = event,
at = os.time(),
message = message,
}
self.settings.last_error = nil
G_reader_settings:flush()
end
function BookOrbit:recordSyncError(event, err, message)
self.settings.last_error = {
event = event,
at = os.time(),
code = err,
message = message or self:errorLabel(err),
}
G_reader_settings:flush()
end
function BookOrbit:recordHighlightSync(summary, err)
summary = BookOrbitHighlightSummary.normalize(summary)
summary.message = summary.message or BookOrbitHighlightSummary.message(summary)
self.settings.last_highlight_sync = summary
local code = BookOrbitHighlightSummary.actionableError(summary, err)
if code then
local message
if code == "unsupported_server" then
message = _("BookOrbit server needs an update for two-way highlights.")
end
self:recordSyncError("highlight_sync", code, message)
else
G_reader_settings:flush()
end
end
function BookOrbit:shouldSkipAutoSyncOffline(event)
if self.settings.skip_sync_when_offline and not NetworkMgr:isOnline() then
logger.dbg("BookOrbit: automatic sync skipped while offline:", event)
return true
end
return false
end
function BookOrbit:getSyncCoordinator()
if not self.sync_coordinator then
self.sync_coordinator = BookOrbitSyncCoordinator.new()
end
return self.sync_coordinator
end
function BookOrbit:getSyncCoordinatorStatus()
if not self.sync_coordinator then
return { pending_count = 0 }
end
return self.sync_coordinator:status()
end
function BookOrbit:getOpenAnnotationScheduler()
if not self.open_annotation_scheduler then
self.open_annotation_scheduler = BookOrbitOpenAnnotationScheduler.new{
delay = OPEN_ANNOTATION_SYNC_DELAY,
schedule_in = function(delay, callback)
UIManager:scheduleIn(delay, callback)
end,
}
end
return self.open_annotation_scheduler
end
function BookOrbit:isOpenBookMatched(digest)
if not digest then return false end
local state = BookOrbitState.open()
return state:getBook(digest) ~= nil
end
function BookOrbit:recordOpenAnnotationUnmatched(reason)
self:recordHighlightSync({
event = "open_book",
reason = reason or "annotation_open",
skipped = 1,
}, "unmatched")
end
function BookOrbit:scheduleOpenAnnotationSync(digest, reason, opts)
return self:getOpenAnnotationScheduler():schedule(self, digest, reason or "annotation_open", opts)
end
function BookOrbit:retryOpenHighlightSync()
if not self:isLoggedIn() then
self:promptLogin()
return
end
if not self.ui or not self.ui.document then
UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
return
end
self.open_highlight_retry_status = nil
self:requestAnnotationExchange("diagnostics_retry")
UIManager:show(InfoMessage:new{ text = _("BookOrbit highlight sync queued."), timeout = 2 })
end
function BookOrbit:retryOpenBookMatch()
if not self:isLoggedIn() then
self:promptLogin()
return
end
if not self.ui or not self.ui.document then
UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
return
end
local digest = self:getDocumentDigest()
if not digest then
UIManager:show(InfoMessage:new{ text = _("Could not identify the open book."), timeout = 2 })
return
end
local state = BookOrbitState.open()
state.unmatched[digest] = nil
state:flush()
self:requestOpenBookMatch("diagnostics", {
schedule_annotation_sync = self.settings.annotation_sync,
annotation_digest = digest,
})
UIManager:show(InfoMessage:new{ text = _("BookOrbit match retry queued."), timeout = 2 })
end
function BookOrbit:submitSyncJob(job)
local result = self:getSyncCoordinator():submit(job)
if (result == "queued" or result == "kept") and job.interactive then
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync queued."), timeout = 2 })
end
return result
end
function BookOrbit:requestUpdateCheck(interactive, source)
if interactive and not self:isLoggedIn() then
self:promptLogin()
return
end
local submit = function()
self:submitSyncJob{
family = "update_check",
label = _("Update check"),
source = source or (interactive and "manual" or "auto"),
priority = interactive and SYNC_JOB_PRIORITY.manual or SYNC_JOB_PRIORITY.auto,
interactive = interactive == true,
run = function()
if interactive then
self:doCheckForUpdate()
else
self:maybeCheckForUpdate(false)
end
end,
}
end
if interactive then
NetworkMgr:runWhenConnected(submit)
else
submit()
end
end
function BookOrbit:requestProgressPush(ensure_networking, interactive, source, priority)
self:submitSyncJob{
family = "progress_push",
label = _("Progress push"),
source = source or (interactive and "manual" or "auto"),
priority = priority or (interactive and SYNC_JOB_PRIORITY.manual or SYNC_JOB_PRIORITY.auto),
interactive = interactive == true,
run = function()
self:updateProgress(ensure_networking, interactive)
end,
}
end
function BookOrbit:requestProgressPull(ensure_networking, interactive, source, priority)
self:submitSyncJob{
family = "progress_pull",
label = _("Progress pull"),
source = source or (interactive and "manual" or "auto"),
priority = priority or (interactive and SYNC_JOB_PRIORITY.manual or SYNC_JOB_PRIORITY.auto),
interactive = interactive == true,
run = function()
self:getProgress(ensure_networking, interactive)
end,
}
end
function BookOrbit:requestAnnotationExchange(source)
self:submitSyncJob{
family = "annotation_exchange",
label = _("Highlight sync"),
source = source or "auto",
priority = SYNC_JOB_PRIORITY.auto,
interactive = false,
async = true,
run = function(done)
local execute = function()
self:exchangeAnnotationsForOpenBook(source or "auto")
done()
end
if NetworkMgr:willRerunWhenConnected(execute) then
return
end
execute()
end,
}
end
function BookOrbit:scheduleOpenHighlightRetry(reason, retry_count)
retry_count = retry_count or 0
if retry_count >= OPEN_HIGHLIGHT_MAX_RETRIES then
self.open_highlight_retry_status = nil
return
end
self.open_highlight_retry_status = {
pending = true,
reason = reason or "annotation_retry",
retry_count = retry_count + 1,
max_retries = OPEN_HIGHLIGHT_MAX_RETRIES,
}
UIManager:scheduleIn(OPEN_HIGHLIGHT_RETRY_DELAY, function()
self.open_highlight_retry_status = nil
if self:shouldSkipAutoSyncOffline("annotation_retry") then return end
self:submitSyncJob{
family = "annotation_exchange",
label = _("Highlight sync"),
source = reason or "annotation_retry",
priority = SYNC_JOB_PRIORITY.auto,
interactive = false,
async = true,
run = function(done)
local execute = function()
self:exchangeAnnotationsForOpenBook(reason or "annotation_retry", retry_count + 1)
done()
end
if NetworkMgr:willRerunWhenConnected(execute) then
return
end
execute()
end,
}
end)
end
function BookOrbit:requestOpenBookMatch(source, opts)
opts = opts or {}
self:submitSyncJob{
family = "match_open_book",
label = _("Open book match"),
source = source or "auto",
priority = SYNC_JOB_PRIORITY.auto,
interactive = false,
async = true,
run = function(done)
self:matchOpenBookForAutoSync(function(matched)
done()
if not matched then return end
if opts.schedule_annotation_sync and self.settings.annotation_sync then
self:scheduleOpenAnnotationSync(opts.annotation_digest or self:getDocumentDigest(), "annotation_open")
end
self:requestProgressPull(true, false, "reader_ready")
end)
end,
}
end
function BookOrbit:requestSweep(interactive, source)
local submit = function()
self:submitSyncJob{
family = "sweep",
label = _("Library sync"),
source = source or (interactive and "manual" or "auto"),
priority = interactive and SYNC_JOB_PRIORITY.manual or SYNC_JOB_PRIORITY.auto,
interactive = interactive == true,
async = true,
run = function(done)
local started = BookOrbitSweep.run{
api = self:apiOpts(),
interactive = interactive == true,
annotation_sync = self.settings.annotation_sync,
plugin = self,
on_finish = function(err)
if not err then
self:requestUpdateCheck(false, "sweep_done")
end
done()
end,
}
if started == false then
done()
end
end,
}
end
if NetworkMgr:willRerunWhenOnline(submit) then
return
end
submit()
end
function BookOrbit:requestManualBookSync(snap)
if not snap then return end
local api_opts = self:apiOpts()
self:submitSyncJob{
family = "book_snapshot",
label = _("Book sync"),
source = "manual",
priority = SYNC_JOB_PRIORITY.manual,
interactive = true,
async = true,
run = function(done)
local function run_book_sync(skip_progress)
local latest_snap = BookOrbitBookSync.capture(self)
if not latest_snap then
UIManager:show(InfoMessage:new{ text = _("Could not read this book's data."), timeout = 2 })
done()
return
end
if latest_snap.digest ~= snap.digest then
UIManager:show(InfoMessage:new{ text = _("The open book changed. Start the sync again."), timeout = 3 })
done()
return
end
local started = BookOrbitBookSync.run{
api = api_opts,
snap = latest_snap,
reason = "manual",
interactive = true,
plugin = self,
annotation_sync = self.settings.annotation_sync,
skip_progress = skip_progress,
on_finish = done,
}
if started == false then
done()
end
end
local run = function()
local will_finish = self:reconcileProgressBeforeBookSync(snap.digest, run_book_sync)
if will_finish == false then
done()
end
end
if NetworkMgr:willRerunWhenOnline(run) then
return
end
run()
end,
}
end
function BookOrbit:requestBookSnapshotSync(opts)
opts = opts or {}
if not opts.snap then return end
local api_opts = self:apiOpts()
self:submitSyncJob{
family = "book_snapshot",
label = opts.label or _("Book sync"),
source = opts.reason or "auto",
priority = opts.priority or SYNC_JOB_PRIORITY.auto,
interactive = opts.interactive == true,
async = true,
run = function(done)
local function run()
local started = BookOrbitBookSync.run{
api = api_opts,
snap = opts.snap,
reason = opts.reason,
interactive = opts.interactive == true,
synchronous = opts.synchronous == true,
plugin = self,
on_finish = function()
if opts.on_finish then
pcall(opts.on_finish)
end
done()
end,
annotation_sync = self.settings.annotation_sync,
}
if started == false then
done()
end
end
if opts.go_online then
NetworkMgr:goOnlineToRun(run)
return
end
if opts.ensure_networking and NetworkMgr:willRerunWhenOnline(run) then
return
end
run()
end,
}
end
function BookOrbit:onStart()
if PluginShare.bookorbit_auto_open_done then return end
PluginShare.bookorbit_auto_open_done = true
@@ -279,28 +709,92 @@ end
function BookOrbit:onReaderReady()
if self.settings.auto_sync then
UIManager:nextTick(function()
if self.settings.skip_sync_when_offline and not NetworkMgr:isOnline() then
return
end
self:getProgress(true, false)
end)
if self.settings.annotation_sync then
UIManager:scheduleIn(2, function()
if self.settings.skip_sync_when_offline and not NetworkMgr:isOnline() then
return
if self:shouldSkipAutoSyncOffline("reader_ready") then return end
local digest = self:getDocumentDigest()
if self.settings.annotation_sync then
if self:isOpenBookMatched(digest) then
self:scheduleOpenAnnotationSync(digest, "annotation_open")
end
self:exchangeAnnotationsForOpenBook()
end)
end
end
self:requestOpenBookMatch("reader_ready", {
schedule_annotation_sync = self.settings.annotation_sync,
annotation_digest = digest,
})
end)
end
self:registerEvents()
self.last_page = self.ui:getCurrentPage()
end
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("%.[^%.]+$", "")
return title ~= "" and title or name
end
function BookOrbit:matchOpenBookForAutoSync(on_done)
if not self:isLoggedIn() or not self.ui or not self.ui.document then
if on_done then on_done(false) end
return
end
local digest = self:getDocumentDigest()
if not digest then
if on_done then on_done(false) end
return
end
local state = BookOrbitState.open()
if state:getBook(digest) then
if on_done then on_done(true) end
return
end
local run = function()
if not self.ui or not self.ui.document then
if on_done then on_done(false) end
return
end
local body, err = self:newClient():matchCheck({ digest }, {
[digest] = {
title = titleFromFile(self.ui.document.file),
source = "current_file",
},
})
if not body then
self:recordSyncError("match_open_book", err)
if on_done then on_done(false) end
return
end
local matched = false
for _, match in ipairs(body.matches or {}) do
if match.hash == digest then
state:setMatched(match.hash, match.bookFileId, match.bookId, self.ui.document.file)
matched = true
break
end
end
state:flush()
if on_done then on_done(matched) end
end
if self:shouldSkipAutoSyncOffline("match_open_book") then
if on_done then on_done(false) end
return
end
if NetworkMgr:willRerunWhenConnected(run) then
return
end
run()
end
-- Two-way annotation pull/push for the open book. Runs once per book open;
-- safe to call again manually, guarded against concurrent syncs.
function BookOrbit:exchangeAnnotationsForOpenBook()
function BookOrbit:exchangeAnnotationsForOpenBook(reason, retry_count)
if self.annotation_exchange_running then return end
if not self:isLoggedIn() or not self.ui or not self.ui.document then return end
if BookOrbitBookSync.isRunning() or BookOrbitSweep.isRunning() then return end
@@ -310,6 +804,11 @@ function BookOrbit:exchangeAnnotationsForOpenBook()
local state = BookOrbitState.open()
if not state:getBook(digest) then
-- Unknown or unmatched book: the close-path snapshot sync matches it.
self:recordHighlightSync({
event = "open_book",
reason = reason or "auto",
skipped = 1,
}, "unmatched")
return
end
@@ -325,13 +824,36 @@ function BookOrbit:exchangeAnnotationsForOpenBook()
if not ok then
logger.err("BookOrbit: annotation exchange error:", result)
self:recordHighlightSync({
event = "open_book",
reason = reason or "auto",
failed = 1,
}, "partial_failure")
elseif result then
local touched = (result.applied or 0) + (result.deleted or 0)
if touched > 0 then
Notification:notify(T(_("BookOrbit: %1 highlight(s) updated"), touched))
local summary = BookOrbitHighlightSummary.add({
event = "open_book",
reason = reason or "auto",
}, result)
self:recordHighlightSync(summary)
if BookOrbitHighlightSummary.hasRemoteChanges(summary) then
Notification:notify(T(_("BookOrbit: %1 highlight(s) updated"),
summary.applied + summary.deleted))
end
if reason == "annotation_open" and (summary.failed or 0) > 0 then
self:scheduleOpenHighlightRetry(reason, retry_count or 0)
elseif (summary.failed or 0) == 0 then
self.open_highlight_retry_status = nil
end
elseif err then
self:recordHighlightSync({
event = "open_book",
reason = reason or "auto",
skipped = err == "unmatched" and 1 or 0,
failed = (err == "network" or err == "unsupported_server") and 1 or 0,
}, err)
if err ~= "unmatched" and err ~= "unsupported_server" and err ~= "network" then
logger.dbg("BookOrbit: annotation exchange skipped:", err)
end
elseif err and err ~= "unmatched" and err ~= "unsupported_server" and err ~= "network" then
logger.dbg("BookOrbit: annotation exchange skipped:", err)
end
end
@@ -382,23 +904,7 @@ function BookOrbit:startSweep()
promptLogin()
return
end
if BookOrbitBookSync.isRunning() then
UIManager:show(InfoMessage:new{ text = _("BookOrbit is syncing the current book, try again shortly."), timeout = 2 })
return
end
local sweep_opts = {
api = self:apiOpts(),
interactive = true,
annotation_sync = self.settings.annotation_sync,
on_finish = function(err)
if not err then self:maybeCheckForUpdate(false) end
end,
}
if NetworkMgr:willRerunWhenOnline(function() BookOrbitSweep.run(sweep_opts) end) then
return
end
BookOrbitSweep.run(sweep_opts)
self:requestSweep(true, "manual")
end
function BookOrbit:onBookOrbitSyncBook()
@@ -407,11 +913,7 @@ function BookOrbit:onBookOrbitSyncBook()
return
end
if not self.ui or not self.ui.document then
UIManager:show(InfoMessage:new{ text = _("Open a book to sync it."), timeout = 2 })
return
end
if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync is already running."), timeout = 2 })
UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
return
end
@@ -421,27 +923,7 @@ function BookOrbit:onBookOrbitSyncBook()
return
end
local api_opts = self:apiOpts()
local run_book_sync = function(skip_progress)
local latest_snap = BookOrbitBookSync.capture(self)
if not latest_snap then
UIManager:show(InfoMessage:new{ text = _("Could not read this book's data."), timeout = 2 })
return
end
if latest_snap.digest ~= snap.digest then
UIManager:show(InfoMessage:new{ text = _("The open book changed. Start the sync again."), timeout = 3 })
return
end
BookOrbitBookSync.run{ api = api_opts, snap = latest_snap, reason = "manual", interactive = true, plugin = self,
annotation_sync = self.settings.annotation_sync, skip_progress = skip_progress }
end
local run = function()
self:reconcileProgressBeforeBookSync(snap.digest, run_book_sync)
end
if NetworkMgr:willRerunWhenOnline(run) then
return
end
run()
self:requestManualBookSync(snap)
end
-- Events
@@ -453,10 +935,7 @@ function BookOrbit:_onCloseDocument()
UIManager:unschedule(self.periodic_push_task)
self.periodic_push_scheduled = false
if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then
logger.dbg("BookOrbit: close sync skipped, another sync is running")
return
end
if self:shouldSkipAutoSyncOffline("close") then return end
-- Snapshot now: reader objects die after this handler returns. ReaderUI
-- already flushed the sidecar and statistics before broadcasting
@@ -464,11 +943,14 @@ function BookOrbit:_onCloseDocument()
local snap = BookOrbitBookSync.capture(self)
if not snap then return end
local api_opts = self:apiOpts()
NetworkMgr:goOnlineToRun(function()
BookOrbitBookSync.run{ api = api_opts, snap = snap, reason = "close", interactive = false,
annotation_sync = self.settings.annotation_sync }
end)
self:requestBookSnapshotSync{
snap = snap,
reason = "close",
label = _("Close sync"),
priority = SYNC_JOB_PRIORITY.lifecycle,
interactive = false,
go_online = true,
}
end
function BookOrbit:_onPageUpdate(page)
@@ -491,8 +973,9 @@ function BookOrbit:_onResume()
if Device:hasWifiRestore() and NetworkMgr.wifi_was_on and G_reader_settings:isTrue("auto_restore_wifi") then
return
end
if self:shouldSkipAutoSyncOffline("resume") then return end
UIManager:scheduleIn(1, function()
self:getProgress(true, false)
self:requestProgressPull(true, false, "resume")
end)
end
@@ -502,51 +985,48 @@ function BookOrbit:_onSuspend()
self.periodic_push_scheduled = false
if not self:isLoggedIn() then return end
if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then return end
if self:shouldSkipAutoSyncOffline("suspend") then return end
local snap = BookOrbitBookSync.capture(self)
if not snap then return end
local on_finish
if Device:hasWifiManager() then
if Device:hasWifiManager() and not self:getSyncCoordinator():isBusy() then
on_finish = function() NetworkMgr:disableWifi() end
end
local api_opts = self:apiOpts()
-- Synchronous: the device is about to sleep, so the uploads must not be
-- parked on the scheduler (they would run after resume and then kill
-- wifi mid-read via on_finish).
local run = function()
BookOrbitBookSync.run{
api = api_opts, snap = snap, reason = "suspend",
interactive = false, synchronous = true, plugin = self, on_finish = on_finish,
annotation_sync = self.settings.annotation_sync,
}
end
if NetworkMgr:willRerunWhenOnline(run) then
return
end
run()
self:requestBookSnapshotSync{
snap = snap,
reason = "suspend",
label = _("Suspend sync"),
priority = SYNC_JOB_PRIORITY.lifecycle,
interactive = false,
synchronous = true,
ensure_networking = true,
on_finish = on_finish,
}
end
function BookOrbit:_onNetworkConnected()
logger.dbg("BookOrbit: onNetworkConnected")
UIManager:scheduleIn(0.5, function()
self:getProgress(false, false)
self:maybeCheckForUpdate(false)
if self:shouldSkipAutoSyncOffline("network_connected") then return end
self:requestProgressPull(false, false, "network_connected")
self:requestUpdateCheck(false, "network_connected")
end)
end
function BookOrbit:_onNetworkDisconnecting()
logger.dbg("BookOrbit: onNetworkDisconnecting")
self:updateProgress(false, false)
if self:shouldSkipAutoSyncOffline("network_disconnecting") then return end
self:requestProgressPush(false, false, "network_disconnecting", SYNC_JOB_PRIORITY.lifecycle)
end
function BookOrbit:onBookOrbitPushProgress()
self:updateProgress(true, true)
self:requestProgressPush(true, true, "manual")
end
function BookOrbit:onBookOrbitPullProgress()
self:getProgress(true, true)
self:requestProgressPull(true, true, "manual")
end
function BookOrbit:onBookOrbitSyncNow()
@@ -573,13 +1053,13 @@ function BookOrbit:onBookOrbitToggleAutoSync(toggle, from_menu)
self:registerEvents()
if self.settings.auto_sync then
self:getProgress(true, true)
self:requestProgressPull(true, true, "auto_enabled")
else
UIManager:unschedule(self.periodic_push_task)
self.periodic_push_scheduled = false
self.page_update_counter = 0
if from_menu then
self:updateProgress(true, true)
self:requestProgressPush(true, true, "auto_disabled")
end
end