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>
172 lines
7.0 KiB
Lua
172 lines
7.0 KiB
Lua
-- libraryviewmenu.lua
|
|
-- The Library view-menu — a ButtonDialog with sections for View Mode,
|
|
-- Columns, Cover fit, Group by, Sort by, Rescan library, and Download
|
|
-- folder. Persists every choice to G_reader_settings.readest_sync.library_*
|
|
-- and notifies the caller (LibraryWidget) so it can re-query and re-render.
|
|
--
|
|
-- Live-KOReader-only; smoke-tested via the manual matrix.
|
|
|
|
local ButtonDialog = require("ui/widget/buttondialog")
|
|
local PathChooser = require("ui/widget/pathchooser")
|
|
local UIManager = require("ui/uimanager")
|
|
local _ = require("i18n")
|
|
|
|
local M = {}
|
|
|
|
-- Settings that affect the Menu's dimensions / mixin selection. Changing
|
|
-- one requires a full Menu rebuild; M.refresh() (which only re-queries
|
|
-- listBooks) wouldn't pick them up because nb_cols_portrait and the
|
|
-- _recalculateDimen / _updateItemsBuildUI pointers are baked in at
|
|
-- Menu construction time.
|
|
local LAYOUT_KEYS = {
|
|
library_view_mode = true,
|
|
library_columns = true,
|
|
library_rows = true,
|
|
}
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Helper: persist + invoke the right kind of refresh
|
|
-- ---------------------------------------------------------------------------
|
|
local function set(opts, key, value)
|
|
opts.settings[key] = value
|
|
G_reader_settings:saveSetting("readest_sync", opts.settings)
|
|
if LAYOUT_KEYS[key] and opts.on_layout_change then
|
|
opts.on_layout_change()
|
|
elseif opts.on_change then
|
|
opts.on_change()
|
|
end
|
|
end
|
|
|
|
-- Per-key default values that match what librarywidget assumes when the
|
|
-- user hasn't explicitly chosen anything. The view menu's ✓ marker needs
|
|
-- to know these so the implicit default still shows as selected.
|
|
local DEFAULTS = {
|
|
library_view_mode = "mosaic",
|
|
library_group_by = "groups",
|
|
library_sort_by = "last_read_at",
|
|
library_sort_ascending = false,
|
|
}
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Helper: render a single row of mutually-exclusive options. Each option's
|
|
-- label gets a "✓ " prefix when its value matches the current setting (or
|
|
-- the default, if the setting hasn't been explicitly set).
|
|
-- The dialog-close call is added by the back-fill loop in show() so this
|
|
-- helper doesn't need to capture the dialog ref.
|
|
-- ---------------------------------------------------------------------------
|
|
local function row(opts, key, choices)
|
|
local current = opts.settings[key]
|
|
if current == nil then current = DEFAULTS[key] end
|
|
local buttons = {}
|
|
for _i, choice in ipairs(choices) do
|
|
local label = choice.label
|
|
if choice.value == current then label = "✓ " .. label end
|
|
buttons[#buttons + 1] = {
|
|
text = label,
|
|
callback = function() set(opts, key, choice.value) end,
|
|
}
|
|
end
|
|
return buttons
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- show(opts) — opts: { settings, on_change }
|
|
-- ---------------------------------------------------------------------------
|
|
function M.show(opts)
|
|
local dialog
|
|
dialog = ButtonDialog:new{
|
|
title = _("Library view"),
|
|
title_align = "center",
|
|
buttons = {
|
|
-- View Mode
|
|
{ { text = _("View"), enabled = false } },
|
|
row(opts, "library_view_mode", {
|
|
{ label = _("Grid"), value = "mosaic" },
|
|
{ label = _("List"), value = "list" },
|
|
}),
|
|
|
|
-- Group by — alphabetical order matching Readest's web UI.
|
|
-- Values mirror Readest's LibraryGroupByType ("authors",
|
|
-- "groups", "series"); active_group_by() in librarywidget
|
|
-- maps these to the SQL column names ("author",
|
|
-- "group_name", "series") at the store boundary.
|
|
-- "Books" is the no-grouping mode (flat list).
|
|
{ { text = _("Group by"), enabled = false } },
|
|
row(opts, "library_group_by", {
|
|
{ label = _("Authors"), value = "authors" },
|
|
{ label = _("Books"), value = "none" },
|
|
{ label = _("Groups"), value = "groups" },
|
|
{ label = _("Series"), value = "series" },
|
|
}),
|
|
|
|
-- Sort by
|
|
{ { text = _("Sort by"), enabled = false } },
|
|
row(opts, "library_sort_by", {
|
|
{ label = _("Date Read"), value = "last_read_at" },
|
|
{ label = _("Date Added"), value = "created_at" },
|
|
{ label = _("Title"), value = "title" },
|
|
{ label = _("Author"), value = "author" },
|
|
}),
|
|
row(opts, "library_sort_ascending", {
|
|
{ label = _("Descending"), value = false },
|
|
{ label = _("Ascending"), value = true },
|
|
}),
|
|
|
|
-- Actions
|
|
{ { text = _("Actions"), enabled = false } },
|
|
{
|
|
{
|
|
text = _("Rescan library"),
|
|
callback = function()
|
|
local Trapper = require("ui/trapper")
|
|
local LibraryWidget = require("library.librarywidget")
|
|
local localscanner = require("library.localscanner")
|
|
Trapper:wrap(function()
|
|
localscanner.fullSidecarWalk({
|
|
store = LibraryWidget._store,
|
|
home_dir = G_reader_settings:readSetting("home_dir"),
|
|
})
|
|
LibraryWidget.refresh()
|
|
end)
|
|
end,
|
|
},
|
|
{
|
|
text = _("Download folder…"),
|
|
callback = function()
|
|
local picker
|
|
picker = PathChooser:new{
|
|
title = _("Pick a folder for downloaded books"),
|
|
path = opts.settings.library_download_dir
|
|
or G_reader_settings:readSetting("home_dir")
|
|
or require("datastorage"):getDataDir(),
|
|
select_directory = true,
|
|
select_file = false,
|
|
onConfirm = function(path)
|
|
set(opts, "library_download_dir", path)
|
|
end,
|
|
}
|
|
UIManager:show(picker)
|
|
end,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
-- Wrap every actionable callback with `UIManager:close(dialog)` so each
|
|
-- selection dismisses the dialog before applying its effect. Disabled
|
|
-- header rows have no callback and are skipped.
|
|
for _i, line in ipairs(dialog.buttons) do
|
|
for _j, btn in ipairs(line) do
|
|
if btn.callback then
|
|
local orig = btn.callback
|
|
btn.callback = function()
|
|
UIManager:close(dialog)
|
|
return orig()
|
|
end
|
|
end
|
|
end
|
|
end
|
|
UIManager:show(dialog)
|
|
end
|
|
|
|
return M
|