mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
bf4102e8df
Add support for user-scoped personal notes (reviews) and allow clearing book ratings to null, providing full sync capabilities for these actions in the KOReader plugin. Modularize the KOReader plugin codebase into dedicated mixin files to improve maintainability and expand the catalog detail view with related sections (series, author, similar books). Closes #552
809 lines
29 KiB
Lua
809 lines
29 KiB
Lua
--[[--
|
|
Full-library sweep: pushes reading statistics, annotations, book states and
|
|
bulk progress for all matched books to BookOrbit. Manual-only; the open book
|
|
is covered day to day by the per-book snapshot sync.
|
|
|
|
The sweep is a module-level singleton driven by small steps chained through
|
|
UIManager:scheduleIn so the UI stays responsive. It holds no references to a
|
|
ReaderUI/FileManager plugin instance and therefore survives document close.
|
|
Per-book sync state advances only after the server acknowledges an upload, so
|
|
an interrupted sweep resumes exactly where it left off.
|
|
]]
|
|
|
|
local DocSettings = require("docsettings")
|
|
local InfoMessage = require("ui/widget/infomessage")
|
|
local Notification = require("ui/widget/notification")
|
|
local ReadHistory = require("readhistory")
|
|
local UIManager = require("ui/uimanager")
|
|
local logger = require("logger")
|
|
local lfs = require("libs/libkoreader-lfs")
|
|
local util = require("util")
|
|
local T = require("ffi/util").template
|
|
local _ = require("gettext")
|
|
|
|
local BookOrbitAnnotations = require("bookorbit_annotations")
|
|
local BookOrbitApi = require("bookorbit_api")
|
|
local BookOrbitSidecar = require("bookorbit_sidecar")
|
|
local BookOrbitState = require("bookorbit_state")
|
|
local BookOrbitStatsReader = require("bookorbit_stats_reader")
|
|
|
|
local STEP_DELAY = 0.1
|
|
local MATCH_BATCH = 500
|
|
local STATS_BATCH = 500
|
|
local ANN_BATCH_BOOKS = 20
|
|
local ANN_BATCH_TOTAL = 50
|
|
local STATES_BATCH = 200
|
|
local PROGRESS_BATCH = 100
|
|
|
|
local BookOrbitSweep = {
|
|
running = false,
|
|
}
|
|
|
|
local function titleFromHistoryItem(item)
|
|
local name = item.text
|
|
if (not name or name == "") and item.file then
|
|
name = item.file:gsub(".*/", "")
|
|
end
|
|
if not name or name == "" then return nil end
|
|
local title = name:gsub("%.[^%.]+$", "")
|
|
if title == "" then return name end
|
|
return title
|
|
end
|
|
|
|
function BookOrbitSweep.isRunning()
|
|
return BookOrbitSweep.running
|
|
end
|
|
|
|
function BookOrbitSweep.syncStatus()
|
|
local state = BookOrbitState.open()
|
|
local matched, unmatched = 0, 0
|
|
for _ in pairs(state.books) do matched = matched + 1 end
|
|
for _ in pairs(state.unmatched) do unmatched = unmatched + 1 end
|
|
return {
|
|
lastSweepAt = state.global.lastSweepAt or 0,
|
|
matched = matched,
|
|
unmatched = unmatched,
|
|
}
|
|
end
|
|
|
|
local function isAuthError(err)
|
|
return err == 401 or err == 403
|
|
end
|
|
|
|
local PROGRESS_THROTTLE = 2
|
|
|
|
-- Interactive-only progress toast for the sweep, reused across phases. Identical
|
|
-- text never redraws, and changing text redraws at most every PROGRESS_THROTTLE
|
|
-- seconds, so a long first run stays legible without flooding the e-ink screen.
|
|
local function setProgress(ctx, text, force)
|
|
if not ctx.interactive then return end
|
|
if not force then
|
|
if text == ctx.progress_shown_text then return end
|
|
if ctx.progress_msg and os.time() - (ctx.progress_shown_at or 0) < PROGRESS_THROTTLE then
|
|
return
|
|
end
|
|
end
|
|
ctx.progress_shown_text = text
|
|
ctx.progress_shown_at = os.time()
|
|
if ctx.progress_msg then UIManager:close(ctx.progress_msg) end
|
|
ctx.progress_msg = InfoMessage:new{ text = text }
|
|
UIManager:show(ctx.progress_msg)
|
|
end
|
|
|
|
local function finish(ctx, err)
|
|
BookOrbitSweep.running = false
|
|
if ctx.progress_msg then
|
|
UIManager:close(ctx.progress_msg)
|
|
ctx.progress_msg = nil
|
|
end
|
|
ctx.state:flush()
|
|
if ctx.on_finish then
|
|
pcall(ctx.on_finish, err)
|
|
end
|
|
|
|
if err == "auth" then
|
|
if ctx.interactive then
|
|
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync: login failed. Check your credentials."), timeout = 4 })
|
|
else
|
|
Notification:notify(_("BookOrbit sync: login failed"))
|
|
end
|
|
return
|
|
end
|
|
|
|
if err == "network" then
|
|
if ctx.interactive then
|
|
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync: server not reachable."), timeout = 4 })
|
|
end
|
|
logger.dbg("BookOrbit: sweep aborted, server not reachable")
|
|
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.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)))
|
|
end
|
|
|
|
local function step(ctx, fn)
|
|
UIManager:scheduleIn(STEP_DELAY, function()
|
|
local ok, err = pcall(fn, ctx)
|
|
if not ok then
|
|
logger.err("BookOrbit: sweep step failed:", err)
|
|
ctx.had_errors = true
|
|
finish(ctx)
|
|
end
|
|
end)
|
|
end
|
|
|
|
-- Phase 1: enumerate candidate books from the stats database (primary source,
|
|
-- has md5 directly) and ReadHistory (provides file paths for sidecar data).
|
|
local function stepEnumerate(ctx)
|
|
ctx.candidates = {}
|
|
|
|
local stats_books = BookOrbitStatsReader.getBooks()
|
|
for _, entry in ipairs(stats_books or {}) do
|
|
ctx.candidates[entry.md5] = {
|
|
stat_ids = entry.ids,
|
|
last_open = entry.last_open,
|
|
title = entry.title,
|
|
authors = entry.authors,
|
|
source = "statistics",
|
|
metadata_ambiguous = entry.metadata_ambiguous,
|
|
stats_metadata_ambiguous = entry.metadata_ambiguous,
|
|
}
|
|
end
|
|
|
|
for _, item in ipairs(ReadHistory.hist or {}) do
|
|
local file = item.file
|
|
if file then
|
|
local file_exists = lfs.attributes(file, "mode") == "file"
|
|
local md5 = ctx.state.files[file]
|
|
if not md5 and file_exists and DocSettings:hasSidecarFile(file) then
|
|
local doc_settings = DocSettings:open(file)
|
|
md5 = doc_settings:readSetting("partial_md5_checksum")
|
|
if not md5 then
|
|
local ok, computed = pcall(util.partialMD5, file)
|
|
if ok then md5 = computed end
|
|
end
|
|
ctx.state:rememberFile(file, md5)
|
|
end
|
|
if md5 then
|
|
local cand = ctx.candidates[md5] or {}
|
|
if file_exists then
|
|
cand.file = file
|
|
cand.source = "file"
|
|
if cand.metadata_ambiguous then
|
|
cand.title = titleFromHistoryItem(item)
|
|
cand.authors = nil
|
|
elseif not cand.title then
|
|
cand.title = titleFromHistoryItem(item)
|
|
end
|
|
cand.metadata_ambiguous = false
|
|
end
|
|
if (item.time or 0) > (cand.last_open or 0) then
|
|
cand.last_open = item.time
|
|
end
|
|
ctx.candidates[md5] = cand
|
|
local book = ctx.state:getBook(md5)
|
|
if book and file_exists and not book.file then
|
|
book.file = file
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
step(ctx, ctx.steps.match)
|
|
end
|
|
|
|
-- Phase 2: ask the server which hashes it knows. Only never-seen hashes,
|
|
-- unmatched hashes with new activity, and (when the library version token
|
|
-- changed or on manual sync) all known local matches are checked.
|
|
local function stepMatch(ctx)
|
|
local to_check = {}
|
|
local queued = {}
|
|
local function queue(md5)
|
|
if not queued[md5] then
|
|
queued[md5] = true
|
|
table.insert(to_check, md5)
|
|
end
|
|
end
|
|
|
|
for md5, cand in pairs(ctx.candidates) do
|
|
if ctx.full_recheck then
|
|
queue(md5)
|
|
elseif not ctx.state:getBook(md5) then
|
|
local last_check = ctx.state.unmatched[md5]
|
|
if not last_check then
|
|
queue(md5)
|
|
elseif (cand.last_open or 0) > last_check then
|
|
queue(md5)
|
|
end
|
|
end
|
|
end
|
|
|
|
if ctx.full_recheck then
|
|
for md5 in pairs(ctx.state.books) do
|
|
queue(md5)
|
|
end
|
|
for md5 in pairs(ctx.state.unmatched) do
|
|
queue(md5)
|
|
end
|
|
end
|
|
|
|
ctx.match_queue = {}
|
|
for i = 1, #to_check, MATCH_BATCH do
|
|
local batch = {}
|
|
for j = i, math.min(i + MATCH_BATCH - 1, #to_check) do
|
|
table.insert(batch, to_check[j])
|
|
end
|
|
table.insert(ctx.match_queue, batch)
|
|
end
|
|
|
|
step(ctx, ctx.steps.matchNext)
|
|
end
|
|
|
|
local function stepMatchNext(ctx)
|
|
local batch = table.remove(ctx.match_queue, 1)
|
|
if not batch then
|
|
for md5 in pairs(ctx.candidates) do
|
|
if ctx.state:getBook(md5) then
|
|
ctx.counts.books_matched = ctx.counts.books_matched + 1
|
|
end
|
|
end
|
|
ctx.state:flush()
|
|
ctx.stats_queue = {}
|
|
for md5, cand in pairs(ctx.candidates) do
|
|
if cand.stat_ids and not cand.stats_metadata_ambiguous and ctx.state:getBook(md5) then
|
|
table.insert(ctx.stats_queue, { md5 = md5, ids = cand.stat_ids })
|
|
end
|
|
end
|
|
ctx.stats_total = #ctx.stats_queue
|
|
step(ctx, ctx.steps.statsNext)
|
|
return
|
|
end
|
|
|
|
setProgress(ctx, _("BookOrbit sync: matching books..."))
|
|
local body, err = ctx.client:matchCheck(batch, ctx.candidates)
|
|
if not body then
|
|
if isAuthError(err) then return finish(ctx, "auth") end
|
|
return finish(ctx, "network")
|
|
end
|
|
|
|
local matched = {}
|
|
for _, match in ipairs(body.matches or {}) do
|
|
matched[match.hash] = true
|
|
local cand = ctx.candidates[match.hash]
|
|
ctx.state:setMatched(match.hash, match.bookFileId, match.bookId, cand and cand.file or nil)
|
|
end
|
|
for _, md5 in ipairs(batch) do
|
|
if not matched[md5] then
|
|
ctx.state:setUnmatched(md5)
|
|
end
|
|
end
|
|
if body.libraryVersion then
|
|
ctx.server_library_version = body.libraryVersion
|
|
end
|
|
|
|
step(ctx, ctx.steps.matchNext)
|
|
end
|
|
|
|
-- Phase 3: upload page stat events per matched book, batched and watermarked.
|
|
local function stepStatsNext(ctx)
|
|
local item = ctx.current_stats or table.remove(ctx.stats_queue, 1)
|
|
ctx.current_stats = nil
|
|
if not item then
|
|
step(ctx, ctx.steps.sidecars)
|
|
return
|
|
end
|
|
|
|
if ctx.stats_total and ctx.stats_total > 0 then
|
|
setProgress(ctx, T(_("BookOrbit sync: uploading reading data (%1/%2)"),
|
|
ctx.stats_total - #ctx.stats_queue, ctx.stats_total))
|
|
end
|
|
|
|
local book = ctx.state:getBook(item.md5)
|
|
if not book then
|
|
step(ctx, ctx.steps.statsNext)
|
|
return
|
|
end
|
|
|
|
local watermark = book.statsWatermark or 0
|
|
local events = BookOrbitStatsReader.getEventsAfter(item.ids, watermark, STATS_BATCH)
|
|
if not events or #events == 0 then
|
|
step(ctx, ctx.steps.statsNext)
|
|
return
|
|
end
|
|
|
|
local body, err = ctx.client:uploadPageStats({ { hash = item.md5, events = events } })
|
|
if not body then
|
|
if isAuthError(err) then return finish(ctx, "auth") end
|
|
logger.dbg("BookOrbit: page stats upload failed for", item.md5, err)
|
|
ctx.had_errors = true
|
|
step(ctx, ctx.steps.statsNext)
|
|
return
|
|
end
|
|
|
|
for _, unmatched in ipairs(body.unmatched or {}) do
|
|
if unmatched == item.md5 then
|
|
ctx.state:setUnmatched(item.md5)
|
|
step(ctx, ctx.steps.statsNext)
|
|
return
|
|
end
|
|
end
|
|
|
|
local more = BookOrbitState.applyStatsAck(book, events, body, item.md5, STATS_BATCH, watermark)
|
|
ctx.counts.page_stats = ctx.counts.page_stats + #events
|
|
if more then
|
|
ctx.current_stats = item
|
|
end
|
|
|
|
step(ctx, ctx.steps.statsNext)
|
|
end
|
|
|
|
-- Phase 4: read sidecars of matched books with known paths, mtime-gated, and
|
|
-- queue annotation/state/progress deltas.
|
|
local function stepSidecars(ctx)
|
|
ctx.pending_books = {}
|
|
ctx.ann_queue = {}
|
|
ctx.ann_chunks = {}
|
|
ctx.states_items = {}
|
|
ctx.progress_items = {}
|
|
|
|
for md5, book in pairs(ctx.state.books) do
|
|
if book.file then
|
|
local mtime = BookOrbitSidecar.sidecarMtime(book.file)
|
|
if mtime then
|
|
local sidecar_changed = mtime ~= book.sidecarMtime
|
|
local state_unknown = book.ratingSyncedKnown ~= true or book.reviewSyncedKnown ~= true
|
|
local extract = nil
|
|
if sidecar_changed or state_unknown then
|
|
extract = BookOrbitSidecar.extract(book.file)
|
|
end
|
|
|
|
if extract then
|
|
local pending = {
|
|
md5 = md5,
|
|
mtime = mtime,
|
|
ann_count = sidecar_changed and extract.annotations_count or (book.annCount or 0),
|
|
ann_max_datetime = sidecar_changed and extract.annotations_max_datetime or nil,
|
|
ann_done = not sidecar_changed,
|
|
failed = false,
|
|
need_state = false,
|
|
need_progress = false,
|
|
}
|
|
|
|
if sidecar_changed then
|
|
-- Every changed sidecar goes through the exchange, even with
|
|
-- no new local highlights: the full key set is what lets the
|
|
-- server detect on-device deletions.
|
|
table.insert(ctx.ann_queue, {
|
|
md5 = md5,
|
|
file = book.file,
|
|
annotations = extract.annotations,
|
|
ann_max_datetime = extract.annotations_max_datetime,
|
|
})
|
|
end
|
|
|
|
local state_payload = BookOrbitSidecar.buildStatePayload(md5, book, {
|
|
status = extract.status,
|
|
status_modified = extract.status_modified,
|
|
rating = extract.rating,
|
|
review_note = extract.review_note,
|
|
}, true)
|
|
if state_payload then
|
|
pending.need_state = true
|
|
pending.status_modified = state_payload.statusModified
|
|
pending.state_payload = state_payload
|
|
pending.state_summary = {
|
|
rating = extract.rating,
|
|
review_note = extract.review_note,
|
|
}
|
|
table.insert(ctx.states_items, state_payload)
|
|
end
|
|
|
|
if sidecar_changed and extract.percent_finished then
|
|
local pushed = book.progressPushedPct or -1
|
|
if math.abs(extract.percent_finished - pushed) > 0.001 then
|
|
pending.need_progress = true
|
|
pending.percentage = extract.percent_finished
|
|
local cand = ctx.candidates[md5]
|
|
table.insert(ctx.progress_items, {
|
|
hash = md5,
|
|
percentage = extract.percent_finished,
|
|
progress = extract.last_position,
|
|
timestamp = cand and cand.last_open or nil,
|
|
})
|
|
end
|
|
end
|
|
|
|
ctx.pending_books[md5] = pending
|
|
elseif not sidecar_changed then
|
|
local state_payload = { hash = md5 }
|
|
ctx.pending_books[md5] = {
|
|
md5 = md5,
|
|
mtime = mtime,
|
|
ann_count = book.annCount or 0,
|
|
ann_done = true,
|
|
failed = false,
|
|
need_state = true,
|
|
need_progress = false,
|
|
state_payload = state_payload,
|
|
state_summary = {},
|
|
}
|
|
table.insert(ctx.states_items, state_payload)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
ctx.ann_total = #ctx.ann_queue
|
|
step(ctx, ctx.steps.annotationsNext)
|
|
end
|
|
|
|
local buildLegacyAnnotationChunks
|
|
|
|
local function currentlyOpenFile()
|
|
local ok, ReaderUI = pcall(require, "apps/reader/readerui")
|
|
if ok and ReaderUI and ReaderUI.instance and ReaderUI.instance.document then
|
|
return ReaderUI.instance.document.file
|
|
end
|
|
end
|
|
|
|
-- Phase 5: per-book annotation exchange. Uploads the local delta, reports the
|
|
-- key set for deletion detection and applies server-side changes into the
|
|
-- sidecar of closed books. Falls back to the legacy one-way upload when the
|
|
-- server has no exchange endpoint.
|
|
local function stepAnnotationsNext(ctx)
|
|
if ctx.use_legacy_annotations then
|
|
return step(ctx, ctx.steps.annotationsLegacyNext)
|
|
end
|
|
local entry = table.remove(ctx.ann_queue, 1)
|
|
if not entry then
|
|
step(ctx, ctx.steps.statesNext)
|
|
return
|
|
end
|
|
|
|
if ctx.ann_total and ctx.ann_total > 0 then
|
|
setProgress(ctx, T(_("BookOrbit sync: syncing highlights (%1/%2)"),
|
|
ctx.ann_total - #ctx.ann_queue, ctx.ann_total))
|
|
end
|
|
|
|
local pending = ctx.pending_books[entry.md5]
|
|
local apply_mode = "skip"
|
|
if ctx.annotation_sync and entry.file ~= currentlyOpenFile() then
|
|
apply_mode = "sidecar"
|
|
end
|
|
|
|
local result, err = BookOrbitAnnotations.exchangeBook({
|
|
client = ctx.client,
|
|
state = ctx.state,
|
|
digest = entry.md5,
|
|
annotations = entry.annotations,
|
|
ann_max_datetime = entry.ann_max_datetime,
|
|
apply_mode = apply_mode,
|
|
file = entry.file,
|
|
})
|
|
if not result then
|
|
if isAuthError(err) or err == "auth" then return finish(ctx, "auth") end
|
|
if err == "unsupported_server" then
|
|
-- Re-queue everything, including this entry, for the legacy path.
|
|
table.insert(ctx.ann_queue, 1, entry)
|
|
ctx.use_legacy_annotations = true
|
|
buildLegacyAnnotationChunks(ctx)
|
|
return step(ctx, ctx.steps.annotationsLegacyNext)
|
|
end
|
|
ctx.had_errors = true
|
|
if pending then pending.failed = true end
|
|
logger.dbg("BookOrbit: annotation exchange failed for", entry.md5, err)
|
|
return step(ctx, ctx.steps.annotationsNext)
|
|
end
|
|
|
|
if result.had_errors then
|
|
ctx.had_errors = true
|
|
if pending then pending.failed = true end
|
|
elseif pending then
|
|
pending.ann_done = true
|
|
end
|
|
ctx.counts.annotations = ctx.counts.annotations + result.uploaded
|
|
ctx.counts.ann_applied = (ctx.counts.ann_applied or 0) + result.applied
|
|
return step(ctx, ctx.steps.annotationsNext)
|
|
end
|
|
|
|
-- Legacy phase 5: one-way chunk upload, packing several books per request
|
|
-- while respecting both the per-request book and annotation caps.
|
|
buildLegacyAnnotationChunks = function(ctx)
|
|
local device_now = os.date("%Y-%m-%d %H:%M:%S")
|
|
for _, entry in ipairs(ctx.ann_queue) do
|
|
local book = ctx.state:getBook(entry.md5)
|
|
local ann_watermark = book and BookOrbitAnnotations.readWatermark(book, device_now) or ""
|
|
local delta = {}
|
|
for _, annotation in ipairs(entry.annotations) do
|
|
local effective = annotation.datetimeUpdated or annotation.datetime
|
|
if effective > ann_watermark then
|
|
table.insert(delta, annotation)
|
|
end
|
|
end
|
|
local pending = ctx.pending_books[entry.md5]
|
|
if pending then pending.ann_chunks_left = 0 end
|
|
for i = 1, #delta, ANN_BATCH_TOTAL do
|
|
local chunk = {}
|
|
for j = i, math.min(i + ANN_BATCH_TOTAL - 1, #delta) do
|
|
table.insert(chunk, delta[j])
|
|
end
|
|
table.insert(ctx.ann_chunks, { md5 = entry.md5, annotations = chunk })
|
|
if pending then pending.ann_chunks_left = pending.ann_chunks_left + 1 end
|
|
end
|
|
end
|
|
ctx.ann_queue = {}
|
|
end
|
|
|
|
local function stepAnnotationsLegacyNext(ctx)
|
|
if #ctx.ann_chunks == 0 then
|
|
step(ctx, ctx.steps.statesNext)
|
|
return
|
|
end
|
|
|
|
local books = {}
|
|
local total = 0
|
|
while #ctx.ann_chunks > 0 and #books < ANN_BATCH_BOOKS do
|
|
local next_chunk = ctx.ann_chunks[1]
|
|
if total + #next_chunk.annotations > ANN_BATCH_TOTAL then break end
|
|
table.remove(ctx.ann_chunks, 1)
|
|
table.insert(books, { hash = next_chunk.md5, annotations = next_chunk.annotations })
|
|
total = total + #next_chunk.annotations
|
|
end
|
|
|
|
local body, err = ctx.client:uploadAnnotations(books)
|
|
if not body then
|
|
if isAuthError(err) then return finish(ctx, "auth") end
|
|
logger.dbg("BookOrbit: annotations upload failed:", err)
|
|
ctx.had_errors = true
|
|
for _, book in ipairs(books) do
|
|
local pending = ctx.pending_books[book.hash]
|
|
if pending then pending.failed = true end
|
|
end
|
|
step(ctx, ctx.steps.annotationsLegacyNext)
|
|
return
|
|
end
|
|
|
|
local unmatched = {}
|
|
for _, hash in ipairs(body.unmatched or {}) do
|
|
unmatched[hash] = true
|
|
end
|
|
|
|
for _, book in ipairs(books) do
|
|
local pending = ctx.pending_books[book.hash]
|
|
if unmatched[book.hash] then
|
|
ctx.state:setUnmatched(book.hash)
|
|
if pending then pending.failed = true end
|
|
elseif pending then
|
|
pending.ann_chunks_left = pending.ann_chunks_left - 1
|
|
ctx.counts.annotations = ctx.counts.annotations + #book.annotations
|
|
end
|
|
end
|
|
|
|
step(ctx, ctx.steps.annotationsLegacyNext)
|
|
end
|
|
|
|
-- Phase 6: book status + rating, batched.
|
|
local function stepStatesNext(ctx)
|
|
if #ctx.states_items == 0 then
|
|
step(ctx, ctx.steps.progressNext)
|
|
return
|
|
end
|
|
|
|
local batch = {}
|
|
while #ctx.states_items > 0 and #batch < STATES_BATCH do
|
|
table.insert(batch, table.remove(ctx.states_items, 1))
|
|
end
|
|
|
|
local body, err = ctx.client:uploadBookStates(batch)
|
|
if not body then
|
|
if isAuthError(err) then return finish(ctx, "auth") end
|
|
logger.dbg("BookOrbit: book states upload failed:", err)
|
|
ctx.had_errors = true
|
|
for _, item in ipairs(batch) do
|
|
local pending = ctx.pending_books[item.hash]
|
|
if pending then pending.failed = true end
|
|
end
|
|
step(ctx, ctx.steps.statesNext)
|
|
return
|
|
end
|
|
|
|
local unmatched = {}
|
|
for _, hash in ipairs(body.unmatched or {}) do
|
|
unmatched[hash] = true
|
|
end
|
|
local results = {}
|
|
for _, result in ipairs(body.results or {}) do
|
|
results[result.hash] = result
|
|
end
|
|
|
|
for _, item in ipairs(batch) do
|
|
local pending = ctx.pending_books[item.hash]
|
|
if unmatched[item.hash] then
|
|
ctx.state:setUnmatched(item.hash)
|
|
if pending then pending.failed = true end
|
|
elseif pending then
|
|
-- A server-kept tie still counts as synced: the device value was considered.
|
|
pending.state_acked = true
|
|
local book = ctx.state:getBook(item.hash)
|
|
local server_state = BookOrbitSidecar.stateFromServerResult(results[item.hash])
|
|
if book and server_state then
|
|
if book.file and book.file ~= currentlyOpenFile() then
|
|
local touched = BookOrbitSidecar.applyServerStateSidecar(book.file, server_state)
|
|
if touched then
|
|
pending.mtime = BookOrbitSidecar.sidecarMtime(book.file) or pending.mtime
|
|
end
|
|
end
|
|
BookOrbitSidecar.rememberServerState(book, server_state)
|
|
pending.state_used_server = true
|
|
end
|
|
end
|
|
end
|
|
|
|
step(ctx, ctx.steps.statesNext)
|
|
end
|
|
|
|
-- Phase 7: bulk progress for matched books whose sidecar percent changed.
|
|
local function stepProgressNext(ctx)
|
|
if #ctx.progress_items == 0 then
|
|
step(ctx, ctx.steps.done)
|
|
return
|
|
end
|
|
|
|
local batch = {}
|
|
while #ctx.progress_items > 0 and #batch < PROGRESS_BATCH do
|
|
table.insert(batch, table.remove(ctx.progress_items, 1))
|
|
end
|
|
|
|
local body, err = ctx.client:bulkProgress(batch)
|
|
if not body then
|
|
if isAuthError(err) then return finish(ctx, "auth") end
|
|
logger.dbg("BookOrbit: bulk progress upload failed:", err)
|
|
ctx.had_errors = true
|
|
for _, item in ipairs(batch) do
|
|
local pending = ctx.pending_books[item.hash]
|
|
if pending then pending.failed = true end
|
|
end
|
|
step(ctx, ctx.steps.progressNext)
|
|
return
|
|
end
|
|
|
|
local unmatched = {}
|
|
for _, hash in ipairs(body.unmatched or {}) do
|
|
unmatched[hash] = true
|
|
end
|
|
|
|
for _, item in ipairs(batch) do
|
|
local pending = ctx.pending_books[item.hash]
|
|
if unmatched[item.hash] then
|
|
ctx.state:setUnmatched(item.hash)
|
|
if pending then pending.failed = true end
|
|
elseif pending then
|
|
pending.progress_acked = true
|
|
end
|
|
end
|
|
|
|
step(ctx, ctx.steps.progressNext)
|
|
end
|
|
|
|
-- Phase 8: commit per-book sidecar watermarks for fully acked books, record
|
|
-- the sweep server-side and store the fresh library version token.
|
|
local function stepDone(ctx)
|
|
local device_now = os.date("%Y-%m-%d %H:%M:%S")
|
|
for md5, pending in pairs(ctx.pending_books) do
|
|
local book = ctx.state:getBook(md5)
|
|
if book and not pending.failed then
|
|
local ann_done = pending.ann_done or (pending.ann_chunks_left ~= nil and pending.ann_chunks_left <= 0)
|
|
local state_done = not pending.need_state or pending.state_acked
|
|
local progress_done = not pending.need_progress or pending.progress_acked
|
|
if ann_done and state_done and progress_done then
|
|
book.sidecarMtime = pending.mtime
|
|
book.annCount = pending.ann_count
|
|
BookOrbitAnnotations.advanceWatermark(book, pending.ann_max_datetime, device_now)
|
|
if state_done and pending.need_state then
|
|
book.statusSyncedModified = pending.status_modified or book.statusSyncedModified
|
|
if not pending.state_used_server then
|
|
BookOrbitSidecar.rememberUploadedState(book, pending.state_summary or {}, pending.state_payload)
|
|
end
|
|
end
|
|
if progress_done and pending.need_progress then
|
|
book.progressPushedPct = pending.percentage
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local body = ctx.client:sweepComplete(ctx.counts)
|
|
if body and body.libraryVersion then
|
|
ctx.server_library_version = body.libraryVersion
|
|
end
|
|
|
|
if ctx.server_library_version then
|
|
local known = ctx.state.global.libraryVersion
|
|
if ctx.full_recheck or known == nil then
|
|
ctx.state.global.needsFullRecheck = false
|
|
elseif ctx.server_library_version ~= known then
|
|
-- The library changed; the next sweep rechecks local hash mappings.
|
|
ctx.state.global.needsFullRecheck = true
|
|
end
|
|
ctx.state.global.libraryVersion = ctx.server_library_version
|
|
end
|
|
|
|
ctx.state.global.lastSweepAt = os.time()
|
|
finish(ctx)
|
|
end
|
|
|
|
function BookOrbitSweep.run(opts)
|
|
if BookOrbitSweep.running then
|
|
if opts.interactive then
|
|
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync is already running."), timeout = 2 })
|
|
end
|
|
return
|
|
end
|
|
|
|
local BookOrbitBookSync = require("bookorbit_book_sync")
|
|
if BookOrbitBookSync.isRunning() then
|
|
if opts.interactive then
|
|
UIManager:show(InfoMessage:new{ text = _("BookOrbit is syncing the current book, try again shortly."), timeout = 2 })
|
|
end
|
|
return
|
|
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
|
|
end
|
|
|
|
BookOrbitSweep.running = true
|
|
|
|
-- Flush the open book (sidecar + statistics DB) so a mid-session sweep
|
|
-- sees current annotations, summary and reading time.
|
|
local rdr = require("apps/reader/readerui").instance
|
|
if rdr then
|
|
pcall(rdr.saveSettings, rdr)
|
|
end
|
|
|
|
local state = BookOrbitState.open()
|
|
local ctx = {
|
|
client = client,
|
|
state = state,
|
|
interactive = opts.interactive or false,
|
|
annotation_sync = opts.annotation_sync ~= false,
|
|
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 },
|
|
had_errors = false,
|
|
}
|
|
ctx.steps = {
|
|
enumerate = stepEnumerate,
|
|
match = stepMatch,
|
|
matchNext = stepMatchNext,
|
|
statsNext = stepStatsNext,
|
|
sidecars = stepSidecars,
|
|
annotationsNext = stepAnnotationsNext,
|
|
annotationsLegacyNext = stepAnnotationsLegacyNext,
|
|
statesNext = stepStatesNext,
|
|
progressNext = stepProgressNext,
|
|
done = stepDone,
|
|
}
|
|
|
|
setProgress(ctx, _("Syncing to BookOrbit. This may take a while on first run."), true)
|
|
logger.info("BookOrbit: sweep started, interactive:", ctx.interactive)
|
|
|
|
step(ctx, ctx.steps.enumerate)
|
|
end
|
|
|
|
return BookOrbitSweep
|