Files
readest/apps/readest.koplugin/library/list_strip.lua
T
Huang Xin ed8956e9ed feat(koplugin): Readest Library view in KOReader (#4056)
* 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>
2026-05-04 23:12:34 +02:00

167 lines
6.7 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- list_strip.lua
-- List-mode group row builder. ListMenu's cover slot is hard-coded to
-- a square dimen.h × dimen.h. To show 4 mini-covers each at that same
-- square size (so each cell matches a single book row's cover slot)
-- we replace the row's widget tree wholesale.
--
-- Mirrors the standard ListMenuItem.update widget shape:
-- UnderlineContainer → VerticalGroup{
-- VerticalSpan,
-- HorizontalGroup{ cover-area, title, count },
-- }
-- but with the cover-area widened from 1× row-height to 4× row-height.
local group_covers = require("library.group_covers")
local M = {}
-- Mutates self._underline_container[1] in place. Called from the
-- patched ListMenuItem:update for entries with _readest_group set.
-- opts: { store, settings, orig_getBookInfo }
function M.build(self, opts)
local Geom = require("ui/geometry")
local Size = require("ui/size")
local Font = require("ui/font")
local HorizontalGroup = require("ui/widget/horizontalgroup")
local HorizontalSpan = require("ui/widget/horizontalspan")
local VerticalGroup = require("ui/widget/verticalgroup")
local VerticalSpan = require("ui/widget/verticalspan")
local CenterContainer = require("ui/widget/container/centercontainer")
local LeftContainer = require("ui/widget/container/leftcontainer")
local FrameContainer = require("ui/widget/container/framecontainer")
local TextWidget = require("ui/widget/textwidget")
local TextBoxWidget = require("ui/widget/textboxwidget")
local ImageWidget = require("ui/widget/imagewidget")
local BookInfoManager = require("bookinfomanager")
local entry = self.entry
local underline_h = self.underline_h or 1
local dimen_h = self.height - 2 * underline_h
local dimen_w = self.width
-- Each mini-cover gets a thin border (no padding) matching the
-- single-book treatment in ListMenuItem.update (coverbrowser/
-- listmenu.lua:258-269). cell_h subtracts the border on both sides
-- so the framed cell fits within the row height.
local border_size = Size.border.thin
local cell_h = dimen_h - 2 * border_size
local cell_w = cell_h
local n_cells = 4
local gap = math.floor(Size.padding.small / 2)
-- Resolve children with the current sort so the strip matches
-- what the user would see when drilling in.
local store = opts.store
local group = entry._readest_group
local settings = opts.settings or {}
local books = (store and group)
and store:listBooksInGroup(group._group_by, group.name, n_cells, {
sort_by = settings.library_sort_by,
sort_asc = settings.library_sort_ascending == true,
}) or {}
-- Slot width fixed per cell so all rows align horizontally; the
-- framed cover inside is sized to its rendered dimensions
-- (image_size + border) so the border hugs the cover with no
-- internal padding.
local slot_w = cell_w + 2 * border_size
local slot_h = cell_h + 2 * border_size
local strip_children = {}
for i = 1, n_cells do
if i > 1 then
strip_children[#strip_children + 1] = HorizontalSpan:new{ width = gap }
end
local book = books[i]
local cell_widget
if book then
local cover = group_covers.child_cover_bb(book, opts.orig_getBookInfo, BookInfoManager)
if cover then
-- Precompute scale_factor and pass it WITHOUT
-- width/height so ImageWidget:getSize returns the
-- actual scaled bb dims. With explicit width+height
-- it returns those exact dims, which would
-- re-introduce padding inside the frame.
local cw, ch = cover:getWidth(), cover:getHeight()
local _, _, scale_factor = BookInfoManager.getCachedCoverSize(
cw, ch, cell_w, cell_h)
local wimage = ImageWidget:new{
image = cover,
scale_factor = scale_factor,
}
wimage:_render()
local image_size = wimage:getSize()
cell_widget = CenterContainer:new{
dimen = Geom:new{ w = slot_w, h = slot_h },
FrameContainer:new{
width = image_size.w + 2 * border_size,
height = image_size.h + 2 * border_size,
margin = 0, padding = 0,
bordersize = border_size,
wimage,
},
}
end
end
if not cell_widget then
-- Empty slot keeps strip width consistent; no border so
-- it visually disappears (like a missing book).
cell_widget = HorizontalSpan:new{ width = slot_w }
end
strip_children[#strip_children + 1] = cell_widget
end
local strip_widget = HorizontalGroup:new(strip_children)
local strip_w = slot_w * n_cells + gap * (n_cells - 1)
local count_widget = TextWidget:new{
text = entry.mandatory or "",
face = Font:getFace("infont", 16),
}
local count_w = count_widget:getSize().w
local pad_after_strip = Size.padding.large
local pad_right = Size.padding.large
local title_w = math.max(0, dimen_w - strip_w - pad_after_strip - count_w - pad_right)
local title_widget = TextBoxWidget:new{
text = entry.text or "",
face = Font:getFace("smalltfont", 18),
width = title_w,
bold = true,
}
-- Wrap in LeftContainer with explicit dimen — ListMenuItem:paintTo
-- reads self[1][1][2].dimen for shortcut/dogear overlay positioning.
-- A bare HorizontalGroup never sets `dimen` so the access crashes.
local widget = LeftContainer:new{
dimen = Geom:new{ w = dimen_w, h = dimen_h },
HorizontalGroup:new{
align = "center",
strip_widget,
HorizontalSpan:new{ width = pad_after_strip },
CenterContainer:new{
dimen = Geom:new{ w = title_w, h = dimen_h },
title_widget,
},
CenterContainer:new{
dimen = Geom:new{ w = count_w, h = dimen_h },
count_widget,
},
HorizontalSpan:new{ width = pad_right },
},
}
if self._underline_container[1] then
self._underline_container[1]:free()
end
self._underline_container[1] = VerticalGroup:new{
VerticalSpan:new{ width = underline_h },
widget,
}
-- Tell ListMenu's _updateItemsBuildUI not to queue this item for
-- BIM background extraction (which would fork a subprocess to
-- scrape metadata from a file that doesn't actually exist).
self.bookinfo_found = true
end
return M