Files
neon 80244ea4e2 feat(koreader): rebuild the plugin interface and fix four-way annotation sync
Squash of feat/koreader-widget-board. KOReader plugin 2.0.0 gets the widget-board dashboard, redesigned
book details, menus, lists and dialogs, Decor page and reading companions. Highlights, notes and bookmarks
now stay in step across web, Kobo, KOReader and the phones (defects D-00 to D-20, migration 0121), and
reading progress and narration hand off cleanly between devices.
2026-10-20 16:05:27 -06:00

234 lines
10 KiB
Lua

--[[--
Downloading many books at once, in one sheet: what to download and how, then
the run's progress, then what came of it.
Plan: the books (selected, this page or the whole list), where they go, which
format wins, what happens to files already there, and how read-aloud EPUBs
arrive; one Download button. Progress: which book, how far, the counts so
far, and Stop after this book. Summary: the counts that are not zero, what
needs attention, Retry failed and View on device.
]]
local UIManager = require("ui/uimanager")
local T = require("ffi/util").template
local _ = require("gettext")
local BoardMetrics = require("bookorbit_board_metrics")
local BoardUI = require("bookorbit_board_ui")
local DialogUI = require("bookorbit_dialog_ui")
local MenuUI = require("bookorbit_menu_ui")
local INK = BoardMetrics.INK
local px = BoardMetrics.px
local text, vstack, hstack, space, flex = BoardUI.text, BoardUI.vstack, BoardUI.hstack, BoardUI.space, BoardUI.flex
local BulkSheet = {}
local function wOf(widget) return widget:getSize().w end
-- Numbers side by side, each over its label.
local function stats(w, items)
local n = #items
local gap = px(8)
local cw = math.floor((w - gap * (n - 1)) / n)
local cells = {}
for _i, it in ipairs(items) do
table.insert(cells, vstack({ w = cw }, {
text(tostring(it.value), { s = 20, b = true, w = cw }),
text(it.label, { s = 12, col = INK.g4, w = cw }),
}))
end
return hstack({ w = w, gap = gap, valign = "top" }, cells)
end
-- ------------------------------------------------------------------ plan
local function planBody(page, w)
local catalog, plan = page.catalog, page.plan
local iw = MenuUI.rowsIn(w)
local items = { DialogUI.titleRow(w, plan.settings_only and _("Download settings") or _("Download books"), plan.sub) }
local source = plan.scopes and plan.scopes[page.scope] and plan.scopes[page.scope].source or nil
if plan.scopes and #plan.scopes > 1 then
local rows = {}
for index, scope in ipairs(plan.scopes) do
table.insert(rows, MenuUI.row(iw, { label = scope.label, sub = scope.sub, sub_data = true, radio = index == page.scope, action = { kind = "scope", index = index } }))
end
table.insert(items, DialogUI.section(w, _("Books"), MenuUI.group(w, rows)))
end
local BookPeek = require("bookorbit_book_peek")
local folder = catalog:getCurrentDownloadDir()
local how = {
MenuUI.row(iw, { icon = "folder-open", label = _("Folder"), sub = BookPeek.folderLabel(folder) or folder, sub_data = true, chevron = true, action = { kind = "folder" } }),
MenuUI.row(iw, { icon = "file-text", label = _("Format"), sub = catalog:bulkFormatPreset().label, chevron = true, action = { kind = "format" } }),
MenuUI.row(iw, { icon = "files", label = _("Files already there"), sub = catalog:bulkExistingPolicy().label, chevron = true, action = { kind = "existing" } }),
MenuUI.row(iw, { icon = "headphones", label = _("Read-aloud EPUBs"), sub = catalog:bulkDeliveryLabel(), chevron = true, action = { kind = "delivery" } }),
}
table.insert(items, DialogUI.section(w, _("How"), MenuUI.group(w, how)))
if plan.settings_only then return items end
if catalog:bulkExistingPolicy().id == "overwrite" then
table.insert(items, DialogUI.warn(w, _("Files already there are replaced"), _("Replacing a book can move its place and highlights. Each old file stays until its new copy is complete.")))
end
local skips = source and source.known_skips
if skips and (skips.on_device > 0 or skips.unsupported > 0) then
local parts = {}
if skips.on_device > 0 then table.insert(parts, T(_.ngettext("%1 is already on this device", "%1 are already on this device", skips.on_device), skips.on_device)) end
if skips.unsupported > 0 then table.insert(parts, T(_.ngettext("%1 has no format this reader opens", "%1 have no format this reader opens", skips.unsupported), skips.unsupported)) end
table.insert(items, DialogUI.note(w, "info", table.concat(parts, "; ") .. ".", { col = INK.g3 }))
end
local total = source and source.total or 0
table.insert(items, MenuUI.pill(catalog:bulkDownloadLabel(total), { icon = "download", w = w, action = { kind = "download" } }))
return items
end
-- -------------------------------------------------------------- progress
local function progressBody(page, w)
local ctx = page.ctx
local counts = ctx.counts or {}
local total = math.max(ctx.total or 0, ctx.index or 0)
local title
if (ctx.index or 0) > 0 then
title = T(_("Downloading %1 of %2"), ctx.index, total)
else
title = ctx.progress_status or _("Getting ready…")
end
local items = { DialogUI.titleRow(w, title, ctx.label, { close = false }) }
local book = ctx.progress_book
local now = vstack({ w = w }, {
book and text(book.title or _("Untitled"), { s = 15, b = true, w = w, data = true }) or false,
(ctx.index or 0) > 0 and ctx.progress_status and text(ctx.progress_status, { s = 13, col = INK.g4, w = w, data = true }) or false,
space(nil, px(8)),
BoardUI.bar(w, px(8), total > 0 and math.floor((ctx.index or 0) * 100 / total) or 0, { r = px(4), track = INK.gd }),
})
table.insert(items, now)
table.insert(items, stats(w, {
{ value = counts.downloaded or 0, label = _("Downloaded") },
{ value = (counts.skipped_on_device or 0) + (counts.skipped_present or 0), label = _("Already here") },
{ value = (counts.skipped_existing or 0) + (counts.skipped_unsupported or 0), label = _("Skipped") },
{ value = counts.failed or 0, label = _("Failed") },
}))
table.insert(items, MenuUI.pill(ctx.cancel_requested and _("Stopping after this book…") or _("Stop after this book"),
{ kind = "outline", w = w, action = ctx.cancel_requested and { kind = "noop" } or { kind = "stop" } }))
return items
end
-- --------------------------------------------------------------- summary
local function summaryBody(page, w)
local s = page.summary
local iw = MenuUI.rowsIn(w)
local items = {}
local tw = w - px(24) - px(12)
table.insert(items, hstack({ w = w, gap = px(12), valign = "top" }, {
s.stopped and BoardUI.circle(px(24), { bg = INK.ge }, BoardUI.icon("x", px(15), { sw = 3 })) or MenuUI.check(),
vstack({ w = tw }, {
text(s.title, { s = 18, b = true, w = tw }),
s.sub and text(s.sub, { s = 13, col = INK.g4, w = tw, data = true }) or false,
}),
}))
local rows = {}
for _i, r in ipairs(s.rows) do
local v = text(tostring(r.value), { s = 14, b = true })
local row = hstack({ w = iw, h = px(38) }, { text(r.label, { s = 14, col = INK.g3, w = iw - wOf(v) - px(16) }), flex(px(16)), v })
row._no_icon = true
table.insert(rows, row)
end
if #rows > 0 then table.insert(items, MenuUI.group(w, rows, { inset = 0 })) end
for _i, note in ipairs(s.notes) do
table.insert(items, DialogUI.note(w, "info", note, { col = INK.g3, lines = 3 }))
end
if #s.failed_titles > 0 then
local list = table.concat(s.failed_titles, ", ")
if s.more_failed > 0 then list = T(_("%1, and %2 more"), list, s.more_failed) end
table.insert(items, DialogUI.warn(w, _("These did not download"), list))
end
if s.can_retry then
table.insert(items, MenuUI.pill(_("Retry failed"), { icon = "refresh-cw", w = w, action = { kind = "retry" } }))
end
table.insert(items, DialogUI.pillRow(w, {
{ label = _("View on device"), kind = "outline", action = { kind = "on-device" } },
{ label = _("Done"), kind = s.can_retry and "outline" or nil, action = { kind = "close" } },
}))
return items
end
local function build(page, sw, sh)
local body = page.state == "progress" and progressBody or page.state == "summary" and summaryBody or planBody
return DialogUI.sheet(page, sw, sh, function(w) return body(page, w) end, { pop_w = 480 })
end
local function onAction(page, action)
local catalog = page.catalog
local kind = action.kind
local refresh = function() if not page.closed then page:refresh() end end
if kind == "scope" then
page.scope = action.index
refresh()
elseif kind == "folder" then
catalog:chooseDownloadDir(refresh)
elseif kind == "format" then
catalog:showBulkFormatPresetDialog(refresh)
elseif kind == "existing" then
catalog:showBulkExistingPolicyDialog(refresh)
elseif kind == "delivery" then
catalog:showDeliveryPreferenceDialog(refresh)
elseif kind == "download" then
local scope = page.plan.scopes[page.scope]
page:close()
UIManager:tickAfterNext(function() page.plan.on_download(scope.source) end)
elseif kind == "stop" then
catalog:bulkRequestStop(page.ctx)
elseif kind == "retry" then
page:close()
UIManager:tickAfterNext(page.summary.on_retry)
elseif kind == "on-device" then
page:close()
UIManager:tickAfterNext(function() catalog:loadOnDevice() end)
end
return true
end
-- plan: sub (the list's name), scopes ({ label, sub, source }), scope (the
-- one chosen at first), on_download(source), settings_only.
function BulkSheet.openPlan(catalog, plan)
return DialogUI.open{
name = "bookorbit_bulk_sheet",
build = build,
on_action = onAction,
catalog = catalog,
plan = plan,
scope = plan.scope or 1,
state = "plan",
}
end
-- The run's sheet: it follows ctx (index, total, counts, progress_status,
-- progress_book, cancel_requested) until it is given the summary.
function BulkSheet.openProgress(catalog, ctx)
local page = DialogUI.open{
name = "bookorbit_bulk_sheet",
build = build,
on_action = onAction,
catalog = catalog,
ctx = ctx,
state = "progress",
dismiss_outside = false,
}
-- Back must not close the only way to stop a run.
page.on_back = function() return true end
function page:update()
if self.closed then return end
self:refresh()
end
-- summary: title, sub, rows ({ label, value }), notes, failed_titles,
-- more_failed, stopped, can_retry, on_retry.
function page:finished(summary)
if self.closed then return end
self.state, self.summary = "summary", summary
self.dismiss_outside = true
self.on_back = nil
self:refresh()
end
return page
end
return BulkSheet