forked from akai/readest
ed8956e9ed
* feat(koplugin/library): data layer + busted harness + design doc - LibraryStore: per-user SQLite index merging cloud + local books by partial-md5 hash. listBooks/listBookshelfBooks/listBookshelfGroups, upsertBook with cloud_present/local_present OR-merge + _force/clear sentinels, parseSyncRow, getChangedBooks for tombstone push. - EXTS table mirroring web's document.ts. - busted harness with KOReader stubs (G_reader_settings, DataStorage, lua-ljsqlite3 against :memory:); spec_helper, exts_spec, smoke_spec, librarystore_spec covering schema, sort, group nesting, dedupe. - Library design doc + spec README. - pnpm test:lua wired through root + app package.json; lint-koplugin recurses into library/ + spec/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(koplugin/library): cloud sync — push, pull, upload, delete, downloads - Spore methods: pullBooks (incremental /sync), getDownloadUrl, getUploadUrl, listFiles, deleteFile. - syncbooks.lua: pushBook + pushChangedBooks (advances watermark to max(updated_at, deleted_at)), syncBooks(opts, mode) for push/pull/both, downloadBook + downloadCover (sync socket.http with file sink; cover download via fork+poll with single-slot queue + visible-page filter + coalesced refresh), uploadBook (presigned-PUT flow + best-effort cover), deleteCloudFiles (list-then-delete-each, mirrors cloudService.deleteBook). - SyncAuth.withFreshToken wrapper resolves the ensureClient race; 401/403 unified across syncconfig + syncannotations. - Cloud + local book covers shared by partial-md5 hash; cover.png cached at <settings>/readest_covers/<hash>.png with sentinel for known 404s. - syncbooks_spec covers row-to-wire conversion + file_key shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(koplugin/library): local discovery — sidecar scanner + cover provider - localscanner.lightScan iterates ReadHistory entries, reads partial_md5_checksum from .sdr sidecars, and upserts local rows. Slow filesystem walks deferred to fullSidecarWalk (24h-gated). - coverprovider wraps BookInfoManager:getBookInfo for local books with graceful FakeCover fallback when coverbrowser is absent. - localscanner_spec + coverprovider_spec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(koplugin/library): UI — widget, item, view menu, FileManager hooks - librarywidget: full-screen Menu mixed in with CoverMenu + Mosaic/List per zen_ui's group_view pattern. Title-bar tap → view menu, search via left icon, drill-in/back for grouping. Async cloud sync deferred via scheduleIn so the menu paints before HTTP fires. - libraryitem: BIM patch for cloud-only (readest-cloud://) and group (readest-group://) URIs; group-cover composer (2x2 mosaic for grid, same in list) with cache key derived from the actual first-N hashes for content-based invalidation; ListMenuItem update + paintTo patches for wider list-mode cover strip and cloud-up/down icon overlay. - libraryviewmenu: ButtonDialog with View/Group by/Sort by/Actions. Default Group by = Groups (parity with web), values authors/groups. - librarypaint: partial-page e-ink repaint shim adapted from zen_ui. - main.lua: Library menu entry, dispatcher actions (Open Library / Push / Pull as general; progress + annotations as reader-only), "Add to Readest" button in FileManager's long-press file dialog (dedupe by partial_md5; bumps updated_at when present, inserts a fresh local-only row otherwise; un-tombstones via _clear_fields). Push books on Library open when auto_sync is on, pull-only otherwise. - Long-press action sheet with Readest BookDetailView parity: Remove from Cloud & Device / Cloud Only / Device Only, Upload to Cloud, Download Book / Cover / All. - Cloud-down + cloud-up SVG icons (LiaCloudDownloadAltSolid / LiaCloudUploadAltSolid) painted in the right-side wpageinfo slot. - i18n catalog updated for new strings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(koplugin/library): split libraryitem into focused modules libraryitem.lua had grown to 1018 lines mixing five unrelated concerns. Split along the natural seams: cloud_covers — readest-cloud:// URI scheme, on-disk <hash>.png cache, single-slot async download queue, visible-page filter group_covers — readest-group:// URI scheme, 2x2 mosaic composer with content-derived cache key (first-N hashes), cell layout table cloud_icons — bundled cloud-up/cloud-down SVG loader, IconWidget cache, paint-overlay positioning list_strip — list-mode group row builder (4-cover wider strip replacing ListMenu's square cover slot) bim_patch — BookInfoManager:getBookInfo router (cloud / group / local) + ListMenuItem update + paintTo patches; owns the _library_local_paths set and orig BIM reference libraryitem.lua is now 141 lines: just the entry-table constructors (entry_from_row, entry_from_group, entry_back) plus thin install / set_visible_hashes delegates. Each new module is 88-216 lines. No behavior change — same 113 specs pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(koplugin): co-locate dev tooling, ship-zip exclusions, CI job split - apps/readest.koplugin/scripts/build-koplugin.mjs: local sideloading build with the same exclusions the release workflow uses. - Move lint-koplugin + test-koplugin from apps/readest-app/scripts/ to apps/readest.koplugin/scripts/. All koplugin dev tooling now lives with the koplugin and is excluded from the published release zip. - Rename to .mjs so Node treats them as ESM without the reparse warning (the i18n CommonJS scripts stay .js). - Release workflow: zip -r exclusions for scripts/, docs/, spec/, .busted so dev artifacts don't ship to end users. - PR workflow: split build_web_app into build_web_app + test_web_app for parallelism. The test job installs luarocks + busted + lsqlite3complete and runs pnpm test:lua. test-koplugin.mjs now hard-fails (instead of soft-skipping) when CI=true and a tool is missing — a broken CI toolchain previously exited 0 silently. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1130 lines
48 KiB
Lua
1130 lines
48 KiB
Lua
-- librarywidget.lua
|
|
-- Top-level Library view. Constructs a vanilla KOReader Menu and method-
|
|
-- mixes in CoverMenu + MosaicMenu (or ListMenu) per zen_ui's group_view.lua
|
|
-- pattern, then drives item_table from LibraryStore. Owns the search bar,
|
|
-- view-menu button, and group breadcrumb. Triggers lightScan + cloud pull
|
|
-- on open.
|
|
--
|
|
-- See apps/readest.koplugin/docs/library-design.md for the full design and
|
|
-- the runtime compatibility/smoke-test reasoning.
|
|
|
|
local Device = require("device")
|
|
local GestureRange = require("ui/gesturerange")
|
|
local InfoMessage = require("ui/widget/infomessage")
|
|
local InputDialog = require("ui/widget/inputdialog")
|
|
local Menu = require("ui/widget/menu")
|
|
local NetworkMgr = require("ui/network/manager")
|
|
local TitleBar = require("ui/widget/titlebar")
|
|
local Trapper = require("ui/trapper")
|
|
local UIManager = require("ui/uimanager")
|
|
local logger = require("logger")
|
|
local _ = require("i18n")
|
|
|
|
local LibraryStore = require("library.librarystore")
|
|
local libraryitem = require("library.libraryitem")
|
|
local librarypaint = require("library.librarypaint")
|
|
local localscanner = require("library.localscanner")
|
|
local syncbooks = require("library.syncbooks")
|
|
|
|
local M = {}
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Module-level state. The widget is a singleton — reopening reuses the
|
|
-- store. Account-switching closes + reopens with a fresh user_id.
|
|
-- ---------------------------------------------------------------------------
|
|
M._store = nil
|
|
M._current_user = nil
|
|
M._opts = nil -- last-used opts; needed for refresh after view-menu changes
|
|
M._menu = nil
|
|
M._search = nil -- transient per-session search query; never persisted to disk
|
|
M._group_path = nil -- transient: nil = root; "Fantasy/Tolkien" when drilled in
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- check_renderer_compat: signature + smoke test from the eng review.
|
|
-- Returns ok, reason; on failure, librarywidget falls back to a plain Menu
|
|
-- with FakeCover-only items so the view still loads.
|
|
-- ---------------------------------------------------------------------------
|
|
function M.check_renderer_compat()
|
|
local ok_cm, CoverMenu = pcall(require, "covermenu")
|
|
local ok_mm, MosaicMenu = pcall(require, "mosaicmenu")
|
|
local ok_lm, ListMenu = pcall(require, "listmenu")
|
|
if not (ok_cm and ok_mm and ok_lm) then
|
|
return false, "missing-modules:" .. tostring(not ok_cm and "covermenu" or not ok_mm and "mosaicmenu" or "listmenu")
|
|
end
|
|
local needed = {
|
|
{ CoverMenu, "updateItems" },
|
|
{ CoverMenu, "onCloseWidget" },
|
|
{ MosaicMenu, "_recalculateDimen" },
|
|
{ MosaicMenu, "_updateItemsBuildUI" },
|
|
{ ListMenu, "_recalculateDimen" },
|
|
{ ListMenu, "_updateItemsBuildUI" },
|
|
}
|
|
for _, n in ipairs(needed) do
|
|
if type(n[1][n[2]]) ~= "function" then
|
|
return false, "missing-method:" .. n[2]
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- canOpen(settings) — used by main.lua's menu-item enable check.
|
|
-- ---------------------------------------------------------------------------
|
|
function M.canOpen(settings)
|
|
if not settings or not settings.user_id or settings.user_id == "" then
|
|
return false, _("Sign in to Readest to open the Library")
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- ensure_store(settings) — open the LibraryStore for the active user;
|
|
-- close + reopen if the user_id changed since last open (account switch).
|
|
-- ---------------------------------------------------------------------------
|
|
local function ensure_store(settings)
|
|
local DataStorage = require("datastorage")
|
|
local db_path = DataStorage:getSettingsDir() .. "/readest_library.sqlite3"
|
|
if M._store and M._current_user == settings.user_id then return M._store end
|
|
if M._store then M._store:close() end
|
|
M._store = LibraryStore.new({ user_id = settings.user_id, db_path = db_path })
|
|
M._current_user = settings.user_id
|
|
return M._store
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- get_filters(settings) — read the persisted view-menu state from
|
|
-- G_reader_settings.readest_sync.library_*; return a filters table for
|
|
-- LibraryStore:listBooks.
|
|
-- ---------------------------------------------------------------------------
|
|
local function get_filters(settings, search)
|
|
-- Default sort: last_read_at DESC. listBooks COALESCEs with updated_at
|
|
-- so cloud-only books (NULL last_read_at) still sort by their cloud
|
|
-- timestamp instead of falling to the bottom in arbitrary order.
|
|
-- group_by/group_filter are intentionally NOT carried here — the
|
|
-- bookshelf composer (build_item_table) handles them via
|
|
-- listBookshelfGroups + listBookshelfBooks.
|
|
return {
|
|
search = search,
|
|
sort_by = settings.library_sort_by or "last_read_at",
|
|
sort_asc = settings.library_sort_ascending == true,
|
|
}
|
|
end
|
|
|
|
-- The picker values mirror Readest's LibraryGroupByType ("authors",
|
|
-- "groups", "series"); the store schema uses singular SQL column names
|
|
-- ("author", "group_name", "series"). This map translates one to the
|
|
-- other so the UI value and the SQL identifier can both be canonical.
|
|
-- Default first-run group-by is "groups" for parity with Readest web.
|
|
local DEFAULT_GROUP_BY = "groups"
|
|
local GROUP_BY_TO_COLUMN = {
|
|
authors = "author",
|
|
groups = "group_name",
|
|
series = "series",
|
|
}
|
|
local function active_group_by(settings)
|
|
local g = settings.library_group_by
|
|
if g == nil then g = DEFAULT_GROUP_BY end
|
|
if g == "none" then return nil end
|
|
return GROUP_BY_TO_COLUMN[g] or g
|
|
end
|
|
|
|
-- "↩ Parent" or "↩ Library" depending on whether we'd land at a sub-path
|
|
-- or back at root. Used as item_table[1] when drilled in.
|
|
local function back_entry_for(current_path)
|
|
local parent
|
|
if current_path then
|
|
for i = #current_path, 1, -1 do
|
|
if current_path:sub(i, i) == "/" then
|
|
parent = current_path:sub(1, i - 1)
|
|
break
|
|
end
|
|
end
|
|
end
|
|
local label = "↩ " .. (parent or _("Library"))
|
|
return libraryitem.entry_back(parent, label)
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- get_view_mode(settings) — "mosaic" or "list"; defaults to mosaic.
|
|
-- ---------------------------------------------------------------------------
|
|
local function get_view_mode(settings)
|
|
local mode = settings.library_view_mode
|
|
if mode == "list" then return "list" end
|
|
return "mosaic"
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- build_item_table(store, settings, search) — query store + map rows to
|
|
-- Menu entries via libraryitem.entry_from_row.
|
|
-- ---------------------------------------------------------------------------
|
|
-- Sort-value extraction for the merged shelf list. Each entry — whether
|
|
-- a folder/group or a book row — gets one comparable value per sort_by;
|
|
-- groups use their "most recent child" aggregate so they interleave
|
|
-- correctly with siblings under date-based sorts. Mirrors Readest's
|
|
-- getGroupSortValue + getBookSortValue at
|
|
-- apps/readest-app/src/app/library/utils/libraryUtils.ts:313-403.
|
|
local SORT_VALUE_FOR_GROUP = {
|
|
last_read_at = function(g) return g.latest_last_read_at or 0 end,
|
|
updated_at = function(g) return g.latest_updated_at or 0 end,
|
|
created_at = function(g) return g.latest_created_at or 0 end,
|
|
title = function(g) return g.display_name or "" end,
|
|
author = function(g) return g.display_name or "" end,
|
|
series = function(g) return g.display_name or "" end,
|
|
format = function(g) return g.display_name or "" end,
|
|
}
|
|
|
|
local SORT_VALUE_FOR_BOOK = {
|
|
-- last_read_at: prefer updated_at when present (matches the SQL
|
|
-- COALESCE in librarystore.listBooks). Without this the Lua-side
|
|
-- merged-shelf sort overrides the SQL sort with the old
|
|
-- "last_read_at first" behaviour, hiding any updated_at bump (e.g.
|
|
-- the dedupe path of "Add to Readest").
|
|
last_read_at = function(r) return r.updated_at or r.last_read_at or 0 end,
|
|
updated_at = function(r) return r.updated_at or 0 end,
|
|
created_at = function(r) return r.created_at or 0 end,
|
|
title = function(r) return r.title or "" end,
|
|
author = function(r) return r.author or "" end,
|
|
series = function(r) return r.series or "" end,
|
|
format = function(r) return r.format or "" end,
|
|
}
|
|
|
|
local function build_item_table(store, settings, search)
|
|
local group_by = active_group_by(settings)
|
|
|
|
if not group_by then
|
|
local rows = store:listBooks(get_filters(settings, search))
|
|
local items = {}
|
|
for i, row in ipairs(rows) do
|
|
items[i] = libraryitem.entry_from_row(row)
|
|
end
|
|
return items
|
|
end
|
|
|
|
local parent_path = M._group_path
|
|
local groups = store:listBookshelfGroups(group_by, parent_path)
|
|
local books = store:listBookshelfBooks(get_filters(settings, search), group_by, parent_path)
|
|
|
|
local sort_by = settings.library_sort_by or "last_read_at"
|
|
local sort_asc = settings.library_sort_ascending == true
|
|
local g_value = SORT_VALUE_FOR_GROUP[sort_by] or SORT_VALUE_FOR_GROUP.last_read_at
|
|
local b_value = SORT_VALUE_FOR_BOOK[sort_by] or SORT_VALUE_FOR_BOOK.last_read_at
|
|
|
|
logger.dbg("ReadestLibrary build_item_table: group_by=" .. tostring(group_by)
|
|
.. " parent_path=" .. tostring(parent_path)
|
|
.. " sort_by=" .. tostring(sort_by)
|
|
.. " sort_asc=" .. tostring(sort_asc)
|
|
.. " #groups=" .. #groups .. " #books=" .. #books)
|
|
|
|
-- Build a single mixed list with each entry's sort_value pre-computed,
|
|
-- then sort once. Stable on already-sorted input either way.
|
|
-- Group cells get a mini-cover preview in both view modes — a 2x2
|
|
-- mosaic for Grid (mosaic) and a 1x4 horizontal strip for List.
|
|
local view_mode = get_view_mode(settings)
|
|
local group_entry_opts = {
|
|
group_by = group_by,
|
|
with_cover = true,
|
|
shape = (view_mode == "list") and "list" or "grid",
|
|
}
|
|
local merged = {}
|
|
for _i, g in ipairs(groups) do
|
|
merged[#merged + 1] = {
|
|
entry = libraryitem.entry_from_group(g, group_entry_opts),
|
|
sort_value = g_value(g),
|
|
}
|
|
end
|
|
for _i, row in ipairs(books) do
|
|
merged[#merged + 1] = {
|
|
entry = libraryitem.entry_from_row(row),
|
|
sort_value = b_value(row),
|
|
}
|
|
end
|
|
table.sort(merged, function(a, b)
|
|
local av, bv = a.sort_value, b.sort_value
|
|
if type(av) ~= type(bv) then
|
|
-- Pathological: a string sort_value next to a numeric one
|
|
-- (shouldn't happen given the table-driven extractors above,
|
|
-- but stay deterministic if a future caller mixes them).
|
|
return tostring(av) < tostring(bv)
|
|
end
|
|
if sort_asc then return av < bv end
|
|
return av > bv
|
|
end)
|
|
|
|
local items = {}
|
|
if parent_path then
|
|
items[#items + 1] = back_entry_for(parent_path)
|
|
end
|
|
for _i, m in ipairs(merged) do
|
|
items[#items + 1] = m.entry
|
|
end
|
|
return items
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- mix_renderer(menu, view_mode) — apply CoverMenu + (Mosaic|List)Menu
|
|
-- methods onto our Menu. Mirrors zen_ui group_view.lua:62-95 layout.
|
|
-- ---------------------------------------------------------------------------
|
|
local function mix_renderer(menu, view_mode)
|
|
local CoverMenu = require("covermenu")
|
|
local MosaicMenu = require("mosaicmenu")
|
|
local ListMenu = require("listmenu")
|
|
|
|
menu.updateItems = CoverMenu.updateItems
|
|
menu.onCloseWidget = CoverMenu.onCloseWidget
|
|
|
|
-- Per-mode mixins
|
|
if view_mode == "mosaic" then
|
|
menu._recalculateDimen = MosaicMenu._recalculateDimen
|
|
menu._updateItemsBuildUI = MosaicMenu._updateItemsBuildUI
|
|
menu._do_cover_images = true
|
|
menu._do_center_partial_rows = false
|
|
menu._do_hint_opened = false
|
|
else
|
|
menu._recalculateDimen = ListMenu._recalculateDimen
|
|
menu._updateItemsBuildUI = ListMenu._updateItemsBuildUI
|
|
menu._do_cover_images = true
|
|
menu._do_filename_only = false
|
|
end
|
|
|
|
menu.display_mode_type = view_mode
|
|
|
|
-- Codex round 2 finding 3: zen_ui supplies these methods because the
|
|
-- mixin's _updateItemsBuildUI calls them as if they were native to the
|
|
-- Menu. Provide real implementations (an empty stub causes
|
|
-- been_opened=nil → MosaicMenu paints the "New" ribbon on every
|
|
-- already-read book — which was every book in the user's bug report).
|
|
if not menu.getBookInfo then
|
|
menu.getBookInfo = function(file_path)
|
|
if not file_path then return {} end
|
|
-- Cloud-only entries use a synthetic readest-cloud:// path;
|
|
-- they're not on disk so DocSettings can't read them. Return
|
|
-- been_opened=false so the renderer doesn't show "New" but
|
|
-- doesn't try to read a percent_finished either.
|
|
if file_path:match("^readest%-cloud://") then
|
|
return { been_opened = false }
|
|
end
|
|
local ok_ds, DocSettings = pcall(require, "docsettings")
|
|
if not ok_ds then return {} end
|
|
if not DocSettings:hasSidecarFile(file_path) then return {} end
|
|
local ok_open, doc = pcall(DocSettings.open, DocSettings, file_path)
|
|
if not ok_open or not doc then return {} end
|
|
local summary = doc:readSetting("summary")
|
|
local stats = doc:readSetting("stats")
|
|
return {
|
|
been_opened = true,
|
|
percent_finished = doc:readSetting("percent_finished"),
|
|
status = summary and summary.status,
|
|
pages = stats and stats.pages,
|
|
}
|
|
end
|
|
end
|
|
if not menu.resetBookInfoCache then
|
|
menu.resetBookInfoCache = function() end
|
|
end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- smoke_test_render(menu) — render one synthetic local + one cloud-only
|
|
-- entry off-screen via pcall. If the renderer throws on either, fall back.
|
|
-- Catches contract drift in entry shape that the method-existence check
|
|
-- alone would miss (codex round 2 finding 3).
|
|
-- ---------------------------------------------------------------------------
|
|
local function smoke_test_render(menu)
|
|
local probe = { is_file = true, file = "/tmp/readest-smoke.epub", text = "Smoke" }
|
|
local cloud = libraryitem.entry_from_row({
|
|
hash = "00smoke", title = "Cloud Smoke", cloud_present = 1, local_present = 0,
|
|
})
|
|
local saved_table = menu.item_table
|
|
menu.item_table = { probe, cloud }
|
|
local ok, err = pcall(function()
|
|
if menu._recalculateDimen then menu:_recalculateDimen() end
|
|
-- Don't call _updateItemsBuildUI — it appends real widgets to the
|
|
-- menu's content_group which we don't want side-effects from.
|
|
-- The dimension recalc is the failure-prone part anyway.
|
|
end)
|
|
menu.item_table = saved_table
|
|
return ok, err
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- title_for(search) — build the menu title bar text. When a search is
|
|
-- active, surface it so the user can see why their library "looks empty".
|
|
-- ---------------------------------------------------------------------------
|
|
local function title_for(search)
|
|
local title = _("Readest Library")
|
|
if M._group_path then
|
|
title = title .. ": " .. M._group_path
|
|
end
|
|
if search and search ~= "" then
|
|
title = title .. " (" .. search .. ")"
|
|
end
|
|
return title
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- handleSearch(menu, store, settings) — open InputDialog. Search is
|
|
-- session-transient (M._search), never persisted: a stuck filter from a
|
|
-- previous run was hiding all-but-matching books with no UI hint.
|
|
-- Includes a Clear button when a search is active so getting back to the
|
|
-- full library is one tap.
|
|
-- ---------------------------------------------------------------------------
|
|
local function handleSearch(menu, store, settings)
|
|
local has_active = M._search and M._search ~= ""
|
|
local dialog
|
|
local apply = function(q)
|
|
UIManager:close(dialog)
|
|
M._search = (q and q ~= "") and q or nil
|
|
-- switchItemTable accepts the new title as its first positional arg;
|
|
-- call it that way instead of menu:setTitle (which doesn't exist on
|
|
-- Menu — it's on TitleBar). One call updates both title + items.
|
|
menu:switchItemTable(title_for(M._search),
|
|
build_item_table(store, settings, M._search), 1)
|
|
end
|
|
local row = {
|
|
{
|
|
text = _("Cancel"),
|
|
id = "close",
|
|
callback = function() UIManager:close(dialog) end,
|
|
},
|
|
}
|
|
if has_active then
|
|
row[#row + 1] = {
|
|
text = _("Clear"),
|
|
callback = function() apply(nil) end,
|
|
}
|
|
end
|
|
row[#row + 1] = {
|
|
text = _("Search"),
|
|
is_enter_default = true,
|
|
callback = function() apply(dialog:getInputText() or "") end,
|
|
}
|
|
dialog = InputDialog:new{
|
|
title = _("Search library"),
|
|
input = M._search or "",
|
|
input_hint = _("Search title or author"),
|
|
buttons = { row },
|
|
}
|
|
UIManager:show(dialog)
|
|
dialog:onShowKeyboard()
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- refresh(menu, store, settings) — re-query store and update visible page.
|
|
-- Called after any view-menu change, search change, sync pull, or scan.
|
|
-- ---------------------------------------------------------------------------
|
|
function M.refresh()
|
|
if not M._menu then return end
|
|
local items = build_item_table(M._store, M._opts.settings, M._search)
|
|
-- Preserve current page so cover-download completions (which call
|
|
-- refresh too) don't yank the user back to page 1 mid-browse. The
|
|
-- third arg to switchItemTable is "jump to this item number"; we
|
|
-- compute the first item of the current page so the same page
|
|
-- re-renders. If the new item count is shorter (e.g. after a sync
|
|
-- delete), clamp to the last available item.
|
|
local page = M._menu.page or 1
|
|
local perpage = M._menu.perpage or 1
|
|
local jump_to = math.max(1, math.min((page - 1) * perpage + 1, #items))
|
|
M._menu:switchItemTable(title_for(M._search), items, jump_to)
|
|
end
|
|
|
|
-- Close the current Menu and rebuild it from scratch using the latest
|
|
-- settings. Used after view-menu changes that affect layout dimensions
|
|
-- (view mode, column count, cover fit) — those are baked in at Menu
|
|
-- construction time via mix_renderer + nb_cols_portrait, so a soft
|
|
-- refresh wouldn't pick them up. Settings that only affect the SQL
|
|
-- query (sort, group, search) keep using M.refresh() since rebuilding
|
|
-- the whole Menu would be needless flicker.
|
|
function M.reopen()
|
|
if M._menu then
|
|
UIManager:close(M._menu)
|
|
M._menu = nil
|
|
-- Clear the visible-hash filter so any in-flight BIM lookups
|
|
-- after close don't kick off downloads we no longer want.
|
|
libraryitem.set_visible_hashes(nil)
|
|
end
|
|
-- A view-mode/columns/cover-fit change is layout-only — keep the
|
|
-- user's current drill-in. _keep_state tells M.open to skip its
|
|
-- per-session resets (group_path, search reset).
|
|
if M._opts then M.open(M._opts, { keep_state = true }) end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- runOpenSync(opts, menu) — fired after the menu is shown; runs lightScan
|
|
-- + cloud sync in a background-friendly way (Trapper coroutine for the
|
|
-- progress dialog; subprocess for the heavy walk happens inside
|
|
-- localscanner.fullSidecarWalk on first run / 24h gate / explicit Rescan).
|
|
--
|
|
-- Sync direction depends on settings.auto_sync (mirrors the auto-sync
|
|
-- toggle the user controls from the Readest plugin menu):
|
|
-- on → "both" (push local changes first, then pull remote changes)
|
|
-- off → "pull" only (still surface cloud-side updates so the Library
|
|
-- view stays meaningful, but never silently push local state).
|
|
-- ---------------------------------------------------------------------------
|
|
local function runCloudSync(opts, store)
|
|
local mode = opts.settings.auto_sync and "both" or "pull"
|
|
logger.info("ReadestLibrary runCloudSync: mode=" .. mode
|
|
.. " auto_sync=" .. tostring(opts.settings.auto_sync))
|
|
syncbooks.syncBooks({
|
|
sync_auth = opts.sync_auth,
|
|
sync_path = opts.sync_path,
|
|
settings = opts.settings,
|
|
store = store,
|
|
}, mode, function(success, msg, status)
|
|
logger.info("ReadestLibrary runCloudSync[" .. mode .. "] done: success="
|
|
.. tostring(success) .. " msg=" .. tostring(msg)
|
|
.. " status=" .. tostring(status))
|
|
-- Refresh either way: success picks up new cloud rows; failure
|
|
-- (auth, network, server) leaves local rows visible without
|
|
-- leaking a stale display state.
|
|
M.refresh()
|
|
end)
|
|
end
|
|
|
|
-- Cloud sync HTTP is synchronous on platforms without the Turbo looper
|
|
-- (macOS desktop, most KOReader builds with the lightweight networking
|
|
-- layer). Calling it inline from M.open blocks the UI loop, so
|
|
-- UIManager:show(menu) doesn't actually repaint until the sync returns
|
|
-- — visible to the user as a frozen Library on open. Pushing it through
|
|
-- UIManager:scheduleIn yields back to the event loop first, lets the
|
|
-- menu paint with the pre-sync local snapshot, and only then issues the
|
|
-- HTTP. The user still sees a brief blocking window when the request
|
|
-- fires, but at least content is on screen instead of a black hole.
|
|
-- Plan B (true non-blocking via runInSubProcess) is a bigger refactor.
|
|
local SYNC_DEFER_SECONDS = 0.05
|
|
|
|
local function runOpenSync(opts, store, menu)
|
|
Trapper:wrap(function()
|
|
logger.info("ReadestLibrary runOpenSync: start user="
|
|
.. tostring(opts.settings.user_id and opts.settings.user_id:sub(1, 8))
|
|
.. " auto_sync=" .. tostring(opts.settings.auto_sync))
|
|
|
|
-- 1. Light local scan (cheap, synchronous). Always refresh after,
|
|
-- regardless of pull outcome below — otherwise local-only books
|
|
-- (the common case for a freshly-installed plugin where nothing
|
|
-- has been uploaded to Readest cloud yet) would never appear in
|
|
-- the menu, since the initial item_table was built from the
|
|
-- pre-scan store snapshot.
|
|
local ok, err = pcall(localscanner.lightScan, { store = store })
|
|
if not ok then logger.warn("ReadestLibrary lightScan failed:", err) end
|
|
M.refresh()
|
|
|
|
-- 2. Cloud sync — deferred so the menu paints first.
|
|
-- willRerunWhenOnline + the inline call moved into the
|
|
-- scheduled handler so the offline→online and already-online
|
|
-- branches share one code path.
|
|
local since = store:getLastPulledAt() or 0
|
|
logger.info("ReadestLibrary runOpenSync: since=" .. tostring(since)
|
|
.. ", scheduling cloud sync in " .. SYNC_DEFER_SECONDS .. "s")
|
|
UIManager:scheduleIn(SYNC_DEFER_SECONDS, function()
|
|
if NetworkMgr:willRerunWhenOnline(function() runCloudSync(opts, store) end) then
|
|
logger.info("ReadestLibrary runOpenSync: sync deferred until network online")
|
|
return
|
|
end
|
|
logger.info("ReadestLibrary runOpenSync: network online, dispatching sync")
|
|
runCloudSync(opts, store)
|
|
end)
|
|
end)
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- open(opts) — main entry from the Readest plugin menu.
|
|
-- opts: { settings = G_reader_settings.readest_sync, sync_path, sync_auth }
|
|
-- ---------------------------------------------------------------------------
|
|
function M.open(opts, internal)
|
|
local can_open, reason = M.canOpen(opts.settings)
|
|
if not can_open then
|
|
UIManager:show(InfoMessage:new{ text = reason, timeout = 3 })
|
|
return
|
|
end
|
|
|
|
M._opts = opts
|
|
-- Each fresh open starts at the root shelf; group drill-in state is
|
|
-- per-session, never persisted to disk. M.reopen passes keep_state
|
|
-- so a layout-change rebuild stays in the user's current folder.
|
|
if not (internal and internal.keep_state) then
|
|
M._group_path = nil
|
|
end
|
|
-- Migrate any stuck pre-fix library_search from settings into the
|
|
-- transient session slot, then strip it from disk so future upgrades
|
|
-- don't have to repeat this. (Leaving it in settings would re-pollute
|
|
-- M._search next session.)
|
|
if opts.settings.library_search then
|
|
M._search = opts.settings.library_search
|
|
opts.settings.library_search = nil
|
|
G_reader_settings:saveSetting("readest_sync", opts.settings)
|
|
end
|
|
local store = ensure_store(opts.settings)
|
|
|
|
-- Renderer compatibility check (codex round 2 finding 3)
|
|
local ok, why = M.check_renderer_compat()
|
|
if not ok then
|
|
logger.warn("ReadestLibrary renderer compat check failed:", why)
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("Cover Browser plugin required for full Library rendering. Falling back to plain list."),
|
|
timeout = 5,
|
|
})
|
|
-- Plain Menu fallback: still usable, just no covers
|
|
else
|
|
-- Pass sync auth so the patched BIM can lazily download cloud
|
|
-- cover.png files for cloud-only entries (covers are then
|
|
-- shared between cloud + local presentations of the same hash).
|
|
libraryitem.install({
|
|
sync_auth = opts.sync_auth,
|
|
sync_path = opts.sync_path,
|
|
settings = opts.settings,
|
|
})
|
|
end
|
|
|
|
local Screen = Device.screen
|
|
local view_mode = get_view_mode(opts.settings)
|
|
|
|
local menu
|
|
-- Custom TitleBar: close X on the right (via close_callback), search on
|
|
-- the left, and the centered title acts as a tap target for the View
|
|
-- menu. Stock TitleBar has no title-tap callback, so the actual tap
|
|
-- handler is registered on the Menu's ges_events below — this title
|
|
-- bar just supplies the dimen for the gesture range.
|
|
local view_menu_callback = function()
|
|
local LibraryViewMenu = require("library.libraryviewmenu")
|
|
local prev_group_by = active_group_by(opts.settings)
|
|
LibraryViewMenu.show({
|
|
settings = opts.settings,
|
|
on_change = function()
|
|
if active_group_by(opts.settings) ~= prev_group_by then
|
|
M._group_path = nil
|
|
end
|
|
M.refresh()
|
|
end,
|
|
on_layout_change = function()
|
|
if active_group_by(opts.settings) ~= prev_group_by then
|
|
M._group_path = nil
|
|
end
|
|
M.reopen()
|
|
end,
|
|
})
|
|
end
|
|
local title_bar = TitleBar:new{
|
|
width = Screen:getWidth(),
|
|
fullscreen = "true",
|
|
align = "center",
|
|
title = title_for(M._search),
|
|
left_icon = "appbar.search",
|
|
left_icon_tap_callback = function() handleSearch(menu, store, opts.settings) end,
|
|
close_callback = function() if menu then menu:onClose() end end,
|
|
}
|
|
-- Compute the orientation-appropriate per-page count up front and
|
|
-- pass it as items_per_page. Menu's native _recalculateDimen
|
|
-- (menu.lua:648) uses items_per_page; MosaicMenu's mixin uses
|
|
-- nb_rows*nb_cols. Without setting items_per_page they disagree —
|
|
-- Menu's heuristic computed ~14, MosaicMenu's mixin computed 9, the
|
|
-- footer page-nav lagged the cell layout, and the cell layout
|
|
-- attempted to fit 14 cells in 9 visible slots, leaking partial cells
|
|
-- under the page nav. Make both formulas land on the same number by
|
|
-- pre-setting items_per_page.
|
|
local nb_cols_p = opts.settings.library_columns or 3
|
|
local nb_rows_p = opts.settings.library_rows or 3
|
|
local nb_cols_l = opts.settings.library_columns_landscape or 4
|
|
local nb_rows_l = opts.settings.library_rows_landscape or 2
|
|
local portrait = Screen:getWidth() <= Screen:getHeight()
|
|
local items_per_page = portrait and (nb_cols_p * nb_rows_p) or (nb_cols_l * nb_rows_l)
|
|
|
|
menu = Menu:new{
|
|
name = "readest_library",
|
|
is_borderless = true,
|
|
is_popout = false,
|
|
covers_fullscreen = true,
|
|
custom_title_bar = title_bar,
|
|
item_table = build_item_table(store, opts.settings, M._search),
|
|
width = Screen:getWidth(),
|
|
height = Screen:getHeight(),
|
|
items_per_page = items_per_page,
|
|
nb_cols_portrait = nb_cols_p,
|
|
nb_rows_portrait = nb_rows_p,
|
|
nb_cols_landscape = nb_cols_l,
|
|
nb_rows_landscape = nb_rows_l,
|
|
onMenuSelect = function(_self, item)
|
|
M.handleTap(item, opts)
|
|
end,
|
|
onMenuHold = function(_self, item)
|
|
M.handleHold(item, opts)
|
|
end,
|
|
}
|
|
title_bar.show_parent = menu
|
|
|
|
if ok then
|
|
mix_renderer(menu, view_mode)
|
|
local smoke_ok, smoke_err = smoke_test_render(menu)
|
|
if not smoke_ok then
|
|
logger.warn("ReadestLibrary smoke test failed:", smoke_err)
|
|
-- Already constructed; just leave the mixin in place. The
|
|
-- smoke test is a tripwire, not a fatal check — we surface
|
|
-- via logger so a future user report includes the trace.
|
|
end
|
|
librarypaint.install(menu)
|
|
|
|
-- Wrap updateItems to recompute the visible-page hash set before
|
|
-- any cell paints. Cell paints call BIM:getBookInfo, which calls
|
|
-- trigger_cover_download — and we want THAT to only fire for
|
|
-- on-screen items. set_visible_hashes runs FIRST so when the
|
|
-- subsequent paint hits BIM, the filter is already in place.
|
|
local orig_update = menu.updateItems
|
|
menu.updateItems = function(self, ...)
|
|
libraryitem.set_visible_hashes(self)
|
|
return orig_update(self, ...)
|
|
end
|
|
end
|
|
|
|
-- Tap on the title bar (anywhere not on the left search icon or the
|
|
-- right close X) opens the View menu. Stock TitleBar has no
|
|
-- title_tap_callback hook, so we register a gesture range at the
|
|
-- Menu level. Children (IconButton, MenuItem cells, footer) get the
|
|
-- gesture first via WidgetContainer dispatch; only taps that no child
|
|
-- claims fall through to Menu's onGesture and reach this handler.
|
|
-- Use a range function so the dimen is read at match time — TitleBar
|
|
-- only sets dimen.x/y during paintTo, so the value at registration
|
|
-- isn't reliable on first tap.
|
|
menu.ges_events.TapTitle = {
|
|
GestureRange:new{
|
|
ges = "tap",
|
|
range = function() return title_bar.dimen end,
|
|
},
|
|
}
|
|
menu.onTapTitle = function() view_menu_callback() return true end
|
|
|
|
M._menu = menu
|
|
UIManager:show(menu)
|
|
|
|
runOpenSync(opts, store, menu)
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- handleTap(item, opts) — tap dispatch. Local books open immediately;
|
|
-- cloud-only books prompt for download.
|
|
-- ---------------------------------------------------------------------------
|
|
function M.handleTap(item, opts)
|
|
if not item then return end
|
|
|
|
-- Group folder entry → drill in
|
|
if item._readest_group then
|
|
M._group_path = item._readest_group.name
|
|
M.refresh()
|
|
return
|
|
end
|
|
|
|
-- "↩ Back" entry → pop one level (or up to root)
|
|
if item._readest_is_back then
|
|
M._group_path = item._readest_back_to
|
|
M.refresh()
|
|
return
|
|
end
|
|
|
|
if not item._readest_row then return end
|
|
local row = item._readest_row
|
|
local lfs = require("libs/libkoreader-lfs")
|
|
|
|
if row.local_present == 1 and row.file_path then
|
|
-- Tap-time recovery: maybe the file vanished since the last scan
|
|
if lfs.attributes(row.file_path, "mode") ~= "file" then
|
|
local ConfirmBox = require("ui/widget/confirmbox")
|
|
UIManager:show(ConfirmBox:new{
|
|
text = _("File moved or deleted. Rescan library?"),
|
|
ok_callback = function()
|
|
Trapper:wrap(function()
|
|
localscanner.fullSidecarWalk({
|
|
store = M._store,
|
|
home_dir = G_reader_settings:readSetting("home_dir"),
|
|
})
|
|
M.refresh()
|
|
end)
|
|
end,
|
|
})
|
|
return
|
|
end
|
|
local ReaderUI = require("apps/reader/readerui")
|
|
ReaderUI:showReader(row.file_path)
|
|
return
|
|
end
|
|
|
|
-- Cloud-only path: confirm + download + open
|
|
if row.cloud_present == 1 then
|
|
local ConfirmBox = require("ui/widget/confirmbox")
|
|
UIManager:show(ConfirmBox:new{
|
|
text = _("Download this book from Readest?") .. "\n\n" .. (row.title or ""),
|
|
ok_text = _("Download"),
|
|
ok_callback = function()
|
|
local download_dir = opts.settings.library_download_dir
|
|
or G_reader_settings:readSetting("home_dir")
|
|
if not download_dir or download_dir == "" then
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("Set Home folder in File Manager first to enable downloads."),
|
|
timeout = 3,
|
|
})
|
|
return
|
|
end
|
|
local progress = InfoMessage:new{
|
|
text = _("Downloading…") .. " " .. (row.title or ""),
|
|
}
|
|
UIManager:show(progress)
|
|
syncbooks.downloadBook(row, {
|
|
sync_auth = opts.sync_auth,
|
|
sync_path = opts.sync_path,
|
|
settings = opts.settings,
|
|
download_dir = download_dir,
|
|
}, function(success, dst_or_err, status)
|
|
UIManager:close(progress)
|
|
if not success then
|
|
local msg = (status == 404)
|
|
and _("Cloud copy unavailable.")
|
|
or _("Download failed.")
|
|
UIManager:show(InfoMessage:new{ text = msg, timeout = 3 })
|
|
return
|
|
end
|
|
-- Update store: row now has a local file
|
|
M._store:upsertBook({
|
|
hash = row.hash, title = row.title,
|
|
local_present = 1, file_path = dst_or_err,
|
|
})
|
|
M.refresh()
|
|
local ReaderUI = require("apps/reader/readerui")
|
|
ReaderUI:showReader(dst_or_err)
|
|
end)
|
|
end,
|
|
})
|
|
return
|
|
end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Action helpers (used by handleHold's button dialog)
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- Run a book download without auto-opening the reader on success. Used by
|
|
-- both "Download Book" and "Download All" from the long-press action
|
|
-- sheet — there the user wants the file on device, not to start reading.
|
|
local function downloadBookOnly(row, opts, after_cb)
|
|
local download_dir = opts.settings.library_download_dir
|
|
or G_reader_settings:readSetting("home_dir")
|
|
if not download_dir or download_dir == "" then
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("Set Home folder in File Manager first to enable downloads."),
|
|
timeout = 3,
|
|
})
|
|
if after_cb then after_cb(false) end
|
|
return
|
|
end
|
|
local progress = InfoMessage:new{
|
|
text = _("Downloading…") .. " " .. (row.title or ""),
|
|
}
|
|
UIManager:show(progress)
|
|
syncbooks.downloadBook(row, {
|
|
sync_auth = opts.sync_auth,
|
|
sync_path = opts.sync_path,
|
|
settings = opts.settings,
|
|
download_dir = download_dir,
|
|
}, function(success, dst_or_err, status)
|
|
UIManager:close(progress)
|
|
if not success then
|
|
local msg = (status == 404)
|
|
and _("Cloud copy unavailable.")
|
|
or _("Download failed.")
|
|
UIManager:show(InfoMessage:new{ text = msg, timeout = 3 })
|
|
if after_cb then after_cb(false) end
|
|
return
|
|
end
|
|
M._store:upsertBook({
|
|
hash = row.hash, title = row.title,
|
|
local_present = 1, file_path = dst_or_err,
|
|
})
|
|
M.refresh()
|
|
if after_cb then after_cb(true) end
|
|
end)
|
|
end
|
|
|
|
local function downloadCoverOnly(row, opts, after_cb)
|
|
local DataStorage = require("datastorage")
|
|
syncbooks.downloadCover(row, {
|
|
sync_auth = opts.sync_auth,
|
|
sync_path = opts.sync_path,
|
|
settings = opts.settings,
|
|
covers_dir = DataStorage:getSettingsDir() .. "/readest_covers",
|
|
}, function(success, _path_or_err, status)
|
|
if not success then
|
|
local msg = (status == 404)
|
|
and _("No cover available on Readest.")
|
|
or _("Cover download failed.")
|
|
UIManager:show(InfoMessage:new{ text = msg, timeout = 3 })
|
|
if after_cb then after_cb(false) end
|
|
return
|
|
end
|
|
M.refresh()
|
|
if after_cb then after_cb(true) end
|
|
end)
|
|
end
|
|
|
|
-- Remove the local file for a book and clear local_present in the store.
|
|
-- Returns true on success. Cloud-side state (cloud_present, deleted_at)
|
|
-- is untouched.
|
|
local function removeLocalFile(row)
|
|
local lfs = require("libs/libkoreader-lfs")
|
|
if not row.file_path or row.file_path == "" then return false end
|
|
if lfs.attributes(row.file_path, "mode") == "file" then
|
|
local ok = os.remove(row.file_path)
|
|
if not ok then
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("Could not delete the file."), timeout = 3,
|
|
})
|
|
return false
|
|
end
|
|
end
|
|
-- Reset local presence in the store; cloud_present stays as-is so
|
|
-- the row remains visible (and re-downloadable) if it's in the cloud.
|
|
M._store:upsertBook({
|
|
hash = row.hash,
|
|
title = row.title,
|
|
local_present = 0,
|
|
file_path = nil,
|
|
})
|
|
return true
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- handleHold(item, opts) — long-press action sheet
|
|
-- ---------------------------------------------------------------------------
|
|
-- Mirrors Readest's BookDetailView action set (apps/readest-app/src/
|
|
-- components/metadata/BookDetailView.tsx): Delete is a sub-menu with
|
|
-- Cloud & Device / Cloud Only / Device Only; Upload shows only when the
|
|
-- book is on device but not in the cloud; Download shows only when the
|
|
-- book is in the cloud. Cloud-side actions (Upload, Remove from Cloud)
|
|
-- need new /storage/upload + DELETE /sync calls that aren't ported yet,
|
|
-- so they're labeled but stubbed; local-side actions (Remove from
|
|
-- Device, Download Book/Cover/All) work today.
|
|
function M.handleHold(item, opts)
|
|
if not item or not item._readest_row then return end
|
|
local row = item._readest_row
|
|
local ButtonDialog = require("ui/widget/buttondialog")
|
|
local ConfirmBox = require("ui/widget/confirmbox")
|
|
|
|
local on_cloud = row.cloud_present == 1
|
|
local on_local = row.local_present == 1
|
|
|
|
local dialog
|
|
local function close() UIManager:close(dialog) end
|
|
|
|
local rows = {}
|
|
local function add_row(text, cb)
|
|
rows[#rows + 1] = {{ text = text, callback = cb }}
|
|
end
|
|
|
|
-- Cloud delete shared by "Cloud & Device" and "Cloud Only". Mirrors
|
|
-- Readest's cloudService.deleteBook 'cloud' branch: list /storage,
|
|
-- delete each file, clear cloud_present in the store. For Cloud &
|
|
-- Device the caller also pushes a tombstone via /sync so peers
|
|
-- consider the book gone (peers pulling /sync see deleted_at).
|
|
local function doCloudDelete(after_cb)
|
|
local progress = InfoMessage:new{
|
|
text = _("Removing from cloud…") .. " " .. (row.title or ""),
|
|
}
|
|
UIManager:show(progress)
|
|
syncbooks.deleteCloudFiles(row, {
|
|
sync_auth = opts.sync_auth,
|
|
sync_path = opts.sync_path,
|
|
settings = opts.settings,
|
|
}, function(success, _msg, status)
|
|
UIManager:close(progress)
|
|
if not success then
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("Cloud removal failed.")
|
|
.. " (status=" .. tostring(status) .. ")",
|
|
timeout = 3,
|
|
})
|
|
if after_cb then after_cb(false) end
|
|
return
|
|
end
|
|
if after_cb then after_cb(true) end
|
|
end)
|
|
end
|
|
|
|
-- Delete sub-options (parity with BookDetailView's three-item dropdown).
|
|
add_row(_("Remove from Cloud & Device"), function()
|
|
close()
|
|
UIManager:show(ConfirmBox:new{
|
|
text = _("Remove this book from cloud and device?")
|
|
.. "\n\n" .. (row.title or ""),
|
|
ok_text = _("Remove"),
|
|
ok_callback = function()
|
|
local function finish_local()
|
|
if on_local then removeLocalFile(row) end
|
|
-- Tombstone push: matches Readest's `book.deletedAt =
|
|
-- Date.now() + pushLibrary()` for the 'both' delete.
|
|
-- Peers pulling /sync see deleted_at and stop showing
|
|
-- the row, even though we already removed our local
|
|
-- copy + cloud objects.
|
|
local now = math.floor(os.time() * 1000)
|
|
M._store:upsertBook({
|
|
hash = row.hash,
|
|
title = row.title,
|
|
cloud_present = 0,
|
|
local_present = 0,
|
|
deleted_at = now,
|
|
_force_cloud_present = true,
|
|
})
|
|
-- Build a tombstone wire row from the in-memory entry
|
|
-- row (which already has format/meta_hash/etc) plus
|
|
-- the deleted_at + bumped updated_at.
|
|
local tombstone = {}
|
|
for k, v in pairs(row) do tombstone[k] = v end
|
|
tombstone.deleted_at = now
|
|
tombstone.updated_at = now
|
|
syncbooks.pushBook(tombstone, {
|
|
sync_auth = opts.sync_auth,
|
|
sync_path = opts.sync_path,
|
|
settings = opts.settings,
|
|
}, function() M.refresh() end)
|
|
M.refresh()
|
|
end
|
|
if on_cloud then
|
|
doCloudDelete(function() finish_local() end)
|
|
else
|
|
finish_local()
|
|
end
|
|
end,
|
|
})
|
|
end)
|
|
if on_cloud then
|
|
add_row(_("Remove from Cloud Only"), function()
|
|
close()
|
|
UIManager:show(ConfirmBox:new{
|
|
text = _("Remove this book from the cloud only?")
|
|
.. "\n\n" .. (row.title or ""),
|
|
ok_text = _("Remove"),
|
|
ok_callback = function()
|
|
doCloudDelete(function(success)
|
|
if success then
|
|
-- Cloud objects gone; clear cloud_present but
|
|
-- leave the row visible if local_present=1.
|
|
M._store:upsertBook({
|
|
hash = row.hash,
|
|
title = row.title,
|
|
cloud_present = 0,
|
|
_force_cloud_present = true,
|
|
})
|
|
M.refresh()
|
|
end
|
|
end)
|
|
end,
|
|
})
|
|
end)
|
|
end
|
|
if on_local then
|
|
add_row(_("Remove from Device Only"), function()
|
|
close()
|
|
UIManager:show(ConfirmBox:new{
|
|
text = _("Remove the local copy of this book?")
|
|
.. "\n\n" .. (row.title or ""),
|
|
ok_text = _("Remove"),
|
|
ok_callback = function()
|
|
if removeLocalFile(row) then M.refresh() end
|
|
end,
|
|
})
|
|
end)
|
|
end
|
|
|
|
-- Upload: parity with BookDetailView's `book.downloadedAt && onUpload`.
|
|
-- Shown whenever the book is on device, even if it's already in the
|
|
-- cloud — same gating as BookDetailView (which uses `downloadedAt`
|
|
-- alone, not `downloadedAt && !uploadedAt`). Re-uploading is the
|
|
-- web's path for replacing a cloud copy after local edits.
|
|
if on_local then
|
|
add_row(_("Upload to Cloud"), function()
|
|
close()
|
|
local progress = InfoMessage:new{
|
|
text = _("Uploading…") .. " " .. (row.title or ""),
|
|
}
|
|
UIManager:show(progress)
|
|
local DataStorage = require("datastorage")
|
|
syncbooks.uploadBook(row, {
|
|
sync_auth = opts.sync_auth,
|
|
sync_path = opts.sync_path,
|
|
settings = opts.settings,
|
|
covers_dir = DataStorage:getSettingsDir() .. "/readest_covers",
|
|
}, function(success, msg, status)
|
|
UIManager:close(progress)
|
|
if not success then
|
|
local text
|
|
if status == 403 and msg and msg:find("quota", 1, true) then
|
|
text = _("Storage quota exceeded.")
|
|
else
|
|
text = _("Upload failed.")
|
|
.. " (" .. tostring(msg or status) .. ")"
|
|
end
|
|
UIManager:show(InfoMessage:new{ text = text, timeout = 4 })
|
|
return
|
|
end
|
|
-- Mirror cloudService.uploadBook's bookkeeping: clear
|
|
-- deleted_at, bump uploaded_at + updated_at, mark cloud
|
|
-- present, then push the row so peers see the metadata.
|
|
-- _clear_fields un-tombstones since Lua nil in the row
|
|
-- table would otherwise be preserved by upsertBook.
|
|
local now = math.floor(os.time() * 1000)
|
|
M._store:upsertBook({
|
|
hash = row.hash,
|
|
title = row.title,
|
|
cloud_present = 1,
|
|
uploaded_at = now,
|
|
updated_at = now,
|
|
_clear_fields = { "deleted_at" },
|
|
})
|
|
local pushed = {}
|
|
for k, v in pairs(row) do pushed[k] = v end
|
|
pushed.cloud_present = 1
|
|
pushed.uploaded_at = now
|
|
pushed.updated_at = now
|
|
pushed.deleted_at = nil
|
|
syncbooks.pushBook(pushed, {
|
|
sync_auth = opts.sync_auth,
|
|
sync_path = opts.sync_path,
|
|
settings = opts.settings,
|
|
}, function() M.refresh() end)
|
|
M.refresh()
|
|
end)
|
|
end)
|
|
end
|
|
|
|
-- Download: parity with BookDetailView's `book.uploadedAt && onDownload`.
|
|
-- BookDetailView has a single Download from Cloud button; we expose
|
|
-- Cover / Book / All from the user's request since the koplugin can
|
|
-- usefully download just the cover (e.g. to refresh the preview)
|
|
-- without also fetching the full file.
|
|
if on_cloud then
|
|
if not on_local then
|
|
add_row(_("Download Book"), function()
|
|
close()
|
|
downloadBookOnly(row, opts)
|
|
end)
|
|
end
|
|
add_row(_("Download Cover"), function()
|
|
close()
|
|
downloadCoverOnly(row, opts)
|
|
end)
|
|
if not on_local then
|
|
add_row(_("Download All"), function()
|
|
close()
|
|
downloadCoverOnly(row, opts, function()
|
|
downloadBookOnly(row, opts)
|
|
end)
|
|
end)
|
|
end
|
|
end
|
|
|
|
if #rows == 0 then return end
|
|
|
|
dialog = ButtonDialog:new{
|
|
title = row.title or "",
|
|
title_align = "center",
|
|
buttons = rows,
|
|
}
|
|
UIManager:show(dialog)
|
|
end
|
|
|
|
return M
|