mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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."),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user