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>
This commit is contained in:
Huang Xin
2026-05-05 05:12:34 +08:00
committed by GitHub
parent cead0f42e0
commit ed8956e9ed
72 changed files with 12553 additions and 430 deletions
+55 -8
View File
@@ -79,14 +79,6 @@ jobs:
run: |
pnpm format:check || (pnpm format && git diff && exit 1)
- name: install playwright browsers
working-directory: apps/readest-app
run: npx playwright install --with-deps chromium
- name: run web tests
working-directory: apps/readest-app
run: pnpm test:pr:web
- name: run lint
working-directory: apps/readest-app
run: |
@@ -97,6 +89,61 @@ jobs:
run: |
pnpm build-web && pnpm check:all
test_web_app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@v6
- name: setup node
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: cache Next.js build
uses: actions/cache@v5
with:
path: apps/readest-app/.next/cache
key: nextjs-test-${{ github.sha }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
nextjs-test-${{ github.sha }}-
nextjs-test-
- name: install Dependencies
working-directory: apps/readest-app
run: |
pnpm install && pnpm setup-vendors
- name: install LuaJIT + busted (for koplugin tests)
run: |
sudo apt-get update
# luajit — pnpm test:lua
# luarocks/libsqlite3-dev — required to build lsqlite3complete
sudo apt-get install -y luajit luarocks libsqlite3-dev
# Install busted + the SQLite binding the LibraryStore specs use,
# both pinned to Lua 5.1 (LuaJIT-compatible). System-wide install
# so `luarocks --lua-version=5.1 path` (sourced by
# scripts/test-koplugin.mjs) picks them up.
sudo luarocks --lua-version=5.1 install busted
sudo luarocks --lua-version=5.1 install lsqlite3complete
- name: install playwright browsers
working-directory: apps/readest-app
run: npx playwright install --with-deps chromium
- name: run web tests
working-directory: apps/readest-app
run: pnpm test:pr:web
- name: run koplugin tests
working-directory: apps/readest-app
run: pnpm test:lua
build_tauri_app:
runs-on: ubuntu-latest
env:
+12 -1
View File
@@ -98,8 +98,19 @@ jobs:
meta_file="apps/readest.koplugin/_meta.lua"
perl -i -pe "s/^}/ version = \"${version}\",\n}/" "${meta_file}"
# Exclude dev-only artifacts from the published plugin zip:
# scripts/ — i18n + build helpers
# docs/ — design notes
# spec/ — busted test suite
# .busted — busted runner config
# Mirror these in apps/readest.koplugin/scripts/build-koplugin.mjs
# for local builds.
cd apps
zip -r ../${plugin_zip} readest.koplugin
zip -r ../${plugin_zip} readest.koplugin \
-x 'readest.koplugin/scripts/*' \
'readest.koplugin/docs/*' \
'readest.koplugin/spec/*' \
'readest.koplugin/.busted'
cd ..
echo "Uploading ${plugin_zip} to GitHub release"
@@ -2,7 +2,8 @@
Before marking work complete, all applicable checks must pass:
1. `pnpm test` — unit tests
2. `pnpm lint`ESLint
3. `pnpm fmt:check`Rust format check (only when `src-tauri/` files changed)
4. `pnpm clippy:check` — Rust lint (only when `src-tauri/` files changed)
1. `pnpm test` — unit tests (vitest)
2. `pnpm lint`Biome + tsgo (also runs `pnpm lint:lua` if luajit is installed)
3. `pnpm test:lua`busted unit tests for `apps/readest.koplugin/spec/` (only when koplugin Lua files changed; soft-skips when busted/luajit not installed)
4. `pnpm fmt:check` — Rust format check (only when `src-tauri/` files changed)
5. `pnpm clippy:check` — Rust lint (only when `src-tauri/` files changed)
+2 -1
View File
@@ -18,7 +18,8 @@
"dev-ios": "tauri ios build -- --features devtools && ideviceinstaller -i src-tauri/gen/apple/build/arm64/Readest.ipa",
"i18n:extract": "i18next-scanner --config i18next-scanner.config.cjs",
"lint": "tsgo --noEmit && biome check . && pnpm lint:lua",
"lint:lua": "node scripts/lint-koplugin.js",
"lint:lua": "node ../readest.koplugin/scripts/lint-koplugin.mjs",
"test:lua": "node ../readest.koplugin/scripts/test-koplugin.mjs",
"test": "dotenv -e .env -e .env.test.local -- vitest",
"test:coverage": "dotenv -e .env -e .env.test.local -- vitest run --coverage",
"test:browser": "dotenv -e .env -e .env.test.local -- vitest run --config vitest.browser.config.mts",
+9
View File
@@ -0,0 +1,9 @@
return {
default = {
ROOT = { "spec" },
pattern = "_spec",
helper = "spec/spec_helper",
verbose = true,
["recursive-tests"] = true,
},
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M 16 6 C 13.351563 6 11.050781 7.238281 9.40625 9.0625 C 9.269531 9.046875 9.148438 9 9 9 C 6.800781 9 5 10.800781 5 13 C 3.269531 14.054688 2 15.835938 2 18 C 2 21.300781 4.699219 24 8 24 L 13 24 L 13 22 L 8 22 C 5.78125 22 4 20.21875 4 18 C 4 16.339844 5.007813 14.921875 6.4375 14.3125 L 7.125 14.03125 L 7.03125 13.28125 C 7.011719 13.117188 7 13.023438 7 13 C 7 11.882813 7.882813 11 9 11 C 9.140625 11 9.296875 11.019531 9.46875 11.0625 L 10.09375 11.21875 L 10.46875 10.71875 C 11.75 9.074219 13.75 8 16 8 C 19.277344 8 22.011719 10.253906 22.78125 13.28125 L 22.96875 14.0625 L 23.8125 14.03125 C 24.023438 14.019531 24.070313 14 24 14 C 26.21875 14 28 15.78125 28 18 C 28 20.21875 26.21875 22 24 22 L 19 22 L 19 24 L 24 24 C 27.300781 24 30 21.300781 30 18 C 30 14.84375 27.511719 12.316406 24.40625 12.09375 C 23.183594 8.574219 19.925781 6 16 6 Z M 15 18 L 15 26 L 12 26 L 16 30 L 20 26 L 17 26 L 17 18 Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M 16 7 C 13.351563 7 11.050781 8.238281 9.40625 10.0625 C 9.269531 10.046875 9.148438 10 9 10 C 6.800781 10 5 11.800781 5 14 C 3.269531 15.054688 2 16.835938 2 19 C 2 22.300781 4.699219 25 8 25 L 13 25 L 13 23 L 8 23 C 5.78125 23 4 21.21875 4 19 C 4 17.339844 5.007813 15.921875 6.4375 15.3125 L 7.125 15.03125 L 7.03125 14.28125 C 7.011719 14.117188 7 14.023438 7 14 C 7 12.882813 7.882813 12 9 12 C 9.140625 12 9.296875 12.019531 9.46875 12.0625 L 10.09375 12.21875 L 10.46875 11.71875 C 11.75 10.074219 13.75 9 16 9 C 19.277344 9 22.011719 11.253906 22.78125 14.28125 L 22.96875 15.0625 L 23.8125 15.03125 C 24.023438 15.019531 24.070313 15 24 15 C 26.21875 15 28 16.78125 28 19 C 28 21.21875 26.21875 23 24 23 L 19 23 L 19 25 L 24 25 C 27.300781 25 30 22.300781 30 19 C 30 15.84375 27.511719 13.316406 24.40625 13.09375 C 23.183594 9.574219 19.925781 7 16 7 Z M 16 15 L 12 19 L 15 19 L 15 27 L 17 27 L 17 19 L 20 19 Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+216
View File
@@ -0,0 +1,216 @@
-- bim_patch.lua
-- Two global monkey-patches that make our cloud-only / group entries
-- coexist with KOReader's coverbrowser pipeline:
--
-- 1. BookInfoManager:getBookInfo — intercepts readest-cloud:// and
-- readest-group:// URIs. Without this, MosaicMenuItem's
-- "info incomplete → schedule background extraction" path fires;
-- BIM forks a subprocess that crashes at bookinfomanager.lua:492
-- trying to lfs.attributes the synthetic URI.
--
-- 2. ListMenuItem:update + paintTo — list-mode group rows use a
-- custom widget tree (4-cell cover strip), and book rows get a
-- cloud-up/cloud-down icon overlay below the format text.
--
-- ListMenuItem is `local` to coverbrowser/listmenu.lua, so we reach
-- it via debug.getupvalue on the exported _updateItemsBuildUI mixin.
local logger = require("logger")
local cloud_covers = require("library.cloud_covers")
local group_covers = require("library.group_covers")
local cloud_icons = require("library.cloud_icons")
local list_strip = require("library.list_strip")
local M = {}
local _bim_patched = false
local _list_item_patched = false
local _orig_get_book_info = nil -- captured pre-patch; needed by list_strip
-- Tracks file_paths that came from our LibraryStore (= entries we
-- render in the Library widget). The BIM patch tags returned info
-- with _no_provider so ListMenuItem.update renders mandatory verbatim
-- (the format string), keeping right-side text right-aligned with
-- cloud rows that already use _no_provider.
local _library_local_paths = {}
-- Sentinel used by entry_from_row to flag cloud-only rows. Re-exported
-- here so the patches can read it without a circular libraryitem import.
M.CLOUD_ONLY_FLAG = "_readest_cloud_only"
M.LOCAL_ONLY_FLAG = "_readest_local_only"
function M.register_local_path(path)
_library_local_paths[path] = true
end
function M.orig_get_book_info()
return _orig_get_book_info
end
-- Patch BIM:getBookInfo with a router that dispatches on URI prefix.
-- Idempotent.
local function patch_bim(opts)
if _bim_patched then return end
local ok, BIM = pcall(require, "bookinfomanager")
if not ok or not BIM then
logger.warn("ReadestLibrary bim_patch: bookinfomanager not available")
return
end
_bim_patched = true
_orig_get_book_info = BIM.getBookInfo
local function build_cloud_info(filepath, do_cover_image)
local hash = cloud_covers.hash_from_uri(filepath)
local meta = cloud_covers.get_meta(hash)
local info = {
has_meta = true,
cover_fetched = true,
ignore_cover = false,
title = meta.title,
authors = meta.author,
has_cover = false,
-- Render mandatory verbatim (no "<filetype> size" prefix).
_no_provider = true,
}
if do_cover_image then
local bb = cloud_covers.load_cover_bb(hash)
if bb then
local w, h = bb:getWidth(), bb:getHeight()
info.cover_bb = bb
info.cover_w = w
info.cover_h = h
-- BookInfoManager.isCachedCoverInvalid (bookinfomanager.lua:1017)
-- crashes if cover_sizetag is nil. Format is "<w>x<h>".
info.cover_sizetag = w .. "x" .. h
info.has_cover = true
else
-- Lazy fetch: only currently-visible cells trigger.
cloud_covers.trigger_download(hash)
end
end
return info
end
local function build_group_info(filepath, do_cover_image)
local group_by, value, cache_key, shape = group_covers.parse_uri(filepath)
local meta = cloud_covers.get_meta(filepath)
local info = {
has_meta = true,
cover_fetched = true,
ignore_cover = false,
title = meta.title,
authors = meta.author,
has_cover = false,
_no_provider = true,
}
if do_cover_image and group_by and value and cache_key then
local LibraryWidget = package.loaded["library.librarywidget"]
local store = LibraryWidget and LibraryWidget._store
local settings = M._opts and M._opts.settings or {}
local bb = group_covers.serve_or_compose(
group_by, value, cache_key, shape,
store, settings, _orig_get_book_info, BIM)
if bb then
local w, h = bb:getWidth(), bb:getHeight()
info.cover_bb = bb
info.cover_w = w
info.cover_h = h
info.cover_sizetag = w .. "x" .. h
info.has_cover = true
end
end
return info
end
function BIM:getBookInfo(filepath, do_cover_image)
if type(filepath) == "string" then
if filepath:sub(1, #cloud_covers.URI_PREFIX) == cloud_covers.URI_PREFIX then
return build_cloud_info(filepath, do_cover_image)
end
if filepath:sub(1, #group_covers.URI_PREFIX) == group_covers.URI_PREFIX then
return build_group_info(filepath, do_cover_image)
end
end
-- Real local file: forward to the original BIM, then add
-- _no_provider for paths that came from our LibraryStore so
-- the right-side text right-aligns with cloud rows. Shallow
-- copy first so we don't mutate BIM's cached entry.
local result = _orig_get_book_info(self, filepath, do_cover_image)
if result and type(filepath) == "string" and _library_local_paths[filepath] then
local copy = {}
for k, v in pairs(result) do copy[k] = v end
copy._no_provider = true
return copy
end
return result
end
end
-- Locate listmenu's local ListMenuItem class via its captured upvalue
-- on the exported _updateItemsBuildUI mixin. Cheapest path that
-- doesn't require modifying coverbrowser.koplugin or copy-pasting the
-- ~50-line build loop.
local function patch_list_menu_item()
if _list_item_patched then return end
local debug = require("debug")
local ok, ListMenu = pcall(require, "listmenu")
if not ok or type(ListMenu._updateItemsBuildUI) ~= "function" then return end
local ListMenuItem
for i = 1, 50 do
local name, val = debug.getupvalue(ListMenu._updateItemsBuildUI, i)
if not name then break end
if name == "ListMenuItem" and type(val) == "table" then
ListMenuItem = val
break
end
end
if not ListMenuItem or type(ListMenuItem.update) ~= "function" then
logger.warn("ReadestLibrary: couldn't locate ListMenuItem class for patching")
return
end
-- Custom group-row widget tree (wider cover strip).
local orig_update = ListMenuItem.update
function ListMenuItem:update()
if self.entry and self.entry._readest_group then
local LibraryWidget = package.loaded["library.librarywidget"]
return list_strip.build(self, {
store = LibraryWidget and LibraryWidget._store,
settings = M._opts and M._opts.settings,
orig_getBookInfo = _orig_get_book_info,
})
end
return orig_update(self)
end
-- Cloud icon overlay painted on top of the standard widget tree.
-- cloud-only (cloud_present=1, local_present=0) → download icon
-- local-only (cloud_present=0, local_present=1) → upload icon
local orig_paint = ListMenuItem.paintTo
function ListMenuItem:paintTo(bb, x, y)
orig_paint(self, bb, x, y)
if not self.entry then return end
if self.entry[M.CLOUD_ONLY_FLAG] and cloud_icons.has_icon("dl") then
cloud_icons.paint(self, bb, x, y, "dl")
elseif self.entry[M.LOCAL_ONLY_FLAG] and cloud_icons.has_icon("up") then
cloud_icons.paint(self, bb, x, y, "up")
end
end
_list_item_patched = true
logger.info("ReadestLibrary: patched ListMenuItem update + paintTo")
end
function M.install(opts)
M._opts = opts or {}
cloud_covers.set_opts(M._opts)
logger.info("ReadestLibrary bim_patch.install: opts="
.. (opts and "set" or "nil")
.. " sync_auth=" .. tostring(opts and opts.sync_auth ~= nil)
.. " bim_patched_before=" .. tostring(_bim_patched))
-- Both patches are idempotent so the order + repeated calls are
-- safe. ListMenuItem first since it doesn't need BIM.
patch_list_menu_item()
patch_bim()
end
return M
@@ -0,0 +1,213 @@
-- cloud_covers.lua
-- Per-book cover lifecycle for cloud-only rows. Owns the on-disk
-- <hash>.png cache, the synthetic readest-cloud:// URI scheme, and
-- the single-slot async download queue that fetches missing covers
-- from Readest storage when their cells become visible.
--
-- The disk cache is shared with hybrid (cloud+local) rows so a
-- previously-downloaded cloud cover gets reused for the local
-- presentation of the same hash without a re-extraction pass.
local logger = require("logger")
local M = {}
M.URI_PREFIX = "readest-cloud://"
-- Synthetic-info metadata cache: keyed by either hash (cloud-only
-- entries) or the full URI string (group entries). Values:
-- { title, author }
-- The patched BIM reads these to fill in title/authors so MosaicMenu's
-- FakeCover renders meaningful text instead of "?".
local _meta = {}
-- Download lifecycle state
local _cover_pending = {} -- hash → true while a download is in flight
local _missing_covers = {} -- hash → true after a 404 (don't keep retrying)
local _visible_hashes = nil -- set of hashes on the current Menu page; nil = no filter
local _refresh_pending = false -- coalesces multiple cover-completion refreshes
local _download_queue = {} -- FIFO list of pending hashes
local _downloading = false -- gate: only one socket.http active at a time
-- Sync-auth opts (set via M.set_opts at install time). Holds
-- sync_auth, sync_path, settings — needed to drive syncbooks.downloadCover.
local _opts = nil
function M.set_opts(opts)
_opts = opts
end
function M.set_meta(key, meta)
_meta[key] = meta
end
function M.get_meta(key)
return _meta[key] or {}
end
function M.covers_dir()
local DataStorage = require("datastorage")
return DataStorage:getSettingsDir() .. "/readest_covers"
end
local function cover_path_for(hash)
return M.covers_dir() .. "/" .. hash .. ".png"
end
-- Strip the .ext suffix so callers get just the partial-md5. The .ext
-- is encoded into the URI so listmenu's filemanagerutil.splitFileNameType
-- returns a non-nil filetype (listmenu.lua:316); without it the
-- right-column composition crashes on string concat.
function M.hash_from_uri(filepath)
local rest = filepath:sub(#M.URI_PREFIX + 1)
return (rest:match("^([^.]+)") or rest)
end
-- Load <hash>.png from disk into a fresh blitbuffer. Returns nil if the
-- file doesn't exist or fails to decode. Caller owns the bb (ImageWidget
-- will free it). No in-memory cache — ImageWidget treats the bb as
-- disposable, so sharing one across paints leads to use-after-free.
function M.load_cover_bb(hash)
local lfs = require("libs/libkoreader-lfs")
local path = cover_path_for(hash)
if lfs.attributes(path, "mode") ~= "file" then return nil end
local ok, RenderImage = pcall(require, "ui/renderimage")
if not ok then return nil end
local ok2, bb = pcall(RenderImage.renderImageFile, RenderImage, path, false)
if not ok2 or not bb then return nil end
return bb
end
-- "<hash8> '<title>'" formatted log tag — searchable by either id.
local function tag_for(hash)
local meta = _meta[hash] or {}
return hash:sub(1, 8) .. " '" .. tostring(meta.title or "?") .. "'"
end
-- Pump the next entry off _download_queue. Re-entrant-safe via the
-- _downloading gate. Filters known-404 hashes and hashes that have
-- scrolled off-screen since they were enqueued.
local function process_queue()
if _downloading then return end
local hash
repeat
hash = table.remove(_download_queue, 1)
if not hash then return end
if _missing_covers[hash] then
_cover_pending[hash] = nil
hash = nil
elseif _visible_hashes and not _visible_hashes[hash] then
logger.dbg("ReadestLibrary cover dequeue skip: " .. tag_for(hash)
.. " no longer on visible page")
_cover_pending[hash] = nil
hash = nil
end
until hash
_downloading = true
logger.info("ReadestLibrary cover download: starting " .. tag_for(hash))
local syncbooks = require("library.syncbooks")
syncbooks.downloadCover(
{ hash = hash },
{
sync_auth = _opts and _opts.sync_auth,
sync_path = _opts and _opts.sync_path,
settings = _opts and _opts.settings,
covers_dir = M.covers_dir(),
},
function(success, path_or_err, status)
_cover_pending[hash] = nil
_downloading = false
if not success then
if status == 404 then
_missing_covers[hash] = true
logger.info("ReadestLibrary cover " .. tag_for(hash)
.. " — no cover on server (404), won't retry")
else
logger.warn("ReadestLibrary cover " .. tag_for(hash)
.. " download failed: " .. tostring(path_or_err)
.. " status=" .. tostring(status))
end
else
logger.info("ReadestLibrary cover " .. tag_for(hash)
.. " saved → " .. tostring(path_or_err))
-- Coalesce refresh: multiple covers landing in the same
-- tick still get one repaint, not N flickering redraws.
if not _refresh_pending then
_refresh_pending = true
local UIManager = require("ui/uimanager")
UIManager:nextTick(function()
_refresh_pending = false
local ok, LibraryWidget = pcall(require, "library.librarywidget")
if ok and LibraryWidget._menu then LibraryWidget.refresh() end
end)
end
end
-- Yield to the UI loop before pumping the next one.
local UIManager = require("ui/uimanager")
UIManager:nextTick(process_queue)
end)
end
-- Idempotent against in-flight requests and known-404 hashes. Only
-- fires for hashes the caller has marked as visible via M.set_visible_hashes
-- — otherwise paint stragglers / poll loops for off-screen items would
-- queue downloads the user can't see.
function M.trigger_download(hash)
if _cover_pending[hash] then
logger.dbg("ReadestLibrary cover skip: " .. tag_for(hash) .. " already in flight")
return
end
if _missing_covers[hash] then
logger.dbg("ReadestLibrary cover skip: " .. tag_for(hash) .. " known 404")
return
end
if not _opts or not _opts.sync_auth then
logger.warn("ReadestLibrary cover skip: " .. tag_for(hash)
.. " — set_opts not called yet")
return
end
if _visible_hashes and not _visible_hashes[hash] then
logger.dbg("ReadestLibrary cover skip: " .. tag_for(hash) .. " not on visible page")
return
end
_cover_pending[hash] = true
table.insert(_download_queue, hash)
logger.dbg("ReadestLibrary cover queued: " .. tag_for(hash)
.. " (queue len=" .. #_download_queue .. ")")
process_queue()
end
-- Set of hashes on the current Menu page; only these may trigger
-- downloads. Pass nil to disable the filter (e.g. when the Library
-- closes and the patched BIM might still be invoked from elsewhere).
function M.set_visible_hashes(menu, cloud_only_flag)
if not menu then
logger.dbg("ReadestLibrary set_visible_hashes: cleared (menu nil)")
_visible_hashes = nil
return
end
local set = {}
local count = 0
local page = menu.page or 1
local perpage = menu.perpage or 1
local items = menu.item_table or {}
local first = (page - 1) * perpage + 1
local last = math.min(first + perpage - 1, #items)
for i = first, last do
local entry = items[i]
if entry and entry[cloud_only_flag] and type(entry.file) == "string" then
local hash = M.hash_from_uri(entry.file)
set[hash] = true
count = count + 1
end
end
_visible_hashes = set
logger.info("ReadestLibrary set_visible_hashes: page=" .. page
.. " range=" .. first .. ".." .. last
.. " cloud_only_visible=" .. count
.. " (item_table size=" .. #items .. ")")
end
return M
@@ -0,0 +1,88 @@
-- cloud_icons.lua
-- Per-row cloud-up/cloud-down overlay icons painted on top of the
-- standard ListMenuItem widget tree. Loaded once from bundled SVGs
-- and cached as IconWidget instances for reuse across all rows.
local M = {}
-- Resolve apps/readest.koplugin root via debug.getinfo on this file's
-- own source path (same trick as zen_ui's plugin_root.lua). Needed
-- because our bundled icons aren't in any of KOReader's ICONS_DIRS,
-- so we load them by absolute path through ImageWidget instead of
-- IconWidget's name-based lookup.
local _plugin_root = (function()
local src = debug.getinfo(1, "S").source or ""
local path = (src:sub(1, 1) == "@")
and src:sub(2):match("^(.*)/library/[^/]+$") or nil
if path and path:sub(1, 1) ~= "/" then
local ok, lfs = pcall(require, "libs/libkoreader-lfs")
local cwd = ok and lfs and lfs.currentdir()
if cwd then path = cwd .. "/" .. path end
end
return path
end)()
local ICON_FILES = {
dl = _plugin_root and (_plugin_root .. "/icons/cloud_download.svg"),
up = _plugin_root and (_plugin_root .. "/icons/cloud_upload.svg"),
}
-- Per-icon cache: {key → {widget, size_loaded}}. IconWidget loads +
-- caches its bb on first render so we only pay the SVG decode once
-- per icon size.
local _cache = {}
function M.has_icon(kind)
return ICON_FILES[kind] ~= nil
end
local function get_widget(kind, target_size)
local entry = _cache[kind]
if entry and entry.size_loaded == target_size then
return entry.widget
end
if entry and entry.widget then
local prev = entry.widget
entry.widget = nil
pcall(function() prev:free() end)
end
local file = ICON_FILES[kind]
if not file then return nil end
local ok, ImageWidget = pcall(require, "ui/widget/imagewidget")
if not ok then return nil end
local widget = ImageWidget:new{
file = file,
width = target_size,
height = target_size,
scale_factor = 0, -- aspect-preserving fit
alpha = true, -- preserve SVG transparency
is_icon = true,
}
_cache[kind] = { widget = widget, size_loaded = target_size }
return widget
end
-- Paint the cloud icon at the right edge of the row, in the slot
-- where ListMenuItem normally draws its second line of right-side
-- text (wpageinfo, e.g. "1% of 1424 pages"). For row height dimen.h,
-- the standard wright VerticalGroup is roughly:
-- VerticalSpan(2) + fileinfo(~h*0.28) + pageinfo(~h*0.28)
-- center-aligned, which lands pageinfo at ~y + 0.5*h. Mirroring that
-- keeps the format label and the cloud icon visually stacked at the
-- right edge with consistent padding.
function M.paint(item, bb, x, y, kind)
local Screen = require("device").screen
local icon_size = math.floor(item.height * 0.28)
local icon = get_widget(kind, icon_size)
if not icon then return end
-- _render so getSize returns the actual scaled dims, not the
-- requested width/height.
icon:_render()
local s = icon:getSize()
local pad_right = Screen:scaleBySize(10)
local icon_x = x + item.width - pad_right - s.w
local icon_y = y + math.floor(item.height * 0.5)
icon:paintTo(bb, icon_x, icon_y)
end
return M
@@ -0,0 +1,116 @@
-- coverprovider.lua
-- Resolves a Library row to a renderable cover. For local books we lean on
-- the bundled coverbrowser.koplugin's BookInfoManager (it already has a
-- battle-tested extraction subprocess + zstd-compressed BLOB cache); for
-- cloud-only books we download `cover.png` from Readest storage and render
-- it via plain ImageWidget{file=path}.
--
-- Hard dependency on coverbrowser, mirroring zen_ui's pattern: at plugin
-- init we check `coverbrowser_loaded()`. If the user has it disabled, the
-- caller offers to enable it; until then, every cell renders FakeCover
-- (no degraded grid mode).
--
-- The pure helpers (coverbrowser_loaded, cached_cover_path, MISSING) are
-- exported and unit-tested. The actual blitbuffer-returning calls
-- (get_local_cover, get_cloud_cover) require live KOReader and are
-- exercised manually.
local logger = require("logger")
local M = {}
-- ---------------------------------------------------------------------------
-- Constants
-- ---------------------------------------------------------------------------
-- Sentinel written into books.cover_path when a cloud cover download
-- returned 404. Distinguishable from any real path so callers can short-
-- circuit FakeCover rendering without re-attempting the download every
-- frame.
M.MISSING = "_missing"
-- ---------------------------------------------------------------------------
-- coverbrowser_loaded
-- ---------------------------------------------------------------------------
-- True if `require("covermenu")` will resolve. coverbrowser.koplugin is the
-- only thing that ships the covermenu module, so this doubles as a "is the
-- plugin enabled?" check.
function M.coverbrowser_loaded()
if package.loaded["covermenu"] then return true end
if package.preload["covermenu"] then return true end
-- pcall(require) costs <1ms but populates package.loaded on success;
-- callers shouldn't pay that cost twice.
local ok = pcall(require, "covermenu")
return ok == true
end
-- ---------------------------------------------------------------------------
-- cached_cover_path
-- ---------------------------------------------------------------------------
-- Where we store a downloaded cloud cover. Flat layout under the plugin's
-- cache dir, keyed by hash so collisions can't happen.
function M.cached_cover_path(covers_dir, hash)
if not covers_dir or covers_dir == "" then return nil end
if not hash or hash == "" then return nil end
return covers_dir .. "/" .. hash .. ".png"
end
-- ---------------------------------------------------------------------------
-- get_local_cover(file_path) → blitbuffer or nil
-- ---------------------------------------------------------------------------
-- Live-KOReader-only. Looks up the book in BookInfoManager's cache; if it's
-- not there, kicks off background extraction (which writes the bb back into
-- BIM next time the user paints). Returns nil immediately on cache miss so
-- the caller can render FakeCover meanwhile.
function M.get_local_cover(file_path)
if not file_path then return nil end
local ok, BIM = pcall(require, "bookinfomanager")
if not ok or not BIM then return nil end
local info = BIM:getBookInfo(file_path, true)
if info and info.cover_bb then return info.cover_bb end
-- Cache miss: ask BIM to extract in the background.
BIM:extractInBackground({ { file_path } })
return nil
end
-- ---------------------------------------------------------------------------
-- get_cloud_cover(book, opts, on_ready) — async; opts must include sync auth
-- + a covers_dir + a settings table.
-- ---------------------------------------------------------------------------
-- If the cover is already on disk, returns its path synchronously.
-- Otherwise schedules a download via syncbooks.downloadCover and invokes
-- on_ready(path_or_missing) when it completes. on_ready may be nil when the
-- caller just wants to kick off the prefetch.
function M.get_cloud_cover(book, opts, on_ready)
if not book or not book.hash then
if on_ready then on_ready(nil) end
return nil
end
local lfs = require("libs/libkoreader-lfs")
local cached = M.cached_cover_path(opts.covers_dir, book.hash)
if cached and lfs.attributes(cached, "mode") == "file" then
return cached
end
-- Don't retry a known-missing cover (avoids a 404 storm on every paint).
if book.cover_path == M.MISSING then return nil end
local syncbooks = require("library.syncbooks")
syncbooks.downloadCover(book, opts, function(success, path_or_err, status)
if success then
if on_ready then on_ready(path_or_err) end
else
if status == 404 then
if on_ready then on_ready(M.MISSING) end
else
logger.dbg("ReadestLibrary cover download failed:", path_or_err, status)
if on_ready then on_ready(nil) end
end
end
end)
return nil -- caller renders FakeCover until on_ready fires
end
return M
+21
View File
@@ -0,0 +1,21 @@
-- exts.lua
-- Book format → file extension mapping.
--
-- Verbatim port of `apps/readest-app/src/libs/document.ts` `EXTS`. The cloud
-- `fileKey` builder in syncbooks.lua uses this to derive the `.{ext}` suffix
-- from a `/sync` row's `format` column. Keep in sync with the web side.
local EXTS = {
EPUB = "epub",
PDF = "pdf",
MOBI = "mobi",
AZW = "azw",
AZW3 = "azw3",
CBZ = "cbz",
FB2 = "fb2",
FBZ = "fbz",
TXT = "txt",
MD = "md",
}
return EXTS
@@ -0,0 +1,203 @@
-- group_covers.lua
-- macOS-style folder previews: a 2x2 mosaic of the first N child book
-- covers, served as a synthetic readest-group:// URI through the
-- patched BookInfoManager. Composites are cached on disk under
-- <settings>/readest_group_covers/<key>.png
-- with the cache key derived from the actual first-N hashes, so any
-- change to the group's content (sort flip, add/remove, reordering
-- bump) auto-invalidates the cached PNG.
local logger = require("logger")
local cloud_covers = require("library.cloud_covers")
local M = {}
M.URI_PREFIX = "readest-group://"
-- Bump when the composite layout/dimensions change so existing on-disk
-- composites get regenerated on next paint instead of serving the old
-- aspect ratio forever.
local CACHE_VERSION = 3
-- Layouts:
-- "grid" — 2x2, 360x480 (3:4 — typical book-cover aspect).
-- "list" — 2x2, 480x480 (square — matches ListMenu's rigid square
-- cover slot, so the composite fills it vertically and each
-- mini-cover stays book-shaped instead of getting squished).
M.LAYOUTS = {
grid = { target_w = 360, target_h = 480, cols = 2, rows = 2 },
list = { target_w = 480, target_h = 480, cols = 2, rows = 2 },
}
local function group_covers_dir()
local DataStorage = require("datastorage")
return DataStorage:getSettingsDir() .. "/readest_group_covers"
end
local function group_cover_path(cache_key)
return group_covers_dir() .. "/" .. cache_key .. "_v" .. CACHE_VERSION .. ".png"
end
-- "Asimov" → "417369..." — filesystem-safe regardless of slashes,
-- colons, etc. in the original group value.
local function hex_encode(s)
return (s:gsub(".", function(c) return string.format("%02x", string.byte(c)) end))
end
local function hex_decode(hex)
return (hex:gsub("..", function(h) return string.char(tonumber(h, 16)) end))
end
-- shape ∈ {"grid", "list"} — controls the composite layout. Defaults
-- to "grid" for backward compat with older callers.
function M.build_uri(group_by, value, shape)
return M.URI_PREFIX .. group_by .. ":" .. hex_encode(value)
.. ":" .. (shape or "grid") .. ".png"
end
-- Returns group_by, value, cache_key, shape; nil if not a group URI.
-- cache_key here is the static "identity" portion; the BIM patch
-- appends the actual first-N hashes for content-based invalidation.
function M.parse_uri(uri)
if uri:sub(1, #M.URI_PREFIX) ~= M.URI_PREFIX then return nil end
local body = uri:sub(#M.URI_PREFIX + 1)
if body:sub(-4) == ".png" then body = body:sub(1, -5) end
local parts = {}
for p in body:gmatch("[^:]+") do parts[#parts + 1] = p end
if #parts < 2 then return nil end
local group_by = parts[1]
local hex = parts[2]
local shape = parts[3] or "grid"
local value = hex_decode(hex)
return group_by, value, group_by .. "_" .. hex .. "_" .. shape, shape
end
-- Pull a usable cover bb for a single child book during composition.
-- Tries (in order):
-- 1. local file via the original BIM cache (already-cached only;
-- no extraction triggered)
-- 2. cloud cover .png we previously downloaded
-- Returns nil if neither path produces one. Caller owns the bb.
function M.child_cover_bb(book, orig_getBookInfo, BIM)
if book.local_present == 1 and book.file_path and orig_getBookInfo then
local ok, info = pcall(orig_getBookInfo, BIM, book.file_path, true)
if ok and info and info.has_cover and info.cover_bb then
-- BIM hands us a cached bb whose ownership it keeps; we copy
-- before scaling so the BIM cache stays intact when our
-- composition pipeline frees what it received.
local Blitbuffer = require("ffi/blitbuffer")
local copy = Blitbuffer.new(info.cover_bb:getWidth(),
info.cover_bb:getHeight(),
info.cover_bb:getType())
copy:blitFrom(info.cover_bb, 0, 0, 0, 0,
info.cover_bb:getWidth(), info.cover_bb:getHeight())
return copy
end
end
return cloud_covers.load_cover_bb(book.hash)
end
-- Compose up to N child covers into a mosaic and write the PNG to
-- disk. Returns the loaded composite as a fresh bb, or nil on total
-- failure (no usable child covers, or PNG write failure).
local function compose(books, dest_path, shape, orig_getBookInfo, BIM)
if #books == 0 then return nil end
local layout = M.LAYOUTS[shape] or M.LAYOUTS.grid
local target_w, target_h = layout.target_w, layout.target_h
local cols, rows = layout.cols, layout.rows
local max_cells = cols * rows
local Blitbuffer = require("ffi/blitbuffer")
local target = Blitbuffer.new(target_w, target_h, Blitbuffer.TYPE_BBRGB32)
target:fill(Blitbuffer.COLOR_WHITE)
local gap = 8
local cell_w = math.floor((target_w - (cols - 1) * gap) / cols)
local cell_h = math.floor((target_h - (rows - 1) * gap) / rows)
local placed = 0
for i = 1, math.min(max_cells, #books) do
local book = books[i]
local cover = M.child_cover_bb(book, orig_getBookInfo, BIM)
if cover then
local row = math.floor((i - 1) / cols)
local col = (i - 1) % cols
local dx = col * (cell_w + gap)
local dy = row * (cell_h + gap)
local ok_scale, scaled = pcall(cover.scale, cover, cell_w, cell_h)
if ok_scale and scaled then
target:blitFrom(scaled, dx, dy, 0, 0, cell_w, cell_h)
scaled:free()
placed = placed + 1
end
cover:free()
end
end
if placed == 0 then
target:free()
return nil
end
local lfs = require("libs/libkoreader-lfs")
local dir = group_covers_dir()
if lfs.attributes(dir, "mode") ~= "directory" then lfs.mkdir(dir) end
local ok_write = pcall(target.writeToFile, target, dest_path, "PNG")
target:free()
if not ok_write then
logger.warn("ReadestLibrary group cover write failed: " .. tostring(dest_path))
return nil
end
-- Re-load the file so the bb we hand back is one ImageWidget can
-- safely take ownership of.
local ok_render, RenderImage = pcall(require, "ui/renderimage")
if not ok_render then return nil end
local ok_load, bb = pcall(RenderImage.renderImageFile, RenderImage, dest_path, false)
if not ok_load then return nil end
return bb
end
-- Cells-per-mosaic for a given shape. Used by callers to know how many
-- books to fetch from the store.
function M.cells_for(shape)
local layout = M.LAYOUTS[shape] or M.LAYOUTS.grid
return layout.cols * layout.rows
end
-- High-level: query the store, derive a content-based cache key from
-- the actual first-N hashes, load from disk if present, else compose
-- fresh. Returns (bb, books) — books is the resolved list (so callers
-- can reuse it without a second query).
function M.serve_or_compose(group_by, value, cache_key, shape,
store, settings, orig_getBookInfo, BIM)
if not store then return nil, {} end
local n = M.cells_for(shape)
local books = store:listBooksInGroup(group_by, value, n, {
sort_by = settings and settings.library_sort_by,
sort_asc = settings and settings.library_sort_ascending == true,
})
-- Fingerprint: short prefix of each hash, joined. Stable for the
-- same set in the same order; changes the moment any of those
-- shifts.
local parts = {}
for i = 1, #books do
parts[i] = (books[i].hash or ""):sub(1, 12)
end
local fingerprint = (#parts > 0) and table.concat(parts, "-") or "empty"
local content_key = cache_key .. "_" .. fingerprint
local cache_path = group_cover_path(content_key)
local lfs = require("libs/libkoreader-lfs")
if lfs.attributes(cache_path, "mode") == "file" then
local ok, RenderImage = pcall(require, "ui/renderimage")
if ok then
local ok2, loaded = pcall(RenderImage.renderImageFile,
RenderImage, cache_path, false)
if ok2 then return loaded, books end
end
end
return compose(books, cache_path, shape, orig_getBookInfo, BIM), books
end
return M
@@ -0,0 +1,141 @@
-- libraryitem.lua
-- Maps LibraryStore rows (and group descriptors) into Menu entry
-- tables that MosaicMenuItem / ListMenuItem can render. The heavy
-- lifting is in:
--
-- library.cloud_covers — single-book cloud cover lifecycle
-- library.group_covers — folder-preview composite mosaics
-- library.cloud_icons — cloud-up/down overlay icons
-- library.list_strip — list-mode group row widget
-- library.bim_patch — BookInfoManager + ListMenuItem patches
--
-- This module is a thin glue layer that produces entries shaped the
-- way the rendering pipeline expects, then delegates lifecycle hooks
-- (install, set_visible_hashes) to the right submodule.
local cloud_covers = require("library.cloud_covers")
local group_covers = require("library.group_covers")
local bim_patch = require("library.bim_patch")
local M = {}
-- Sentinel fields on entry tables. Tap dispatch in librarywidget reads
-- them; the bim_patch list-row patches read them via M.<flag>.
M.CLOUD_ONLY_FLAG = bim_patch.CLOUD_ONLY_FLAG
M.LOCAL_ONLY_FLAG = bim_patch.LOCAL_ONLY_FLAG
-- ---------------------------------------------------------------------------
-- Lifecycle delegates
-- ---------------------------------------------------------------------------
-- Called once when the Library widget opens. Wires the patched BIM
-- with the user's current sync_auth/path/settings.
function M.install(opts)
bim_patch.install(opts)
end
-- Limit cloud-cover downloads to entries on the current Menu page.
function M.set_visible_hashes(menu)
cloud_covers.set_visible_hashes(menu, M.CLOUD_ONLY_FLAG)
end
-- ---------------------------------------------------------------------------
-- Entry constructors
-- ---------------------------------------------------------------------------
-- Group folder. Two render modes:
-- * opts.with_cover ~= false → entry.file = readest-group:// URI so
-- MosaicMenuItem treats it as a file and routes the paint through
-- the patched BIM, which serves a 2x2 cover mosaic.
-- * with_cover = false → entry has no `file` field, so the menu
-- widget renders the default folder treatment (rounded frame +
-- count badge).
function M.entry_from_group(group, opts)
opts = opts or {}
local entry = {
text = group.display_name or group.name,
mandatory = tostring(group.count or 0),
_readest_group = group,
}
-- Stash group_by on the descriptor so list_strip can resolve
-- children without re-parsing the URI.
if opts.group_by then group._group_by = opts.group_by end
if opts.group_by and opts.with_cover ~= false then
local shape = opts.shape or "grid"
local uri = group_covers.build_uri(opts.group_by, group.name, shape)
entry.file = uri
entry.is_file = true
-- Stash by URI so the BIM patch can return a proper title for
-- FakeCover when the composite isn't cached yet on first paint.
cloud_covers.set_meta(uri, {
title = group.display_name or group.name,
})
end
return entry
end
-- "Up one level" entry. _readest_back_to is the parent path to
-- navigate to (nil = back to root); the boolean flag distinguishes a
-- root-back entry from a regular row that just happens to lack a
-- back path.
function M.entry_back(parent_path, label)
return {
text = label,
mandatory = "",
_readest_is_back = true,
_readest_back_to = parent_path,
}
end
-- Convert a LibraryStore row into a Menu item_table entry. The Menu
-- item layer expects entry.file, entry.text, entry.is_file etc.
-- _readest_row is preserved so the tap handler in librarywidget can
-- dispatch on cloud_present / local_present without re-querying.
function M.entry_from_row(row, _opts)
if not row then return nil end
local entry = {
text = row.title,
author = row.author,
series = row.series,
series_index = row.series_index,
cover_path = row.cover_path,
is_file = true,
mandatory = "",
}
local EXTS = require("library.exts")
local ext = (EXTS[row.format] or "epub")
if row.local_present == 1 and row.file_path then
entry.file = row.file_path
-- BIM patch tags this path with _no_provider so ListMenuItem
-- renders mandatory verbatim (= the format) without the
-- trailing "<filetype> " padding the standard format-string
-- adds when mandatory is short. Keeps right-side text
-- right-aligned with cloud rows that already use _no_provider.
entry.mandatory = ext
bim_patch.register_local_path(row.file_path)
-- Mark "local but not in cloud" so the paintTo overlay paints
-- the cloud-upload icon (mirroring Readest's BookItem rule:
-- !uploadedAt → cloud-up).
if (row.cloud_present or 0) == 0 then
entry[M.LOCAL_ONLY_FLAG] = true
end
else
-- Encode the real extension into the URI so listmenu's
-- splitFileNameType returns a non-nil filetype for the right
-- column. The patched BIM strips it back off.
entry.file = cloud_covers.URI_PREFIX .. row.hash .. "." .. ext
entry[M.CLOUD_ONLY_FLAG] = true
-- Same _no_provider treatment as the local branch.
entry.mandatory = ext
-- Stash title/author by hash so the patched BIM (keyed by URI
-- /path, not by row) can return them for FakeCover.
cloud_covers.set_meta(row.hash, {
title = row.title,
author = row.author,
})
end
entry._readest_row = row
return entry
end
return M
@@ -0,0 +1,47 @@
-- librarypaint.lua
-- Partial-page repaint shim for the Library Menu, adapted from
-- /Users/chrox/dev/koreader-plugins/zen_ui.koplugin/modules/filebrowser/patches/partial_page_repaint.lua
--
-- Problem on e-ink: when the visible page has fewer items than `perpage`
-- (typical on the last page of any list), KOReader's normal partial-refresh
-- leaves ghost pixels in the now-empty cells from the previously-shown
-- items. A `setDirty(nil, "full")` waveform refresh clears the entire
-- screen and removes the ghosts.
--
-- We hook our menu's updateItems to schedule a full refresh on the next
-- UI tick when items_on_page < perpage. A `pending` guard prevents a
-- double refresh if updateItems fires twice in the same tick (search
-- debounce + sort change can do this).
--
-- Live-KOReader-only; no unit tests.
local M = {}
function M.install(menu)
if menu._readest_paint_installed then return end
menu._readest_paint_installed = true
local UIManager = require("ui/uimanager")
local pending = false
local orig_updateItems = menu.updateItems
menu.updateItems = function(self, ...)
local r = orig_updateItems(self, ...)
local total = #(self.item_table or {})
local perpage = self.perpage
if not perpage or perpage <= 0 or total == 0 then return r end
local page = self.page or 1
local items_on_page = math.max(0, math.min(perpage, total - (page - 1) * perpage))
if items_on_page > 0 and items_on_page < perpage and not pending then
pending = true
UIManager:nextTick(function()
pending = false
UIManager:setDirty(nil, "full")
UIManager:forceRePaint()
end)
end
return r
end
end
return M
@@ -0,0 +1,751 @@
-- librarystore.lua
-- SQLite-backed book index for the Library view. Merges Readest cloud books
-- (from /sync) with KOReader local books (from sidecar walks + ReadHistory)
-- via the partial-md5 hash that both sides already use.
--
-- All queries are scoped by user_id (composite PK with hash) so signing into
-- a different Readest account doesn't surface the previous user's books.
--
-- See apps/readest.koplugin/docs/library-design.md for the full schema and
-- contract notes; spec/library/librarystore_spec.lua is the canonical
-- behavioral spec.
local SQ3 = require("lua-ljsqlite3/init")
local json = require("json")
local SCHEMA_VERSION = 1
local SCHEMA_SQL = [[
CREATE TABLE IF NOT EXISTS books (
user_id TEXT NOT NULL,
hash TEXT NOT NULL,
meta_hash TEXT,
title TEXT NOT NULL,
source_title TEXT,
author TEXT,
format TEXT,
metadata_json TEXT,
series TEXT,
series_index REAL,
group_id TEXT,
group_name TEXT,
cover_path TEXT,
file_path TEXT,
cloud_present INTEGER NOT NULL DEFAULT 0,
local_present INTEGER NOT NULL DEFAULT 0,
uploaded_at INTEGER,
progress_lib TEXT,
reading_status TEXT,
last_read_at INTEGER,
created_at INTEGER,
updated_at INTEGER,
deleted_at INTEGER,
PRIMARY KEY (user_id, hash)
);
CREATE INDEX IF NOT EXISTS books_user_updated ON books(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS books_user_lastread ON books(user_id, last_read_at DESC);
CREATE INDEX IF NOT EXISTS books_user_meta ON books(user_id, meta_hash);
CREATE INDEX IF NOT EXISTS books_user_group ON books(user_id, group_name);
CREATE INDEX IF NOT EXISTS books_user_author ON books(user_id, author);
CREATE TABLE IF NOT EXISTS sync_state (
user_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT,
PRIMARY KEY (user_id, key)
);
]]
-- All columns we round-trip in the books row, in insert order.
local BOOK_COLS = {
"user_id", "hash", "meta_hash", "title", "source_title", "author",
"format", "metadata_json", "series", "series_index", "group_id",
"group_name", "cover_path", "file_path", "cloud_present",
"local_present", "uploaded_at", "progress_lib", "reading_status",
"last_read_at", "created_at", "updated_at", "deleted_at",
}
local BOOK_COL_INDEX = {}
for i, c in ipairs(BOOK_COLS) do BOOK_COL_INDEX[c] = i end
-- Integer/real columns that lua-ljsqlite3 returns as int64_t / double cdata.
-- We tonumber() these on row read so consumers can do arithmetic and
-- string concat without worrying about cdata. Unix-ms timestamps fit
-- well within Lua's 53-bit double mantissa.
local NUMERIC_COLS = {
series_index = true, cloud_present = true, local_present = true,
uploaded_at = true, last_read_at = true, created_at = true,
updated_at = true, deleted_at = true,
}
local function row_to_table(raw)
local out = {}
for i, col in ipairs(BOOK_COLS) do
local v = raw[i]
if v ~= nil and NUMERIC_COLS[col] then
v = tonumber(v)
end
out[col] = v
end
return out
end
-- Allowed sort columns. listBooks accepts only these to keep SQL safe from
-- injection via filters.sort_by.
local SORT_WHITELIST = {
updated_at = true,
last_read_at = true,
title = true,
author = true,
created_at = true,
series = true,
format = true,
}
-- Allowed group_by columns. Must match a real column name.
local GROUP_WHITELIST = {
author = true,
series = true,
group_name = true,
}
local M = {}
M.__index = M
-- ---------------------------------------------------------------------------
-- Construction
-- ---------------------------------------------------------------------------
-- opts:
-- user_id (required, string) — currently-authenticated Readest user.id
-- db_path (optional, string) — defaults to ":memory:" for tests
function M.new(opts)
assert(opts and type(opts.user_id) == "string" and opts.user_id ~= "",
"LibraryStore.new requires opts.user_id")
local self = setmetatable({}, M)
self.user_id = opts.user_id
self.db_path = opts.db_path or ":memory:"
self.db = SQ3.open(self.db_path)
self.db:exec(SCHEMA_SQL)
self.db:exec(string.format("PRAGMA user_version = %d;", SCHEMA_VERSION))
self._groups_cache = {}
return self
end
function M:close()
if self.db then self.db:close(); self.db = nil end
end
function M:getUserVersion()
local v = self.db:rowexec("PRAGMA user_version;")
return tonumber(v)
end
-- ---------------------------------------------------------------------------
-- Sync state per (user_id, key)
-- ---------------------------------------------------------------------------
function M:getLastPulledAt()
local stmt = self.db:prepare(
"SELECT value FROM sync_state WHERE user_id = ? AND key = ?")
local row = stmt:reset():bind(self.user_id, "last_books_pulled_at"):step()
stmt:close()
if not row then return nil end
return tonumber(row[1])
end
function M:setLastPulledAt(ts)
local stmt = self.db:prepare([[
INSERT INTO sync_state (user_id, key, value) VALUES (?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
]])
stmt:reset():bind(self.user_id, "last_books_pulled_at", tostring(ts)):step()
stmt:close()
end
-- ---------------------------------------------------------------------------
-- upsertBook
-- ---------------------------------------------------------------------------
-- Merges a row by (user_id, hash). Flags `cloud_present` and `local_present`
-- are OR-merged with the existing row UNLESS the caller passes the
-- `_force_cloud_present` sentinel, in which case the supplied value is
-- written verbatim (used for cloud-tombstone updates that must clear the
-- flag).
--
-- Sentinels:
-- _force_cloud_present = true → caller's cloud_present overrides OR-merge.
-- _clear_fields = { "deleted_at", ... } → after the preserve-existing pass,
-- these columns are explicitly nulled. Lets a caller un-tombstone a row
-- by passing nil (which would otherwise be indistinguishable from "not
-- provided" since Lua tables drop nil values).
function M:upsertBook(row)
assert(row and type(row.hash) == "string" and row.hash ~= "",
"upsertBook requires row.hash")
assert(row.title, "upsertBook requires row.title")
local existing = self:_getRowRaw(row.hash)
local merged = {}
for k in pairs(BOOK_COL_INDEX) do
merged[k] = row[k]
end
merged.user_id = self.user_id
merged.hash = row.hash
if existing then
-- OR-merge cloud_present unless explicit override
if not row._force_cloud_present then
merged.cloud_present = math.max(
tonumber(existing.cloud_present) or 0,
tonumber(merged.cloud_present) or 0)
else
merged.cloud_present = tonumber(row.cloud_present) or 0
end
-- OR-merge local_present always (no use case for force-clearing yet)
merged.local_present = math.max(
tonumber(existing.local_present) or 0,
tonumber(merged.local_present) or 0)
-- Preserve fields the caller didn't provide
for k in pairs(BOOK_COL_INDEX) do
if merged[k] == nil and existing[k] ~= nil then
merged[k] = existing[k]
end
end
-- Explicit clears: applied after preserve so they win.
if row._clear_fields then
for _, col in ipairs(row._clear_fields) do
merged[col] = nil
end
end
else
merged.cloud_present = tonumber(merged.cloud_present) or 0
merged.local_present = tonumber(merged.local_present) or 0
end
-- Build INSERT … ON CONFLICT … DO UPDATE … with positional params.
local placeholders = {}
local update_setters = {}
for i, col in ipairs(BOOK_COLS) do
placeholders[i] = "?"
if col ~= "user_id" and col ~= "hash" then
update_setters[#update_setters + 1] = col .. " = excluded." .. col
end
end
local sql = string.format([[
INSERT INTO books (%s) VALUES (%s)
ON CONFLICT(user_id, hash) DO UPDATE SET %s
]], table.concat(BOOK_COLS, ", "),
table.concat(placeholders, ", "),
table.concat(update_setters, ", "))
local stmt = self.db:prepare(sql)
stmt:reset()
for i, col in ipairs(BOOK_COLS) do
stmt:bind1(i, merged[col])
end
stmt:step()
stmt:close()
-- Cached groupings stale after any insert/update.
self._groups_cache = {}
end
-- ---------------------------------------------------------------------------
-- getChangedBooks(since) — returns every row whose updated_at OR deleted_at
-- exceeds the watermark. Mirrors useBooksSync.getNewBooks at
-- apps/readest-app/src/app/library/hooks/useBooksSync.ts:22-35.
-- Used by the auto-sync push path on book close to send deltas.
-- ---------------------------------------------------------------------------
function M:getChangedBooks(since)
since = tonumber(since) or 0
local stmt = self.db:prepare(string.format([[
SELECT %s FROM books
WHERE user_id = ?
AND (updated_at > ? OR (deleted_at IS NOT NULL AND deleted_at > ?))
ORDER BY updated_at ASC
]], table.concat(BOOK_COLS, ", ")))
stmt:reset():bind(self.user_id, since, since)
local rows = {}
while true do
local r = stmt:step()
if not r then break end
rows[#rows + 1] = row_to_table(r)
end
stmt:close()
return rows
end
-- ---------------------------------------------------------------------------
-- touchBook(hash, fields) — update updated_at + last_read_at to "now",
-- merge in any other fields the caller passes (commonly progress_lib),
-- return the resulting row (or nil if the book isn't in the index).
--
-- This is the local-write half of "after open/close, sync to server"; the
-- caller composes touchBook + syncbooks.pushBook to mirror what
-- Readest web does in updateBookProgress + the books-table sync push
-- (apps/readest-app/src/store/libraryStore.ts:105-122).
-- ---------------------------------------------------------------------------
function M:touchBook(hash, fields)
if not hash or hash == "" then return nil end
local existing = self:_getRowRaw(hash)
if not existing then return nil end
local now = os.time() * 1000
local merge = {
hash = hash,
title = existing.title,
updated_at = now,
last_read_at = now,
}
if fields then for k, v in pairs(fields) do merge[k] = v end end
self:upsertBook(merge)
return self:_getRowRaw(hash)
end
-- Internal: fetch a row by hash for the current user, returned as a table
-- keyed by column name. Exposed (with leading underscore) for spec checks.
function M:_getRowRaw(hash)
local stmt = self.db:prepare(string.format(
"SELECT %s FROM books WHERE user_id = ? AND hash = ?",
table.concat(BOOK_COLS, ", ")))
local row = stmt:reset():bind(self.user_id, hash):step()
stmt:close()
if not row then return nil end
return row_to_table(row)
end
-- ---------------------------------------------------------------------------
-- listBooks
-- ---------------------------------------------------------------------------
-- filters: { search, sort_by, sort_asc, group_by, group_filter }
function M:listBooks(filters)
filters = filters or {}
local where = {
"user_id = ?",
"deleted_at IS NULL",
"(cloud_present = 1 OR local_present = 1)",
}
local args = { self.user_id }
if filters.search and filters.search ~= "" then
where[#where + 1] = "(LOWER(COALESCE(title, '')) LIKE ? OR LOWER(COALESCE(author, '')) LIKE ?)"
local needle = "%" .. string.lower(filters.search) .. "%"
args[#args + 1] = needle
args[#args + 1] = needle
end
if filters.group_by and filters.group_filter then
local col = GROUP_WHITELIST[filters.group_by] and filters.group_by
if col then
where[#where + 1] = col .. " = ?"
args[#args + 1] = filters.group_filter
end
end
-- "Books at this shelf level with no group value" — used by the
-- bookshelf composer for the root view of group_by=author/series/group_name.
if filters.ungrouped_col and GROUP_WHITELIST[filters.ungrouped_col] then
local col = filters.ungrouped_col
where[#where + 1] = "(" .. col .. " IS NULL OR " .. col .. " = '')"
end
local sort_by = SORT_WHITELIST[filters.sort_by] and filters.sort_by or "last_read_at"
local sort_dir = filters.sort_asc and "ASC" or "DESC"
-- "Date Read" semantics in this plugin = "any recent activity" (the
-- web's Updated + Date Read concepts merged earlier). Prefer
-- updated_at when present so a metadata bump (e.g. "Add to Readest"
-- dedupe re-stamping updated_at) floats the row to the top even
-- when last_read_at is older. Falls back to last_read_at for the
-- rare row that has only the read timestamp (no updated_at).
local sort_expr = (sort_by == "last_read_at")
and "COALESCE(updated_at, last_read_at)"
or sort_by
local sql = string.format(
"SELECT %s FROM books WHERE %s ORDER BY %s %s, hash ASC",
table.concat(BOOK_COLS, ", "),
table.concat(where, " AND "),
sort_expr, sort_dir)
local stmt = self.db:prepare(sql)
stmt:reset()
for i, v in ipairs(args) do stmt:bind1(i, v) end
local rows = {}
while true do
local r = stmt:step()
if not r then break end
rows[#rows + 1] = row_to_table(r)
end
stmt:close()
return rows
end
-- ---------------------------------------------------------------------------
-- getGroups
-- ---------------------------------------------------------------------------
-- Returns array of { name, count, latest_updated_at, latest_last_read_at,
-- latest_created_at } sorted by name. The per-sort aggregates let the
-- caller interleave groups and books in the merged shelf list using each
-- group's "most recent child" value (parity with Readest's
-- getGroupSortValue at apps/readest-app/src/app/library/utils/libraryUtils.ts:381-387).
-- Memoized per (user_id, group_by); invalidated by upsertBook.
function M:getGroups(group_by)
if not GROUP_WHITELIST[group_by] then return {} end
local cached = self._groups_cache[group_by]
if cached then return cached end
local sql = string.format([[
SELECT %s AS name,
COUNT(*) AS cnt,
MAX(updated_at) AS latest_updated,
MAX(COALESCE(updated_at, last_read_at)) AS latest_last_read,
MAX(created_at) AS latest_created
FROM books
WHERE user_id = ? AND deleted_at IS NULL
AND (cloud_present = 1 OR local_present = 1)
AND %s IS NOT NULL AND %s != ''
GROUP BY %s
ORDER BY name ASC
]], group_by, group_by, group_by, group_by)
local stmt = self.db:prepare(sql)
stmt:reset():bind1(1, self.user_id)
local out = {}
while true do
local r = stmt:step()
if not r then break end
out[#out + 1] = {
name = r[1],
count = tonumber(r[2]),
latest_updated_at = tonumber(r[3]),
latest_last_read_at = tonumber(r[4]),
latest_created_at = tonumber(r[5]),
}
end
stmt:close()
self._groups_cache[group_by] = out
return out
end
-- ---------------------------------------------------------------------------
-- listBookshelfGroups(group_by, parent_path)
-- ---------------------------------------------------------------------------
-- Returns the group entries shown at the current shelf level, mirroring
-- Readest's library nav model:
-- author/series — flat groups; parent_path is ignored (only meaningful
-- at root). Each entry is the existing getGroups output with display_name
-- set to the group name.
--
-- group_name — nested folders. parent_path is the folder we're inside
-- (nil = root). We emit one entry per immediate-child segment, with the
-- `name` field carrying the full slash-delimited path so the caller can
-- pass it back as parent_path for drill-in.
--
-- Each returned entry is { _group=true, name, display_name, count,
-- latest_updated_at }, sorted by display_name ASC.
function M:listBookshelfGroups(group_by, parent_path)
if not GROUP_WHITELIST[group_by] then return {} end
if group_by ~= "group_name" then
if parent_path then return {} end
local out = {}
for _i, g in ipairs(self:getGroups(group_by)) do
out[#out + 1] = {
_group = true,
name = g.name,
display_name = g.name,
count = g.count,
latest_updated_at = g.latest_updated_at,
latest_last_read_at = g.latest_last_read_at,
latest_created_at = g.latest_created_at,
}
end
return out
end
-- group_name: walk distinct group_name values and bucket by immediate
-- child segment relative to parent_path. SQLite doesn't have great
-- string-slicing primitives, but the distinct-group_name set is small
-- (one row per unique path), so a Lua-side bucket is cheap.
-- Per-sort aggregates mirror getGroups so the merged-shelf sort can
-- use a folder's "most recent child" timestamp under any sort_by.
local stmt = self.db:prepare([[
SELECT group_name,
COUNT(*) AS cnt,
MAX(updated_at) AS latest_updated,
MAX(COALESCE(updated_at, last_read_at)) AS latest_last_read,
MAX(created_at) AS latest_created
FROM books
WHERE user_id = ? AND deleted_at IS NULL
AND (cloud_present = 1 OR local_present = 1)
AND group_name IS NOT NULL AND group_name != ''
GROUP BY group_name
]])
stmt:reset():bind1(1, self.user_id)
local prefix = parent_path and (parent_path .. "/") or nil
local prefix_len = prefix and #prefix or 0
local children = {} -- segment → aggregate accumulator
while true do
local r = stmt:step()
if not r then break end
local group_name = r[1]
local cnt = tonumber(r[2]) or 0
local latest_updated = tonumber(r[3]) or 0
local latest_lastread = tonumber(r[4]) or 0
local latest_created = tonumber(r[5]) or 0
local rest
if parent_path then
-- "Fantasy" with parent="Fantasy" is a direct-child book, not
-- a folder; skip from the folder list (caller picks it up via
-- listBookshelfBooks).
if group_name ~= parent_path
and group_name:sub(1, prefix_len) == prefix then
rest = group_name:sub(prefix_len + 1)
end
else
rest = group_name
end
if rest and rest ~= "" then
-- Match Readest's slashIndex > 0 semantics
-- (apps/readest-app/src/app/library/components/BookshelfItem.tsx:43-44):
-- a leading slash keeps the whole rest as the immediate-child name
-- instead of producing an empty segment.
local slash_pos = rest:find("/", 1, true)
local segment
if slash_pos and slash_pos > 1 then
segment = rest:sub(1, slash_pos - 1)
else
segment = rest
end
local entry = children[segment]
if entry then
entry.count = entry.count + cnt
entry.latest_updated = math.max(entry.latest_updated, latest_updated)
entry.latest_lastread = math.max(entry.latest_lastread, latest_lastread)
entry.latest_created = math.max(entry.latest_created, latest_created)
else
children[segment] = {
count = cnt,
latest_updated = latest_updated,
latest_lastread = latest_lastread,
latest_created = latest_created,
}
end
end
end
stmt:close()
local out = {}
for segment, data in pairs(children) do
out[#out + 1] = {
_group = true,
name = parent_path and (parent_path .. "/" .. segment) or segment,
display_name = segment,
count = data.count,
latest_updated_at = data.latest_updated,
latest_last_read_at = data.latest_lastread,
latest_created_at = data.latest_created,
}
end
table.sort(out, function(a, b) return a.display_name < b.display_name end)
return out
end
-- ---------------------------------------------------------------------------
-- listBooksInGroup(group_by, group_value, limit, opts)
-- ---------------------------------------------------------------------------
-- Returns up to `limit` books in the group. opts.sort_by + opts.sort_asc
-- mirror M:listBooks, so the cover composer picks the same first-N
-- books the user would see when drilling in. Default sort:
-- COALESCE(updated_at, last_read_at) DESC.
--
-- For group_name, matches the path itself AND any descendant path
-- (so a top-level "Fantasy" preview pulls in books from Fantasy/Tolkien
-- /LOTR even when nothing lives at the root level).
function M:listBooksInGroup(group_by, group_value, limit, opts)
if not GROUP_WHITELIST[group_by] then return {} end
opts = opts or {}
local where_extra, args
if group_by == "group_name" then
where_extra = "(group_name = ? OR group_name LIKE ?)"
args = { self.user_id, group_value, group_value .. "/%", limit }
else
where_extra = group_by .. " = ?"
args = { self.user_id, group_value, limit }
end
-- Honor the caller's current sort so the cover-preview composite
-- picks the same first-N books the user would see when drilling in.
-- Mirrors the sort_expr logic in M:listBooks above.
local sort_by = SORT_WHITELIST[opts.sort_by] and opts.sort_by or "last_read_at"
local sort_dir = opts.sort_asc and "ASC" or "DESC"
local sort_expr = (sort_by == "last_read_at")
and "COALESCE(updated_at, last_read_at)"
or sort_by
local sql = string.format([[
SELECT %s FROM books
WHERE user_id = ? AND deleted_at IS NULL
AND (cloud_present = 1 OR local_present = 1)
AND %s
ORDER BY %s %s, hash ASC
LIMIT ?
]], table.concat(BOOK_COLS, ", "), where_extra, sort_expr, sort_dir)
local stmt = self.db:prepare(sql)
stmt:reset()
for i, v in ipairs(args) do stmt:bind1(i, v) end
local rows = {}
while true do
local r = stmt:step()
if not r then break end
rows[#rows + 1] = row_to_table(r)
end
stmt:close()
return rows
end
-- ---------------------------------------------------------------------------
-- listBookshelfBooks(filters, group_by, parent_path)
-- ---------------------------------------------------------------------------
-- Returns the book rows that appear directly at the current shelf level
-- (siblings of the listBookshelfGroups output, NOT recursively).
-- group_by=nil/"none" — all books matching filters
-- group_by=author/series, root — books whose author/series is null/empty
-- group_by=author/series, drill — books with col = parent_path
-- group_by=group_name, root — books with null/empty group_name
-- group_by=group_name, drill — books with group_name = parent_path
function M:listBookshelfBooks(filters, group_by, parent_path)
local sub = {}
for k, v in pairs(filters or {}) do sub[k] = v end
if not GROUP_WHITELIST[group_by] then
sub.group_by = nil
sub.group_filter = nil
return self:listBooks(sub)
end
if parent_path then
sub.group_by = group_by
sub.group_filter = parent_path
return self:listBooks(sub)
end
sub.group_by = nil
sub.group_filter = nil
sub.ungrouped_col = group_by
return self:listBooks(sub)
end
-- ---------------------------------------------------------------------------
-- parseSyncRow (pure helper, no DB access)
-- ---------------------------------------------------------------------------
-- Maps a raw /sync DB row (snake_case, ISO timestamps, JSON-string metadata)
-- to our internal row shape (fields ready for upsertBook). Returns nil for
-- the dummy initial-sync hash.
local DUMMY_HASH = "00000000000000000000000000000000"
-- ISO-8601 → unix ms. Accepts:
-- 2026-02-01T00:00:00Z
-- 2026-02-01T00:00:00+00:00 (Supabase / Postgres native; the common case)
-- 2026-02-01T00:00:00+0000
-- 2026-02-01T00:00:00.123456+00:00 (with fractional seconds)
-- 2026-02-01 00:00:00+00:00 (Postgres without the T separator)
local function iso_to_ms(s)
if not s then return nil end
if type(s) == "number" then return s end
if type(s) ~= "string" then return nil end
local y, mo, d, h, mi, sec, frac, tz = s:match(
"^(%d%d%d%d)%-(%d%d)%-(%d%d)[T ](%d%d):(%d%d):(%d%d)([%.%d]*)(.*)$")
if not y then return nil end
local t = os.time({
year = tonumber(y), month = tonumber(mo), day = tonumber(d),
hour = tonumber(h), min = tonumber(mi), sec = tonumber(sec),
isdst = false,
})
-- os.time interprets the struct as LOCAL time; convert to UTC by
-- subtracting the local TZ offset.
local utc_offset = os.difftime(t, os.time(os.date("!*t", t)))
t = t + utc_offset
-- Apply the input's own offset (Z = +00:00; "+05:30" subtracts 5.5h to
-- get UTC). Default to UTC if no offset present (server contract).
if tz and tz ~= "" and tz ~= "Z" then
local sign, oh, om = tz:match("^([%+%-])(%d%d):?(%d%d)$")
if sign then
local off = (tonumber(oh) * 3600) + (tonumber(om or 0) * 60)
if sign == "+" then t = t - off else t = t + off end
end
end
local ms = t * 1000
if frac and frac:sub(1, 1) == "." then
-- Fractional seconds: take only the first 3 digits (ms precision)
local f = frac:sub(2, 4)
if #f > 0 then
ms = ms + tonumber(f .. string.rep("0", 3 - #f))
end
end
return ms
end
function M.parseSyncRow(dbRow)
if not dbRow then return nil end
local hash = dbRow.book_hash or dbRow.hash
if not hash or hash == DUMMY_HASH then return nil end
local out = {
hash = hash,
meta_hash = dbRow.meta_hash,
title = dbRow.title or "Untitled",
source_title = dbRow.source_title,
author = dbRow.author,
format = dbRow.format,
group_id = dbRow.group_id,
group_name = dbRow.group_name,
uploaded_at = iso_to_ms(dbRow.uploaded_at),
updated_at = iso_to_ms(dbRow.updated_at),
created_at = iso_to_ms(dbRow.created_at),
deleted_at = iso_to_ms(dbRow.deleted_at),
}
-- Metadata: parse JSON string OR accept an already-parsed table; extract
-- series/series_index into denormalized columns; round-trip the raw JSON
-- so callers can read other fields lazily later.
if dbRow.metadata ~= nil then
local meta
if type(dbRow.metadata) == "string" then
local ok, parsed = pcall(json.decode, dbRow.metadata)
if ok and type(parsed) == "table" then meta = parsed end
elseif type(dbRow.metadata) == "table" then
meta = dbRow.metadata
end
if meta then
out.series = meta.series
out.series_index = meta.seriesIndex
local ok, encoded = pcall(json.encode, meta)
if ok then out.metadata_json = encoded end
end
end
-- Progress: snake-case web shape is `progress = [cur, total]`.
if dbRow.progress and type(dbRow.progress) == "table" then
local ok, encoded = pcall(json.encode, dbRow.progress)
if ok then out.progress_lib = encoded end
end
-- Reading status passthrough (web side has 'unread'/'reading'/'finished')
out.reading_status = dbRow.readingStatus or dbRow.reading_status
-- Cloud-presence flag: tombstones from the cloud arrive with deleted_at
-- set; the row is still useful for tracking that the cloud copy is gone,
-- but it doesn't count as cloud-present anymore. Force the flag through
-- upsertBook's OR-merge with the sentinel.
if out.deleted_at then
out.cloud_present = 0
out._force_cloud_present = true
else
out.cloud_present = 1
end
return out
end
return M
@@ -0,0 +1,171 @@
-- 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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,166 @@
-- 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
@@ -0,0 +1,265 @@
-- localscanner.lua
-- Discover books KOReader has already opened (and therefore has a hash for)
-- and feed them into the LibraryStore.
--
-- We never compute partial-md5 on demand: that's KOReader's job, performed
-- the first time it opens a book file. The scanner only enumerates books
-- whose .sdr/ sidecar already contains `partial_md5_checksum`, which means
-- the local source-of-truth is "anything KOReader has opened at least
-- once." This matches the user's stated v1 constraint and makes the scan
-- bounded and side-effect-free.
--
-- Pure helpers (sidecar_to_book_path, parse_sidecar, should_skip_dir) are
-- exported and unit-tested. The two driver methods (lightScan,
-- fullSidecarWalk) require live KOReader services (ReadHistory,
-- DocSettings, FFIUtil.runInSubProcess) and are exercised manually via the
-- test matrix in docs/library-design.md.
local logger = require("logger")
local M = {}
-- ---------------------------------------------------------------------------
-- sidecar_to_book_path
-- ---------------------------------------------------------------------------
-- Convert "/foo/bar.sdr/metadata.epub.lua" → "/foo/bar.epub".
-- Returns nil for any input that doesn't match the sidecar shape.
function M.sidecar_to_book_path(path)
if type(path) ~= "string" or path == "" then return nil end
local parent, ext = path:match("^(.+)%.sdr/metadata%.([^./]+)%.lua$")
if not parent then return nil end
return parent .. "." .. ext
end
-- ---------------------------------------------------------------------------
-- parse_sidecar
-- ---------------------------------------------------------------------------
-- Open a sidecar file, evaluate it (it returns a Lua table), and pull out
-- the fields we care about: hash + a few doc_props. Returns:
--
-- { hash = "...", title = "...", author = "...", file_path = "..." }
--
-- ...or nil for any failure mode (file missing, syntax error, runtime
-- error, missing partial_md5_checksum). The Library can't use a row without
-- a hash, so we treat hash-missing as nothing-to-see-here.
function M.parse_sidecar(path)
local book_path = M.sidecar_to_book_path(path)
if not book_path then return nil end
local f = loadfile(path)
if not f then return nil end
-- Sidecars are raw `return { ... }` files; loadfile gives us a chunk
-- that, when called, returns the table. Wrap in pcall in case the
-- table contains a function or self-referential table that errors.
local ok, result = pcall(f)
if not ok or type(result) ~= "table" then return nil end
local hash = result.partial_md5_checksum
if type(hash) ~= "string" or hash == "" then return nil end
local doc_props = result.doc_props or {}
return {
hash = hash,
title = doc_props.title,
author = doc_props.authors,
file_path = book_path,
}
end
-- ---------------------------------------------------------------------------
-- should_skip_dir
-- ---------------------------------------------------------------------------
-- Predicate for the recursive walk. `.` / `..` filtered by the caller (lfs
-- emits them but they're trivial); we handle the noise files KOReader's
-- own FileChooser also skips.
local SKIP_DIRS = {
[".git"] = true,
[".svn"] = true,
[".hg"] = true,
["node_modules"] = true,
[".Trash"] = true,
[".Trashes"] = true,
["$RECYCLE.BIN"] = true,
[".adobe-digital-editions"] = true,
[".Spotlight-V100"] = true,
[".fseventsd"] = true,
[".DocumentRevisions-V100"] = true,
[".TemporaryItems"] = true,
}
function M.should_skip_dir(name)
if name == "." or name == ".." then return false end -- caller's job
return SKIP_DIRS[name] == true
end
-- ---------------------------------------------------------------------------
-- lightScan(opts) — fast path; runs on every Library open
-- ---------------------------------------------------------------------------
-- opts: { store = LibraryStore, ui = top-level UI? }
--
-- 1. For every existing local row in the store, stat file_path; if missing,
-- set local_present=0.
-- 2. For every ReadHistory entry whose file still exists, look up the
-- sidecar (it's always present after KOReader opened the book) and
-- upsert {hash, file_path, local_present=1, last_read_at=hist.time*1000}.
--
-- Bounded by `O(rows in SQLite + ReadHistory.hist size)` — typically <200
-- entries. Cheap enough for every Library open. Live-KOReader-only.
function M.lightScan(opts)
local lfs = require("libs/libkoreader-lfs")
local DocSettings = require("docsettings")
local ReadHistory = require("readhistory")
local store = opts.store
if not store then return 0, 0 end
-- Step 1: sweep stale file_paths to local_present=0
local stale = 0
local rows = store:listBooks({})
for _, row in ipairs(rows) do
if row.local_present == 1 and row.file_path then
if lfs.attributes(row.file_path, "mode") ~= "file" then
store:upsertBook({
hash = row.hash,
title = row.title,
local_present = 0,
})
stale = stale + 1
end
end
end
-- Step 2: opportunistic upsert from ReadHistory.
-- Per-iteration pcall so a single bad sidecar (corrupt Lua, file
-- vanished mid-scan, DocSettings throwing on open) doesn't kill the
-- whole loop and leave us with a partially-indexed library.
local added, skipped = 0, 0
for _, item in ipairs(ReadHistory.hist or {}) do
local file = item.file
if file and lfs.attributes(file, "mode") == "file" then
local ok, err = pcall(function()
-- DocSettings reads the sidecar for us; we get the same
-- hash the sidecar walk would produce.
local doc_settings = DocSettings:open(file)
local hash = doc_settings:readSetting("partial_md5_checksum")
if not hash or hash == "" then
skipped = skipped + 1
return
end
local doc_props = doc_settings:readSetting("doc_props") or {}
store:upsertBook({
hash = hash,
title = doc_props.title
or file:match("([^/]+)%.[^.]+$")
or file,
author = doc_props.authors,
format = (file:match("%.([^.]+)$") or ""):upper(),
file_path = file,
local_present = 1,
last_read_at = item.time and (item.time * 1000) or nil,
})
added = added + 1
end)
if not ok then
skipped = skipped + 1
logger.warn("ReadestLibrary lightScan: skipped " .. tostring(file)
.. "" .. tostring(err))
end
end
end
logger.info("ReadestLibrary lightScan: stale=" .. stale
.. " added/refreshed=" .. added .. " skipped=" .. skipped)
return stale, added
end
-- ---------------------------------------------------------------------------
-- fullSidecarWalk(opts, on_progress) — slow path; gated to first-run /
-- explicit Rescan / 24h interval
-- ---------------------------------------------------------------------------
-- opts: { store, home_dir, on_cancel? }
-- on_progress: optional function(scanned_dirs, found_books)
--
-- Runs the recursive walk inside a forked subprocess via
-- FFIUtil.runInSubProcess (the same pattern KOReader's filemanagerfilesearcher.lua
-- uses for its dismissable scan, see :130-210). The subprocess returns a
-- list of {file_path, hash, title, author} structs; the parent upserts each
-- one in chunks so the UI stays responsive.
--
-- If `home_dir` is empty/nil we skip with a warning — this is the
-- "user hasn't picked a Books folder yet" path, handled at the UI level
-- by showing a hint to set Home in FileManager.
function M.fullSidecarWalk(opts, on_progress)
if not opts.home_dir or opts.home_dir == "" then
logger.info("ReadestLibrary fullSidecarWalk: home_dir unset, skipping")
return 0
end
local lfs = require("libs/libkoreader-lfs")
local FFIUtil = require("ffi/util")
-- Fork: walk the tree in the child, return the slim summary table.
local child_fn = function()
local results = {}
local stack = { opts.home_dir }
while #stack > 0 do
local dir = table.remove(stack)
local ok, iter, dir_obj = pcall(lfs.dir, dir)
if ok then
for entry in iter, dir_obj do
if entry ~= "." and entry ~= ".." and not M.should_skip_dir(entry) then
local full = dir .. "/" .. entry
local mode = lfs.attributes(full, "mode")
if mode == "directory" then
-- KOReader sidecars live INSIDE *.sdr/ directories.
if entry:match("%.sdr$") then
-- Find the metadata.<ext>.lua inside
for child in lfs.dir(full) do
if child:match("^metadata%..+%.lua$") then
local parsed = M.parse_sidecar(full .. "/" .. child)
if parsed then
results[#results + 1] = parsed
end
end
end
else
stack[#stack + 1] = full
end
end
end
end
end
end
return results
end
local results = FFIUtil.runInSubProcess(child_fn)
if type(results) ~= "table" then
logger.warn("ReadestLibrary fullSidecarWalk: subprocess returned non-table")
return 0
end
local store = opts.store
local count = 0
for _, p in ipairs(results) do
if p.hash then
store:upsertBook({
hash = p.hash,
title = p.title or p.file_path:match("([^/]+)%.[^.]+$") or "Untitled",
author = p.author,
file_path = p.file_path,
local_present = 1,
})
count = count + 1
if on_progress and count % 50 == 0 then
on_progress(count)
end
end
end
if on_progress then on_progress(count) end
logger.dbg("ReadestLibrary fullSidecarWalk: indexed " .. count .. " books")
return count
end
return M
+891
View File
@@ -0,0 +1,891 @@
-- syncbooks.lua
-- Sync layer for the Library view. Pulls book records from /sync, requests
-- signed download URLs from /storage/download, and streams cloud-only book
-- files + cover images to disk.
--
-- The pure helpers (build_file_key, build_cover_key, build_local_filename,
-- resolve_collision) are exported and unit-tested in
-- spec/library/syncbooks_spec.lua. The network-touching methods (pullBooks,
-- downloadBook, downloadCover) require live KOReader services (Spore,
-- httpclient, NetworkMgr, UIManager.looper) and are exercised via the manual
-- test matrix in docs/library-design.md, not unit tests — stubbing that
-- surface would balloon test setup with little additional confidence.
local M = {}
local EXTS = require("library.exts")
-- ---------------------------------------------------------------------------
-- Constants
-- ---------------------------------------------------------------------------
-- Cloud storage layout under each user's bucket prefix. Web side calls this
-- CLOUD_BOOKS_SUBDIR ("Readest/Books") at apps/readest-app/src/services/constants.ts:35.
local CLOUD_BOOKS_SUBDIR = "Readest/Books"
-- The server's /storage/download fallback accepts any 5-part fileKey shaped
-- like {user_id}/Readest/Books/{book_hash}/{anything}.{ext} and resolves it
-- via a (book_hash, file_key endsWith .ext) lookup in the `files` table —
-- see apps/readest-app/src/pages/api/storage/download.ts:99-107. We therefore
-- send the simple S3-style {hash}/{hash}.{ext} variant; on R2 deployments the
-- fallback transparently rewrites to the actual stored filename.
-- ---------------------------------------------------------------------------
-- build_file_key: cloud download fileKey for a book file
-- ---------------------------------------------------------------------------
function M.build_file_key(book)
if not book then return nil end
if not book.user_id or book.user_id == "" then return nil end
if not book.hash or book.hash == "" then return nil end
local ext = EXTS[book.format]
if not ext then return nil end
return string.format("%s/%s/%s/%s.%s",
book.user_id, CLOUD_BOOKS_SUBDIR, book.hash, book.hash, ext)
end
-- ---------------------------------------------------------------------------
-- build_cover_key: cloud download fileKey for a cover image
-- ---------------------------------------------------------------------------
-- The cover filename is `cover.png` regardless of storage type — same on R2
-- and S3 per `apps/readest-app/src/utils/book.ts:32`. No extension switching.
function M.build_cover_key(book)
if not book then return nil end
if not book.user_id or book.user_id == "" then return nil end
if not book.hash or book.hash == "" then return nil end
return string.format("%s/%s/%s/cover.png",
book.user_id, CLOUD_BOOKS_SUBDIR, book.hash)
end
-- ---------------------------------------------------------------------------
-- build_local_filename: where downloaded book bytes land on disk
-- ---------------------------------------------------------------------------
-- Per design doc: KOReader users prefer flat dirs in their book folder, so
-- downloads land at {library_download_dir}/{safe_title}.{ext} — no nested
-- {hash}/ subdir. The local filename does NOT need to byte-match what Readest
-- writes on the cloud-side filesystem: the only consumer is KOReader's own
-- FileManager, so a simple Lua-native sanitizer is enough (no JS-parity port
-- of makeSafeFilename).
local MAX_BODY_LEN = 200 -- bytes; leaves room for ".extension" suffix
function M.build_local_filename(book)
if not book then return nil end
local ext = EXTS[book.format]
if not ext then return nil end
local raw = book.source_title or book.title or ""
if raw == "" then return "book." .. ext end
-- Replace filesystem-illegal chars + control chars (bytes 0x00-0x1F) with _
-- Safe set covers Windows + macOS + ext4: < > : " / \ | ? * and 0x00-0x1F
local safe = raw:gsub('[<>:|"?*\\/%c]', "_")
-- Byte-clamp; raw byte length is what file systems care about. We may
-- truncate mid-codepoint here, but downstream display is via FileManager
-- which renders bytes as "?" rather than crashing. For v1 we accept this
-- edge — long titles are rare and the user has the cloud copy regardless.
if #safe > MAX_BODY_LEN then
safe = safe:sub(1, MAX_BODY_LEN)
end
-- A pure-_ result (e.g. title was just "????") would round-trip to a
-- weird filename — fall back to "book" in that case.
if safe:match("^_+$") then
safe = "book"
end
return safe .. "." .. ext
end
-- ---------------------------------------------------------------------------
-- resolve_collision: bumps {name}.ext → {name} (1).ext on filename clash
-- ---------------------------------------------------------------------------
-- Takes a candidate filename and a `exists(name) -> bool` predicate; returns
-- a name that doesn't collide. Caller (downloadBook) supplies a predicate
-- that calls lfs.attributes on the destination dir.
function M.resolve_collision(candidate, exists)
if not exists(candidate) then return candidate end
-- Split on the LAST dot so multi-dot titles ("Foo. Vol. 1.epub") still
-- bump correctly: base = "Foo. Vol. 1", ext = "epub".
local base, ext = candidate:match("^(.+)%.([^.]+)$")
if not base then
base = candidate
ext = nil
end
for n = 1, 99 do
local probe
if ext then
probe = string.format("%s (%d).%s", base, n, ext)
else
probe = string.format("%s (%d)", base, n)
end
if not exists(probe) then return probe end
end
-- Should never happen in practice; user has bigger problems if it does.
return candidate
end
-- ---------------------------------------------------------------------------
-- row_to_wire(row) — convert our internal snake_case row to the
-- camelCase Book shape Readest's server expects on POST /sync (mirrors
-- transformBookToDB at apps/readest-app/src/utils/transform.ts:66-105 —
-- but inverted, reading FROM the local row INTO the camelCase wire
-- format that the server itself converts back to DB shape).
-- ---------------------------------------------------------------------------
local function row_to_wire(row)
if not row then return nil end
-- ljsqlite3 returns INTEGER columns as int64_t cdata; dkjson chokes
-- on cdata and the request fails with status=nil. Re-cast defensively.
local function num(v) return v and tonumber(v) or v end
local out = {
bookHash = row.hash,
hash = row.hash,
metaHash = row.meta_hash,
format = row.format,
title = row.title,
author = row.author,
sourceTitle = row.source_title,
groupId = row.group_id,
groupName = row.group_name,
readingStatus = row.reading_status,
createdAt = num(row.created_at),
updatedAt = num(row.updated_at),
deletedAt = num(row.deleted_at),
uploadedAt = num(row.uploaded_at),
}
-- metadata: server stringifies what we send, so pass the parsed
-- table (NOT the metadata_json string, or it'd get double-encoded).
if row.metadata_json and row.metadata_json ~= "" then
local json = require("json")
local ok, parsed = pcall(json.decode, row.metadata_json)
if ok and type(parsed) == "table" then out.metadata = parsed end
end
-- progress: stored locally as JSON tuple [cur, total] in progress_lib;
-- the wire format expects the actual array.
if row.progress_lib and row.progress_lib ~= "" then
local json = require("json")
local ok, parsed = pcall(json.decode, row.progress_lib)
if ok and type(parsed) == "table" then out.progress = parsed end
end
return out
end
M._row_to_wire = row_to_wire -- exported for tests
-- ---------------------------------------------------------------------------
-- pushBook(book_row, opts, cb) — POST a single book row to /sync. Used
-- after touchBook bumps updated_at on reader close, mirroring what
-- Readest web does after every reading session.
--
-- opts: { sync_auth, sync_path, settings }
-- cb: function(success, msg, status)
-- ---------------------------------------------------------------------------
function M.pushBook(book_row, opts, cb)
local logger = require("logger")
local SyncAuth = opts.sync_auth
if not book_row or not book_row.hash then
if cb then cb(false, "missing book row") end
return
end
SyncAuth:withFreshToken(opts.settings, opts.sync_path, function(ok)
if not ok then
if cb then cb(false, "auth refresh failed") end
return
end
local client = SyncAuth:getReadestSyncClient(opts.settings, opts.sync_path)
if not client then
if cb then cb(false, "no sync client") end
return
end
local payload = {
books = { row_to_wire(book_row) },
notes = {},
configs = {},
}
logger.info("ReadestLibrary pushBook: hash=" .. book_row.hash)
client:pushChanges(payload, function(success, body, status)
logger.info("ReadestLibrary pushBook done: success=" .. tostring(success)
.. " status=" .. tostring(status))
if not success then
if cb then cb(false, body and body.error or "push failed", status) end
return
end
if cb then cb(true) end
end)
end)
end
-- ---------------------------------------------------------------------------
-- pushChangedBooks(opts, cb) — push every book row that's changed since the
-- watermark, in one /sync POST (server batches internally). Mirrors
-- useBooksSync's getNewBooks → syncBooks(newBooks, 'push') flow at
-- apps/readest-app/src/app/library/hooks/useBooksSync.ts:88-94.
--
-- After a successful push, advances the watermark to the max
-- updated_at | deleted_at of the rows we sent (so the next sync's
-- getChangedBooks query doesn't re-include them).
-- ---------------------------------------------------------------------------
function M.pushChangedBooks(opts, cb)
local logger = require("logger")
local SyncAuth = opts.sync_auth
local store = opts.store
if not store then
if cb then cb(false, "no store") end
return
end
local since = store:getLastPulledAt() or 0
local changed = store:getChangedBooks(since)
if #changed == 0 then
logger.info("ReadestLibrary pushChangedBooks: nothing to push (since=" .. since .. ")")
if cb then cb(true, 0) end
return
end
SyncAuth:withFreshToken(opts.settings, opts.sync_path, function(ok)
if not ok then
if cb then cb(false, "auth refresh failed") end
return
end
local client = SyncAuth:getReadestSyncClient(opts.settings, opts.sync_path)
if not client then
if cb then cb(false, "no sync client") end
return
end
local books_wire = {}
local max_ts = since
for i, row in ipairs(changed) do
books_wire[i] = row_to_wire(row)
if row.updated_at and row.updated_at > max_ts then max_ts = row.updated_at end
if row.deleted_at and row.deleted_at > max_ts then max_ts = row.deleted_at end
end
logger.info("ReadestLibrary pushChangedBooks: pushing " .. #books_wire
.. " row(s) (since=" .. tostring(since)
.. " new_watermark=" .. tostring(max_ts) .. ")")
client:pushChanges({ books = books_wire, notes = {}, configs = {} },
function(success, body, status)
logger.info("ReadestLibrary pushChangedBooks done: success=" .. tostring(success)
.. " status=" .. tostring(status))
if not success then
if cb then cb(false, body and body.error or "push failed", status) end
return
end
store:setLastPulledAt(max_ts)
if cb then cb(true, #books_wire) end
end)
end)
end
-- ---------------------------------------------------------------------------
-- syncBooks(opts, mode, cb) — convenience wrapper for the bidirectional
-- sync the web does on auto-sync (useBooksSync.handleAutoSync). Modes:
-- "push" — pushChangedBooks only
-- "pull" — pullBooks only (existing fetch)
-- "both" — push then pull (matches the web's syncBooks(..., 'both') call)
-- cb is invoked once after the LAST step completes; intermediate failures
-- are logged but do not abort (push failure shouldn't prevent pull).
-- ---------------------------------------------------------------------------
function M.syncBooks(opts, mode, cb)
mode = mode or "both"
if mode == "push" then
M.pushChangedBooks(opts, cb)
elseif mode == "pull" then
M.pullBooks(opts, cb)
else -- "both"
M.pushChangedBooks(opts, function(push_ok, push_msg)
M.pullBooks(opts, function(pull_ok, pull_msg, pull_status)
if cb then
cb(push_ok and pull_ok,
string.format("push=%s/%s pull=%s/%s",
tostring(push_ok), tostring(push_msg),
tostring(pull_ok), tostring(pull_msg)),
pull_status)
end
end)
end)
end
end
-- ---------------------------------------------------------------------------
-- Network methods (live KOReader required; not unit-tested)
-- ---------------------------------------------------------------------------
-- See spec for `M.pullBooks`, `M.downloadBook`, `M.downloadCover` in the
-- design doc's Sync flow section. Implementation lives below; structured so
-- the pure helpers stay separately exportable for tests.
-- pullBooks(opts, cb)
-- opts: {
-- sync_auth = SyncAuth instance (for withFreshToken),
-- sync_path = path to the koplugin dir (for the spore spec lookup),
-- settings = current G_reader_settings.readest_sync,
-- store = LibraryStore instance,
-- }
-- cb: function(success, msg)
function M.pullBooks(opts, cb)
local logger = require("logger")
local SyncAuth = opts.sync_auth
local LibraryStore = require("library.librarystore")
logger.info("ReadestLibrary syncbooks.pullBooks: starting")
-- Ensure JWT is fresh before issuing the call (codex round 1: today's
-- ensureClient() refreshes async-and-races; the new wrapper blocks until
-- the refresh completes, so the request never fires with a stale token).
SyncAuth:withFreshToken(opts.settings, opts.sync_path, function(ok, err)
logger.info("ReadestLibrary withFreshToken result: ok=" .. tostring(ok) .. " err=" .. tostring(err))
if not ok then
if cb then cb(false, err or "auth refresh failed") end
return
end
local client = SyncAuth:getReadestSyncClient(opts.settings, opts.sync_path)
if not client then
logger.warn("ReadestLibrary getReadestSyncClient returned nil; settings={"
.. "access_token=" .. tostring(opts.settings.access_token and "<set>" or "<nil>")
.. ", expires_at=" .. tostring(opts.settings.expires_at)
.. ", now=" .. tostring(os.time()) .. "}")
if cb then cb(false, "no sync client (not authenticated?)") end
return
end
local since = opts.store:getLastPulledAt() or 0
logger.info("ReadestLibrary client:pullBooks dispatching with since=" .. tostring(since))
client:pullBooks({ since = since }, function(success, body, status)
logger.info("ReadestLibrary client:pullBooks responded: success=" .. tostring(success)
.. " status=" .. tostring(status)
.. " body_type=" .. type(body)
.. " rows=" .. tostring(body and body.books and #body.books or "n/a"))
if not success then
if status == 401 or status == 403
or (body and body.error == "Not authenticated") then
if cb then cb(false, "auth", status) end
else
if cb then cb(false, body and body.error or "pull failed", status) end
end
return
end
local rows = body and body.books or {}
local max_ts = 0
local upserted = 0
for _, raw in ipairs(rows) do
local parsed = LibraryStore.parseSyncRow(raw)
if parsed then
parsed.user_id = opts.settings.user_id
opts.store:upsertBook(parsed)
upserted = upserted + 1
-- Watermark = max of returned updated_at | deleted_at,
-- not local now (codex round 1, finding 8).
if parsed.updated_at and parsed.updated_at > max_ts then
max_ts = parsed.updated_at
end
if parsed.deleted_at and parsed.deleted_at > max_ts then
max_ts = parsed.deleted_at
end
end
end
if max_ts > 0 then opts.store:setLastPulledAt(max_ts) end
logger.info("ReadestLibrary pullBooks complete: rows=" .. #rows
.. " upserted=" .. upserted .. " new_watermark=" .. max_ts)
if cb then cb(true, upserted) end
end)
end)
end
-- downloadBook(book, opts, cb)
-- opts: {
-- sync_auth, sync_path, settings,
-- download_dir = absolute path; created if missing,
-- }
-- book: a row from LibraryStore (must include hash, format, title/source_title)
-- cb: function(success, abs_path_or_err, status)
function M.downloadBook(book, opts, cb)
local logger = require("logger")
local lfs = require("libs/libkoreader-lfs")
local SyncAuth = opts.sync_auth
logger.info("ReadestLibrary downloadBook: hash=" .. tostring(book.hash)
.. " format=" .. tostring(book.format)
.. " title=" .. tostring(book.title))
local file_key = M.build_file_key({
user_id = opts.settings.user_id,
hash = book.hash,
format = book.format,
})
if not file_key then
logger.warn("ReadestLibrary downloadBook: build_file_key returned nil"
.. " (user_id_set=" .. tostring(opts.settings.user_id ~= nil)
.. " hash_set=" .. tostring(book.hash ~= nil and book.hash ~= "")
.. " format=" .. tostring(book.format) .. ")")
if cb then cb(false, "could not build cloud fileKey for book") end
return
end
logger.info("ReadestLibrary downloadBook: file_key=" .. file_key)
local local_name = M.build_local_filename(book)
if not local_name then
logger.warn("ReadestLibrary downloadBook: build_local_filename returned nil")
if cb then cb(false, "unknown book format") end
return
end
if not lfs.attributes(opts.download_dir, "mode") then
logger.info("ReadestLibrary downloadBook: creating download_dir " .. tostring(opts.download_dir))
lfs.mkdir(opts.download_dir)
end
local exists = function(name)
return lfs.attributes(opts.download_dir .. "/" .. name, "mode") ~= nil
end
local final_name = M.resolve_collision(local_name, exists)
local dst = opts.download_dir .. "/" .. final_name
logger.info("ReadestLibrary downloadBook: dst=" .. dst)
logger.info("ReadestLibrary downloadBook: requesting fresh token…")
SyncAuth:withFreshToken(opts.settings, opts.sync_path, function(ok)
logger.info("ReadestLibrary downloadBook: withFreshToken returned ok=" .. tostring(ok))
if not ok then
if cb then cb(false, "auth refresh failed") end
return
end
local client = SyncAuth:getReadestSyncClient(opts.settings, opts.sync_path)
if not client then
logger.warn("ReadestLibrary downloadBook: getReadestSyncClient returned nil")
if cb then cb(false, "no sync client") end
return
end
logger.info("ReadestLibrary downloadBook: dispatching getDownloadUrl…")
client:getDownloadUrl({ fileKey = file_key }, function(success, body, status)
logger.info("ReadestLibrary downloadBook: getDownloadUrl responded"
.. " success=" .. tostring(success)
.. " status=" .. tostring(status)
.. " body_type=" .. type(body)
.. " has_url=" .. tostring(body and body.downloadUrl ~= nil))
if not success or not body or not body.downloadUrl then
local err = (status == 404) and "cloud-not-found"
or (body and body.error)
or "url-fetch-failed"
if cb then cb(false, err, status) end
return
end
local url = body.downloadUrl
logger.info("ReadestLibrary downloadBook: streaming GET " .. url:sub(1, 80) .. "")
-- Use socket.http synchronously with ltn12 file sink — same
-- pattern as KOReader's OPDS downloader (opdsbrowser.lua:1036)
-- and dropboxapi (dropboxapi.lua:39). The async httpclient
-- path we used before only fires its callback inside an
-- active Spore coroutine; calling it from a getDownloadUrl
-- callback doesn't satisfy that, so the response was never
-- delivered and the "Downloading…" dialog hung forever.
-- Synchronous blocks the UI for the duration of the
-- download, which matches OPDS UX (progress dialog stays
-- visible; users expect the brief freeze).
local socket = require("socket")
local http = require("socket.http")
local socketutil = require("socketutil")
local ltn12 = require("ltn12")
local f, ferr = io.open(dst, "wb")
if not f then
logger.warn("ReadestLibrary downloadBook: io.open failed " .. tostring(ferr))
if cb then cb(false, "open dst failed: " .. tostring(ferr)) end
return
end
socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT)
local code, headers, http_status = socket.skip(1, http.request{
url = url,
headers = { ["Accept-Encoding"] = "identity" },
sink = ltn12.sink.file(f),
})
socketutil:reset_timeout()
logger.info("ReadestLibrary downloadBook: socket.http response"
.. " code=" .. tostring(code) .. " status=" .. tostring(http_status))
if code ~= 200 then
-- sink already closed by ltn12 on error; remove the
-- partial file so a retry doesn't trip the
-- collision-resolution suffix.
os.remove(dst)
if cb then cb(false, "download failed", code) end
return
end
logger.info("ReadestLibrary downloadBook: wrote " .. dst)
if cb then cb(true, dst) end
end)
end)
end
-- downloadCover(book, opts, cb)
-- opts: {
-- sync_auth, sync_path, settings,
-- covers_dir = absolute path to readest_covers cache,
-- }
-- cb: function(success, abs_path_or_err, status)
-- A 404 is recorded as a success-with-no-cover so we don't retry forever:
-- callers should set cover_path = "_missing" sentinel when status == 404.
function M.downloadCover(book, opts, cb)
local lfs = require("libs/libkoreader-lfs")
local SyncAuth = opts.sync_auth
local file_key = M.build_cover_key({
user_id = opts.settings.user_id,
hash = book.hash,
})
if not file_key then
if cb then cb(false, "missing user_id or hash") end
return
end
if not lfs.attributes(opts.covers_dir, "mode") then
lfs.mkdir(opts.covers_dir)
end
local dst = opts.covers_dir .. "/" .. book.hash .. ".png"
SyncAuth:withFreshToken(opts.settings, opts.sync_path, function(ok)
if not ok then
if cb then cb(false, "auth refresh failed") end
return
end
local client = SyncAuth:getReadestSyncClient(opts.settings, opts.sync_path)
if not client then
if cb then cb(false, "no sync client") end
return
end
client:getDownloadUrl({ fileKey = file_key }, function(success, body, status)
if status == 404 then
if cb then cb(false, "no-cover", 404) end
return
end
if not success or not body or not body.downloadUrl then
if cb then cb(false, "url-fetch-failed", status) end
return
end
-- Background download via fork+poll. The UI stays fully
-- responsive because the actual blocking IO happens in the
-- subprocess; the parent just polls every 300ms to see if
-- it's done. Same pattern KOReader's BookInfoManager uses
-- for cover extraction (bookinfomanager.lua:721).
-- httpclient/Turbo would be nicer but isn't available on
-- platforms KOReader builds without UIManager.looper
-- (macOS desktop, etc).
local FFIUtil = require("ffi/util")
local UIManager = require("ui/uimanager")
local url = body.downloadUrl
local pid, parent_read_fd = FFIUtil.runInSubProcess(
function(_child_pid, child_write_fd)
local socket = require("socket")
local http = require("socket.http")
local socketutil = require("socketutil")
local ltn12 = require("ltn12")
local result
local f, ferr = io.open(dst, "wb")
if not f then
result = "error:open:" .. tostring(ferr)
else
socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT)
local code = socket.skip(1, http.request{
url = url,
headers = { ["Accept-Encoding"] = "identity" },
sink = ltn12.sink.file(f),
})
socketutil:reset_timeout()
if code == 200 then
result = "ok"
elseif code == 404 then
result = "404"
else
result = "error:http:" .. tostring(code)
end
end
FFIUtil.writeToFD(child_write_fd, result, true)
end,
true) -- with_pipe = true
if not pid then
if cb then cb(false, "fork failed") end
return
end
local poll_interval = 0.3
local poll
poll = function()
if FFIUtil.isSubProcessDone(pid) then
local result = FFIUtil.readAllFromFD(parent_read_fd) or ""
if result == "ok" then
if cb then cb(true, dst) end
elseif result == "404" then
os.remove(dst)
if cb then cb(false, "no-cover", 404) end
else
os.remove(dst)
if cb then cb(false, result, nil) end
end
else
UIManager:scheduleIn(poll_interval, poll)
end
end
UIManager:scheduleIn(poll_interval, poll)
end)
end)
end
-- ---------------------------------------------------------------------------
-- uploadBook(book, opts, cb) — push a local book file to Readest cloud.
-- ---------------------------------------------------------------------------
-- Two-step flow mirroring `apps/readest-app/src/libs/storage.ts:42-78`:
-- 1. POST /storage/upload {fileName, fileSize, bookHash} → server
-- validates quota, inserts a row in `files`, returns a presigned
-- PUT URL valid for 30 min.
-- 2. PUT raw bytes to that URL via socket.http (synchronous, mirrors
-- downloadBook for the same UX trade-off — UI freezes during the
-- upload but the dialog stays visible).
--
-- Cover.png handling is intentionally minimal in v1: if a cover is
-- already cached at <covers_dir>/<hash>.png (from a prior cloud
-- download), upload it too. Books without a cached cover skip the cover
-- step silently — the server tolerates books with no cover row.
--
-- opts: { sync_auth, sync_path, settings, covers_dir = optional }
-- book: row with { hash, format, file_path, title, source_title }
-- cb: function(success, msg, status)
function M.uploadBook(book, opts, cb)
local logger = require("logger")
local lfs = require("libs/libkoreader-lfs")
local SyncAuth = opts.sync_auth
if not book or not book.hash or not book.format or not book.file_path then
if cb then cb(false, "missing book info") end
return
end
local ext = EXTS[book.format]
if not ext then
if cb then cb(false, "unsupported format") end
return
end
local attr = lfs.attributes(book.file_path)
if not attr or attr.mode ~= "file" then
if cb then cb(false, "local file missing") end
return
end
local fileSize = attr.size
-- Cloud-relative path: matches getRemoteBookFilename for S3 storage
-- (apps/readest-app/src/utils/book.ts:24). Server prepends "<user.id>/"
-- to form the final fileKey.
local bookFileName = string.format("%s/%s/%s.%s",
CLOUD_BOOKS_SUBDIR, book.hash, book.hash, ext)
local cover_path = opts.covers_dir
and (opts.covers_dir .. "/" .. book.hash .. ".png") or nil
local cover_attr = cover_path and lfs.attributes(cover_path) or nil
local has_cover = cover_attr and cover_attr.mode == "file"
-- Synchronous PUT helper. Returns (ok, code, body_or_err) — body
-- captures the S3/R2 XML error response on failure, so the caller
-- can log something more useful than just "table: 0x...". Bug
-- before: I had `local _, code = socket.skip(1, http.request{...})`,
-- which assigned the headers table (the second value after skip) to
-- `code`, resulting in log lines like "code=table: 0x01156a2d48".
-- socket.skip(1, ...) drops the first return value of http.request,
-- so the first remaining return IS the HTTP status code.
local function put_bytes(url, src_path, size)
local socket = require("socket")
local http = require("socket.http")
local socketutil = require("socketutil")
local ltn12 = require("ltn12")
local f, ferr = io.open(src_path, "rb")
if not f then return false, nil, "open: " .. tostring(ferr) end
local body_chunks = {}
socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT)
local code, headers, status_line = socket.skip(1, http.request{
url = url,
method = "PUT",
source = ltn12.source.file(f),
headers = { ["content-length"] = tostring(size) },
sink = ltn12.sink.table(body_chunks),
})
socketutil:reset_timeout()
local ok = (code == 200 or code == 204)
local body = table.concat(body_chunks)
if not ok then
logger.warn("ReadestLibrary uploadBook PUT non-2xx: code="
.. tostring(code) .. " status=" .. tostring(status_line)
.. " ctype=" .. tostring(headers and headers["content-type"])
.. " body_len=" .. #body
.. " body_head=" .. tostring(body:sub(1, 400)))
end
return ok, code, body
end
SyncAuth:withFreshToken(opts.settings, opts.sync_path, function(ok)
if not ok then
if cb then cb(false, "auth refresh failed") end
return
end
local client = SyncAuth:getReadestSyncClient(opts.settings, opts.sync_path)
if not client then
if cb then cb(false, "no sync client") end
return
end
-- Step 1: book file presigned URL
logger.info("ReadestLibrary uploadBook: requesting URL for "
.. bookFileName .. " (size=" .. fileSize .. ")")
client:getUploadUrl({
fileName = bookFileName,
fileSize = fileSize,
bookHash = book.hash,
}, function(s, body, status)
if not s or not body or not body.uploadUrl then
local msg
if status == 403 and body and body.error then
msg = body.error -- "Insufficient storage quota"
else
msg = "upload-url-failed"
end
logger.warn("ReadestLibrary uploadBook: getUploadUrl failed status="
.. tostring(status) .. " msg=" .. tostring(msg)
.. " body=" .. tostring(body and body.error or "<nil>"))
if cb then cb(false, msg, status) end
return
end
logger.info("ReadestLibrary uploadBook: presigned URL received"
.. " (host=" .. tostring(body.uploadUrl:match("^https?://([^/]+)") or "?")
.. ", quota_usage=" .. tostring(body.usage)
.. " quota=" .. tostring(body.quota) .. ")")
-- Step 2: PUT book bytes
local put_ok, put_code, put_body = put_bytes(body.uploadUrl, book.file_path, fileSize)
logger.info("ReadestLibrary uploadBook: book PUT code=" .. tostring(put_code)
.. " ok=" .. tostring(put_ok))
if not put_ok then
local err = "book upload failed"
if put_body and #put_body > 0 then
-- S3/R2 returns XML on error; surface the <Code>...</Code> tag
-- if present so the caller's toast can show something useful.
local s3code = put_body:match("<Code>(.-)</Code>")
if s3code then err = err .. " (" .. s3code .. ")" end
end
if cb then cb(false, err, put_code) end
return
end
-- Optional cover upload — best-effort.
if has_cover then
local coverFileName = string.format("%s/%s/cover.png",
CLOUD_BOOKS_SUBDIR, book.hash)
local cover_size = cover_attr.size
client:getUploadUrl({
fileName = coverFileName,
fileSize = cover_size,
bookHash = book.hash,
}, function(s2, b2, status2)
if s2 and b2 and b2.uploadUrl then
local c_ok, c_code = put_bytes(b2.uploadUrl, cover_path, cover_size)
logger.info("ReadestLibrary uploadBook: cover PUT code="
.. tostring(c_code) .. " ok=" .. tostring(c_ok))
else
logger.info("ReadestLibrary uploadBook: cover URL skipped status="
.. tostring(status2))
end
if cb then cb(true) end
end)
else
if cb then cb(true) end
end
end)
end)
end
-- ---------------------------------------------------------------------------
-- deleteCloudFiles(book, opts, cb) — discover the storage objects for a
-- book hash via /storage/list, then DELETE each one. Mirrors Readest's
-- cloudService.deleteBook flow at apps/readest-app/src/services/
-- cloudService.ts:43-54 + libs/storage.ts:180-195.
--
-- The DELETE endpoint requires the literal file_key (no extension
-- fallback like /storage/download has), so we MUST list first to learn
-- the actual filenames — they may differ between R2 ({title}.{ext}) and
-- S3 ({hash}.{ext}) deployments.
--
-- Tolerates per-file failures (matches the web client's try/catch
-- around each delete) and reports overall success when at least one
-- delete succeeded — so a missing cover doesn't fail the book delete.
--
-- opts: { sync_auth, sync_path, settings }
-- cb: function(success, msg, status)
-- ---------------------------------------------------------------------------
function M.deleteCloudFiles(book, opts, cb)
local logger = require("logger")
local SyncAuth = opts.sync_auth
if not book or not book.hash then
if cb then cb(false, "missing book") end
return
end
SyncAuth:withFreshToken(opts.settings, opts.sync_path, function(ok)
if not ok then
if cb then cb(false, "auth refresh failed") end
return
end
local client = SyncAuth:getReadestSyncClient(opts.settings, opts.sync_path)
if not client then
if cb then cb(false, "no sync client") end
return
end
logger.info("ReadestLibrary deleteCloudFiles: hash=" .. book.hash)
client:listFiles({ bookHash = book.hash }, function(success, body, status)
if not success then
logger.warn("ReadestLibrary deleteCloudFiles: listFiles failed status="
.. tostring(status))
if cb then cb(false, body and body.error or "list failed", status) end
return
end
local files = body and body.files or {}
if #files == 0 then
logger.info("ReadestLibrary deleteCloudFiles: no files for hash "
.. book.hash .. " — already gone")
if cb then cb(true, 0) end
return
end
-- Sequential delete (one at a time): the web client tolerates
-- per-file failures and we want to mirror that without hiding
-- partial-success cases. Fire DELETEs one after another via
-- callback chaining; track per-file outcomes.
local total = #files
local done, ok_count = 0, 0
local last_status
local function step(i)
if i > total then
logger.info("ReadestLibrary deleteCloudFiles: " .. ok_count
.. "/" .. total .. " deleted (last_status="
.. tostring(last_status) .. ")")
if cb then
cb(ok_count > 0, ok_count, last_status)
end
return
end
local fkey = files[i].file_key
logger.info("ReadestLibrary deleteCloudFiles: deleting " .. tostring(fkey))
client:deleteFile({ fileKey = fkey }, function(s, _b, st)
done = done + 1
if s then ok_count = ok_count + 1 end
last_status = st
if not s then
logger.warn("ReadestLibrary deleteCloudFiles: failed to delete "
.. tostring(fkey) .. " status=" .. tostring(st))
end
step(i + 1)
end)
end
step(1)
end)
end)
end
return M
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "يحافظ على مزامنة أجهزة KOReader و Readest الخاصة بك."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "العنوان"
msgid "Author"
msgstr "المؤلف"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "إلغاء"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "تعيين المزامنة التلقائية للتقدم"
@@ -41,12 +158,27 @@ msgstr "إرسال ملاحظات Readest من هذا الجهاز"
msgid "Pull readest annotations from other devices"
msgstr "جلب ملاحظات Readest من الأجهزة الأخرى"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "تسجيل الدخول إلى حساب Readest"
msgid "Log out as %1"
msgstr "تسجيل الخروج من %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "مزامنة التقدم والملاحظات تلقائياً"
@@ -92,12 +224,6 @@ msgstr "لم تتم المزامنة مطلقاً"
msgid "Book Fingerprint"
msgstr "بصمة الكتاب"
msgid "Title"
msgstr "العنوان"
msgid "Author"
msgstr "المؤلف"
msgid "Identifiers"
msgstr "المعرفات"
@@ -107,6 +233,15 @@ msgstr "آخر مزامنة"
msgid "Sync Info"
msgstr "معلومات المزامنة"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "التحقق من التحديث…"
@@ -167,6 +302,9 @@ msgstr "مزامنة كاملة: جلب جميع الملاحظات…"
msgid "Pulling annotations..."
msgstr "جارٍ جلب الملاحظات…"
msgid "Authentication failed, please login again"
msgstr "فشل المصادقة، يرجى تسجيل الدخول مرة أخرى"
msgid "Failed to pull annotations"
msgstr "فشل جلب الملاحظات"
@@ -176,9 +314,6 @@ msgstr "لم يتم العثور على ملاحظات جديدة"
msgid "%1 annotations pulled"
msgstr "تم جلب %1 ملاحظات"
msgid "Cancel"
msgstr "إلغاء"
msgid "Login"
msgstr "تسجيل الدخول"
@@ -218,9 +353,6 @@ msgstr "فشل إرسال تقدم القراءة"
msgid "Pulling reading progress..."
msgstr "جارٍ جلب تقدم القراءة…"
msgid "Authentication failed, please login again"
msgstr "فشل المصادقة، يرجى تسجيل الدخول مرة أخرى"
msgid "Failed to pull reading progress"
msgstr "فشل جلب تقدم القراءة"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "আপনার KOReader ও Readest ডিভাইসগুলিকে সিঙ্কে রাখে।"
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "শিরোনাম"
msgid "Author"
msgstr "লেখক"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "বাতিল"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "স্বয়ংক্রিয় অগ্রগতি সিঙ্ক সেট করুন"
@@ -41,12 +158,27 @@ msgstr "এই ডিভাইস থেকে Readest টীকা পাঠা
msgid "Pull readest annotations from other devices"
msgstr "অন্যান্য ডিভাইস থেকে Readest টীকা আনুন"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Readest অ্যাকাউন্টে লগ ইন করুন"
msgid "Log out as %1"
msgstr "%1 হিসেবে লগ আউট করুন"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "স্বয়ংক্রিয়ভাবে অগ্রগতি ও টীকা সিঙ্ক করুন"
@@ -92,12 +224,6 @@ msgstr "কখনও সিঙ্ক করা হয়নি"
msgid "Book Fingerprint"
msgstr "বইয়ের ফিঙ্গারপ্রিন্ট"
msgid "Title"
msgstr "শিরোনাম"
msgid "Author"
msgstr "লেখক"
msgid "Identifiers"
msgstr "শনাক্তকারী"
@@ -107,6 +233,15 @@ msgstr "শেষ সিঙ্ক"
msgid "Sync Info"
msgstr "সিঙ্ক তথ্য"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "আপডেট পরীক্ষা করা হচ্ছে…"
@@ -167,6 +302,9 @@ msgstr "পূর্ণ সিঙ্ক: সকল টীকা আনা হচ
msgid "Pulling annotations..."
msgstr "টীকা আনা হচ্ছে…"
msgid "Authentication failed, please login again"
msgstr "প্রমাণীকরণ ব্যর্থ, অনুগ্রহ করে আবার লগ ইন করুন"
msgid "Failed to pull annotations"
msgstr "টীকা আনা ব্যর্থ"
@@ -176,9 +314,6 @@ msgstr "নতুন কোনো টীকা পাওয়া যায়
msgid "%1 annotations pulled"
msgstr "%1টি টীকা আনা হয়েছে"
msgid "Cancel"
msgstr "বাতিল"
msgid "Login"
msgstr "লগ ইন"
@@ -218,9 +353,6 @@ msgstr "পড়ার অগ্রগতি পাঠানো ব্যর্
msgid "Pulling reading progress..."
msgstr "পড়ার অগ্রগতি আনা হচ্ছে…"
msgid "Authentication failed, please login again"
msgstr "প্রমাণীকরণ ব্যর্থ, অনুগ্রহ করে আবার লগ ইন করুন"
msgid "Failed to pull reading progress"
msgstr "পড়ার অগ্রগতি আনা ব্যর্থ"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "ཁྱེད་ཀྱི་ KOReader དང་ Readest སྒྲིག་ཆས་མཉམ་འགྱུར་འཇོག"
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "དཔེ་དེབ་ཀྱི་མིང་།"
msgid "Author"
msgstr "རྩོམ་པ་པོ།"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "འདོར་བ།"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "རང་འགུལ་འཕེལ་རིམ་མཉམ་འགྱུར་སྒྲིག"
@@ -41,12 +158,27 @@ msgstr "སྒྲིག་ཆས་འདི་ནས་ Readest མཆན་འ
msgid "Pull readest annotations from other devices"
msgstr "སྒྲིག་ཆས་གཞན་ནས་ Readest མཆན་འགྲེལ་ལེན་པ།"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Readest རྩིས་ཐོར་འཛུལ་ཞུགས།"
msgid "Log out as %1"
msgstr "%1 ནས་ཕྱིར་ཐོན།"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "འཕེལ་རིམ་དང་མཆན་འགྲེལ་རང་འགུལ་མཉམ་འགྱུར།"
@@ -92,12 +224,6 @@ msgstr "མཉམ་འགྱུར་མ་བྱས།"
msgid "Book Fingerprint"
msgstr "དཔེ་ཆའི་མཛུབ་རྗེས།"
msgid "Title"
msgstr "དཔེ་དེབ་ཀྱི་མིང་།"
msgid "Author"
msgstr "རྩོམ་པ་པོ།"
msgid "Identifiers"
msgstr "ངོས་འཛིན་བྱེད་མཁན"
@@ -107,6 +233,15 @@ msgstr "མཐའ་མའི་མཉམ་འགྱུར།"
msgid "Sync Info"
msgstr "མཉམ་འགྱུར་གསལ་བཤད།"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "གསར་སྤེལ་ཞིབ་བཤེར་བཞིན་པ…"
@@ -167,6 +302,9 @@ msgstr "ཆ་ཚང་མཉམ་འགྱུར: མཆན་འགྲེལ
msgid "Pulling annotations..."
msgstr "མཆན་འགྲེལ་ལེན་བཞིན་པ…"
msgid "Authentication failed, please login again"
msgstr "ཡིད་ཆེས་ཞིབ་བཤེར་མ་ཐུབ། ཡང་བསྐྱར་འཛུལ་ཞུགས་གནང་རོགས།"
msgid "Failed to pull annotations"
msgstr "མཆན་འགྲེལ་ལེན་མ་ཐུབ།"
@@ -176,9 +314,6 @@ msgstr "མཆན་འགྲེལ་གསར་པ་མ་རྙེད།"
msgid "%1 annotations pulled"
msgstr "མཆན་འགྲེལ་ %1 ལེན་སོང་།"
msgid "Cancel"
msgstr "འདོར་བ།"
msgid "Login"
msgstr "འཛུལ་ཞུགས།"
@@ -218,9 +353,6 @@ msgstr "ཀློག་པའི་འཕེལ་རིམ་སྐུར་མ
msgid "Pulling reading progress..."
msgstr "ཀློག་པའི་འཕེལ་རིམ་ལེན་བཞིན་པ…"
msgid "Authentication failed, please login again"
msgstr "ཡིད་ཆེས་ཞིབ་བཤེར་མ་ཐུབ། ཡང་བསྐྱར་འཛུལ་ཞུགས་གནང་རོགས།"
msgid "Failed to pull reading progress"
msgstr "ཀློག་པའི་འཕེལ་རིམ་ལེན་མ་ཐུབ།"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Hält deine KOReader- und Readest-Geräte synchron."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Titel"
msgid "Author"
msgstr "Autor"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Abbrechen"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Automatischen Fortschritts-Sync einstellen"
@@ -41,12 +158,27 @@ msgstr "Readest-Anmerkungen von diesem Gerät senden"
msgid "Pull readest annotations from other devices"
msgstr "Readest-Anmerkungen von anderen Geräten abrufen"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Beim Readest-Konto anmelden"
msgid "Log out as %1"
msgstr "Abmelden als %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Fortschritt und Anmerkungen automatisch synchronisieren"
@@ -92,12 +224,6 @@ msgstr "Noch nie synchronisiert"
msgid "Book Fingerprint"
msgstr "Buch-Fingerabdruck"
msgid "Title"
msgstr "Titel"
msgid "Author"
msgstr "Autor"
msgid "Identifiers"
msgstr "Bezeichner"
@@ -107,6 +233,15 @@ msgstr "Zuletzt synchronisiert"
msgid "Sync Info"
msgstr "Sync-Info"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Suche nach Aktualisierungen …"
@@ -167,6 +302,9 @@ msgstr "Vollständige Synchronisierung: alle Anmerkungen werden abgerufen …"
msgid "Pulling annotations..."
msgstr "Anmerkungen werden abgerufen …"
msgid "Authentication failed, please login again"
msgstr "Authentifizierung fehlgeschlagen, bitte erneut anmelden"
msgid "Failed to pull annotations"
msgstr "Abrufen der Anmerkungen fehlgeschlagen"
@@ -176,9 +314,6 @@ msgstr "Keine neuen Anmerkungen gefunden"
msgid "%1 annotations pulled"
msgstr "%1 Anmerkungen abgerufen"
msgid "Cancel"
msgstr "Abbrechen"
msgid "Login"
msgstr "Anmelden"
@@ -218,9 +353,6 @@ msgstr "Senden des Lesefortschritts fehlgeschlagen"
msgid "Pulling reading progress..."
msgstr "Lesefortschritt wird abgerufen …"
msgid "Authentication failed, please login again"
msgstr "Authentifizierung fehlgeschlagen, bitte erneut anmelden"
msgid "Failed to pull reading progress"
msgstr "Abrufen des Lesefortschritts fehlgeschlagen"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Διατηρεί τις συσκευές KOReader και Readest συγχρονισμένες."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Τίτλος"
msgid "Author"
msgstr "Συγγραφέας"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Ακύρωση"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Ορισμός αυτόματου συγχρονισμού προόδου"
@@ -41,12 +158,27 @@ msgstr "Αποστολή σχολίων Readest από αυτή τη συσκε
msgid "Pull readest annotations from other devices"
msgstr "Λήψη σχολίων Readest από άλλες συσκευές"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Σύνδεση στον λογαριασμό Readest"
msgid "Log out as %1"
msgstr "Αποσύνδεση ως %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Αυτόματος συγχρονισμός προόδου και σχολίων"
@@ -92,12 +224,6 @@ msgstr "Δεν έχει συγχρονιστεί"
msgid "Book Fingerprint"
msgstr "Δακτυλικό αποτύπωμα βιβλίου"
msgid "Title"
msgstr "Τίτλος"
msgid "Author"
msgstr "Συγγραφέας"
msgid "Identifiers"
msgstr "Αναγνωριστικά"
@@ -107,6 +233,15 @@ msgstr "Τελευταίος συγχρονισμός"
msgid "Sync Info"
msgstr "Πληροφορίες συγχρονισμού"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Έλεγχος για ενημέρωση…"
@@ -167,6 +302,9 @@ msgstr "Πλήρης συγχρονισμός: λήψη όλων των σχολ
msgid "Pulling annotations..."
msgstr "Λήψη σχολίων…"
msgid "Authentication failed, please login again"
msgstr "Αποτυχία ταυτοποίησης, συνδεθείτε ξανά"
msgid "Failed to pull annotations"
msgstr "Η λήψη των σχολίων απέτυχε"
@@ -176,9 +314,6 @@ msgstr "Δεν βρέθηκαν νέα σχόλια"
msgid "%1 annotations pulled"
msgstr "Ελήφθησαν %1 σχόλια"
msgid "Cancel"
msgstr "Ακύρωση"
msgid "Login"
msgstr "Σύνδεση"
@@ -218,9 +353,6 @@ msgstr "Η αποστολή της προόδου ανάγνωσης απέτυ
msgid "Pulling reading progress..."
msgstr "Λήψη προόδου ανάγνωσης…"
msgid "Authentication failed, please login again"
msgstr "Αποτυχία ταυτοποίησης, συνδεθείτε ξανά"
msgid "Failed to pull reading progress"
msgstr "Η λήψη της προόδου ανάγνωσης απέτυχε"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Mantiene sincronizados tus dispositivos KOReader y Readest."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Título"
msgid "Author"
msgstr "Autor"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Cancelar"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Configurar sincronización automática del progreso"
@@ -41,12 +158,27 @@ msgstr "Enviar anotaciones de Readest desde este dispositivo"
msgid "Pull readest annotations from other devices"
msgstr "Obtener anotaciones de Readest de otros dispositivos"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Iniciar sesión en la cuenta Readest"
msgid "Log out as %1"
msgstr "Cerrar sesión de %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Sincronizar automáticamente progreso y anotaciones"
@@ -92,12 +224,6 @@ msgstr "Nunca sincronizado"
msgid "Book Fingerprint"
msgstr "Huella del libro"
msgid "Title"
msgstr "Título"
msgid "Author"
msgstr "Autor"
msgid "Identifiers"
msgstr "Identificadores"
@@ -107,6 +233,15 @@ msgstr "Última sincronización"
msgid "Sync Info"
msgstr "Información de sincronización"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Buscando actualización…"
@@ -167,6 +302,9 @@ msgstr "Sincronización completa: obteniendo todas las anotaciones…"
msgid "Pulling annotations..."
msgstr "Obteniendo anotaciones…"
msgid "Authentication failed, please login again"
msgstr "Error de autenticación, inicia sesión de nuevo"
msgid "Failed to pull annotations"
msgstr "Error al obtener las anotaciones"
@@ -176,9 +314,6 @@ msgstr "No se encontraron anotaciones nuevas"
msgid "%1 annotations pulled"
msgstr "%1 anotaciones obtenidas"
msgid "Cancel"
msgstr "Cancelar"
msgid "Login"
msgstr "Iniciar sesión"
@@ -218,9 +353,6 @@ msgstr "Error al enviar el progreso de lectura"
msgid "Pulling reading progress..."
msgstr "Obteniendo progreso de lectura…"
msgid "Authentication failed, please login again"
msgstr "Error de autenticación, inicia sesión de nuevo"
msgid "Failed to pull reading progress"
msgstr "Error al obtener el progreso de lectura"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "دستگاه‌های KOReader و Readest شما را همگام نگه می‌دارد."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "عنوان"
msgid "Author"
msgstr "نویسنده"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "لغو"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "تنظیم همگام‌سازی خودکار پیشرفت"
@@ -41,12 +158,27 @@ msgstr "ارسال یادداشت‌های Readest از این دستگاه"
msgid "Pull readest annotations from other devices"
msgstr "دریافت یادداشت‌های Readest از دستگاه‌های دیگر"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "ورود به حساب Readest"
msgid "Log out as %1"
msgstr "خروج از حساب %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "همگام‌سازی خودکار پیشرفت و یادداشت‌ها"
@@ -92,12 +224,6 @@ msgstr "هرگز همگام‌سازی نشده"
msgid "Book Fingerprint"
msgstr "اثر انگشت کتاب"
msgid "Title"
msgstr "عنوان"
msgid "Author"
msgstr "نویسنده"
msgid "Identifiers"
msgstr "شناسه‌ها"
@@ -107,6 +233,15 @@ msgstr "آخرین همگام‌سازی"
msgid "Sync Info"
msgstr "اطلاعات همگام‌سازی"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "در حال بررسی به‌روزرسانی…"
@@ -167,6 +302,9 @@ msgstr "همگام‌سازی کامل: در حال دریافت تمام یاد
msgid "Pulling annotations..."
msgstr "در حال دریافت یادداشت‌ها…"
msgid "Authentication failed, please login again"
msgstr "احراز هویت ناموفق بود، دوباره وارد شوید"
msgid "Failed to pull annotations"
msgstr "دریافت یادداشت‌ها ناموفق بود"
@@ -176,9 +314,6 @@ msgstr "یادداشت جدیدی یافت نشد"
msgid "%1 annotations pulled"
msgstr "%1 یادداشت دریافت شد"
msgid "Cancel"
msgstr "لغو"
msgid "Login"
msgstr "ورود"
@@ -218,9 +353,6 @@ msgstr "ارسال پیشرفت مطالعه ناموفق بود"
msgid "Pulling reading progress..."
msgstr "در حال دریافت پیشرفت مطالعه…"
msgid "Authentication failed, please login again"
msgstr "احراز هویت ناموفق بود، دوباره وارد شوید"
msgid "Failed to pull reading progress"
msgstr "دریافت پیشرفت مطالعه ناموفق بود"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Maintient vos appareils KOReader et Readest synchronisés."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Titre"
msgid "Author"
msgstr "Auteur"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Annuler"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Définir la synchronisation automatique de la progression"
@@ -41,12 +158,27 @@ msgstr "Envoyer les annotations Readest depuis cet appareil"
msgid "Pull readest annotations from other devices"
msgstr "Récupérer les annotations Readest des autres appareils"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Se connecter au compte Readest"
msgid "Log out as %1"
msgstr "Se déconnecter de %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Synchroniser automatiquement la progression et les annotations"
@@ -92,12 +224,6 @@ msgstr "Jamais synchronisé"
msgid "Book Fingerprint"
msgstr "Empreinte du livre"
msgid "Title"
msgstr "Titre"
msgid "Author"
msgstr "Auteur"
msgid "Identifiers"
msgstr "Identifiants"
@@ -107,6 +233,15 @@ msgstr "Dernière synchronisation"
msgid "Sync Info"
msgstr "Infos de synchronisation"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Recherche d'une mise à jour…"
@@ -167,6 +302,9 @@ msgstr "Synchronisation complète : récupération de toutes les annotations…"
msgid "Pulling annotations..."
msgstr "Récupération des annotations…"
msgid "Authentication failed, please login again"
msgstr "Échec de l'authentification, veuillez vous reconnecter"
msgid "Failed to pull annotations"
msgstr "Échec de la récupération des annotations"
@@ -176,9 +314,6 @@ msgstr "Aucune nouvelle annotation trouvée"
msgid "%1 annotations pulled"
msgstr "%1 annotations récupérées"
msgid "Cancel"
msgstr "Annuler"
msgid "Login"
msgstr "Connexion"
@@ -218,9 +353,6 @@ msgstr "Échec de l'envoi de la progression de lecture"
msgid "Pulling reading progress..."
msgstr "Récupération de la progression de lecture…"
msgid "Authentication failed, please login again"
msgstr "Échec de l'authentification, veuillez vous reconnecter"
msgid "Failed to pull reading progress"
msgstr "Échec de la récupération de la progression de lecture"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "שומר על סנכרון בין מכשירי KOReader ו-Readest שלך."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "כותרת"
msgid "Author"
msgstr "מחבר"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "ביטול"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "הגדר סנכרון התקדמות אוטומטי"
@@ -41,12 +158,27 @@ msgstr "שלח הערות Readest ממכשיר זה"
msgid "Pull readest annotations from other devices"
msgstr "משוך הערות Readest ממכשירים אחרים"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "התחבר לחשבון Readest"
msgid "Log out as %1"
msgstr "התנתק מ-%1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "סנכרן התקדמות והערות אוטומטית"
@@ -92,12 +224,6 @@ msgstr "מעולם לא סונכרן"
msgid "Book Fingerprint"
msgstr "טביעת אצבע של הספר"
msgid "Title"
msgstr "כותרת"
msgid "Author"
msgstr "מחבר"
msgid "Identifiers"
msgstr "מזהים"
@@ -107,6 +233,15 @@ msgstr "סנכרון אחרון"
msgid "Sync Info"
msgstr "מידע סנכרון"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "בודק עדכון…"
@@ -167,6 +302,9 @@ msgstr "סנכרון מלא: מושך את כל ההערות…"
msgid "Pulling annotations..."
msgstr "מושך הערות…"
msgid "Authentication failed, please login again"
msgstr "האימות נכשל, התחבר שוב"
msgid "Failed to pull annotations"
msgstr "משיכת ההערות נכשלה"
@@ -176,9 +314,6 @@ msgstr "לא נמצאו הערות חדשות"
msgid "%1 annotations pulled"
msgstr "נמשכו %1 הערות"
msgid "Cancel"
msgstr "ביטול"
msgid "Login"
msgstr "התחבר"
@@ -218,9 +353,6 @@ msgstr "שליחת התקדמות הקריאה נכשלה"
msgid "Pulling reading progress..."
msgstr "מושך התקדמות קריאה…"
msgid "Authentication failed, please login again"
msgstr "האימות נכשל, התחבר שוב"
msgid "Failed to pull reading progress"
msgstr "משיכת התקדמות הקריאה נכשלה"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "आपके KOReader और Readest डिवाइसों को सिंक रखता है।"
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "शीर्षक"
msgid "Author"
msgstr "लेखक"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "रद्द करें"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "स्वचालित प्रगति सिंक सेट करें"
@@ -41,12 +158,27 @@ msgstr "इस डिवाइस से Readest एनोटेशन भेज
msgid "Pull readest annotations from other devices"
msgstr "अन्य डिवाइसों से Readest एनोटेशन प्राप्त करें"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Readest खाते में लॉग इन करें"
msgid "Log out as %1"
msgstr "%1 से लॉग आउट करें"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "प्रगति और एनोटेशन स्वचालित रूप से सिंक करें"
@@ -92,12 +224,6 @@ msgstr "कभी सिंक नहीं हुआ"
msgid "Book Fingerprint"
msgstr "पुस्तक फिंगरप्रिंट"
msgid "Title"
msgstr "शीर्षक"
msgid "Author"
msgstr "लेखक"
msgid "Identifiers"
msgstr "पहचानकर्ता"
@@ -107,6 +233,15 @@ msgstr "अंतिम सिंक"
msgid "Sync Info"
msgstr "सिंक जानकारी"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "अपडेट जांच रहा है…"
@@ -167,6 +302,9 @@ msgstr "पूर्ण सिंक: सभी एनोटेशन प्र
msgid "Pulling annotations..."
msgstr "एनोटेशन प्राप्त कर रहा है…"
msgid "Authentication failed, please login again"
msgstr "प्रमाणीकरण विफल, कृपया पुनः लॉग इन करें"
msgid "Failed to pull annotations"
msgstr "एनोटेशन प्राप्त करने में विफल"
@@ -176,9 +314,6 @@ msgstr "कोई नया एनोटेशन नहीं मिला"
msgid "%1 annotations pulled"
msgstr "%1 एनोटेशन प्राप्त हुए"
msgid "Cancel"
msgstr "रद्द करें"
msgid "Login"
msgstr "लॉग इन"
@@ -218,9 +353,6 @@ msgstr "पठन प्रगति भेजने में विफल"
msgid "Pulling reading progress..."
msgstr "पठन प्रगति प्राप्त कर रहा है…"
msgid "Authentication failed, please login again"
msgstr "प्रमाणीकरण विफल, कृपया पुनः लॉग इन करें"
msgid "Failed to pull reading progress"
msgstr "पठन प्रगति प्राप्त करने में विफल"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Szinkronban tartja a KOReader és Readest eszközeidet."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Cím"
msgid "Author"
msgstr "Szerző"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Mégse"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Automatikus haladás-szinkronizálás beállítása"
@@ -41,12 +158,27 @@ msgstr "Readest jegyzetek küldése erről az eszközről"
msgid "Pull readest annotations from other devices"
msgstr "Readest jegyzetek lekérése más eszközökről"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Bejelentkezés Readest-fiókba"
msgid "Log out as %1"
msgstr "Kijelentkezés mint %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Haladás és jegyzetek automatikus szinkronizálása"
@@ -92,12 +224,6 @@ msgstr "Még nincs szinkronizálva"
msgid "Book Fingerprint"
msgstr "Könyv ujjlenyomata"
msgid "Title"
msgstr "Cím"
msgid "Author"
msgstr "Szerző"
msgid "Identifiers"
msgstr "Azonosítók"
@@ -107,6 +233,15 @@ msgstr "Utolsó szinkronizálás"
msgid "Sync Info"
msgstr "Szinkronizálási adatok"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Frissítés keresése…"
@@ -167,6 +302,9 @@ msgstr "Teljes szinkronizálás: az összes jegyzet lekérése…"
msgid "Pulling annotations..."
msgstr "Jegyzetek lekérése…"
msgid "Authentication failed, please login again"
msgstr "Hitelesítés sikertelen, jelentkezz be újra"
msgid "Failed to pull annotations"
msgstr "A jegyzetek lekérése sikertelen"
@@ -176,9 +314,6 @@ msgstr "Nem található új jegyzet"
msgid "%1 annotations pulled"
msgstr "%1 jegyzet lekérve"
msgid "Cancel"
msgstr "Mégse"
msgid "Login"
msgstr "Bejelentkezés"
@@ -218,9 +353,6 @@ msgstr "Az olvasási haladás küldése sikertelen"
msgid "Pulling reading progress..."
msgstr "Olvasási haladás lekérése…"
msgid "Authentication failed, please login again"
msgstr "Hitelesítés sikertelen, jelentkezz be újra"
msgid "Failed to pull reading progress"
msgstr "Az olvasási haladás lekérése sikertelen"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Menjaga perangkat KOReader dan Readest Anda tetap sinkron."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Judul"
msgid "Author"
msgstr "Penulis"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Batal"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Atur sinkronisasi progres otomatis"
@@ -41,12 +158,27 @@ msgstr "Kirim anotasi Readest dari perangkat ini"
msgid "Pull readest annotations from other devices"
msgstr "Ambil anotasi Readest dari perangkat lain"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Masuk ke akun Readest"
msgid "Log out as %1"
msgstr "Keluar sebagai %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Sinkronkan progres dan anotasi otomatis"
@@ -92,12 +224,6 @@ msgstr "Belum pernah disinkronkan"
msgid "Book Fingerprint"
msgstr "Sidik jari buku"
msgid "Title"
msgstr "Judul"
msgid "Author"
msgstr "Penulis"
msgid "Identifiers"
msgstr "Pengenal"
@@ -107,6 +233,15 @@ msgstr "Sinkronisasi terakhir"
msgid "Sync Info"
msgstr "Info sinkronisasi"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Memeriksa pembaruan…"
@@ -167,6 +302,9 @@ msgstr "Sinkronisasi penuh: mengambil semua anotasi…"
msgid "Pulling annotations..."
msgstr "Mengambil anotasi…"
msgid "Authentication failed, please login again"
msgstr "Autentikasi gagal, silakan masuk lagi"
msgid "Failed to pull annotations"
msgstr "Gagal mengambil anotasi"
@@ -176,9 +314,6 @@ msgstr "Tidak ditemukan anotasi baru"
msgid "%1 annotations pulled"
msgstr "%1 anotasi diambil"
msgid "Cancel"
msgstr "Batal"
msgid "Login"
msgstr "Masuk"
@@ -218,9 +353,6 @@ msgstr "Gagal mengirim progres baca"
msgid "Pulling reading progress..."
msgstr "Mengambil progres baca…"
msgid "Authentication failed, please login again"
msgstr "Autentikasi gagal, silakan masuk lagi"
msgid "Failed to pull reading progress"
msgstr "Gagal mengambil progres baca"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Mantiene sincronizzati i tuoi dispositivi KOReader e Readest."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Titolo"
msgid "Author"
msgstr "Autore"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Annulla"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Imposta sincronizzazione automatica del progresso"
@@ -41,12 +158,27 @@ msgstr "Invia annotazioni Readest da questo dispositivo"
msgid "Pull readest annotations from other devices"
msgstr "Ricevi annotazioni Readest dagli altri dispositivi"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Accedi all'account Readest"
msgid "Log out as %1"
msgstr "Esci come %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Sincronizza automaticamente progresso e annotazioni"
@@ -92,12 +224,6 @@ msgstr "Mai sincronizzato"
msgid "Book Fingerprint"
msgstr "Impronta del libro"
msgid "Title"
msgstr "Titolo"
msgid "Author"
msgstr "Autore"
msgid "Identifiers"
msgstr "Identificatori"
@@ -107,6 +233,15 @@ msgstr "Ultima sincronizzazione"
msgid "Sync Info"
msgstr "Info sincronizzazione"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Controllo aggiornamenti…"
@@ -167,6 +302,9 @@ msgstr "Sincronizzazione completa: recupero di tutte le annotazioni…"
msgid "Pulling annotations..."
msgstr "Recupero annotazioni in corso…"
msgid "Authentication failed, please login again"
msgstr "Autenticazione non riuscita, accedi di nuovo"
msgid "Failed to pull annotations"
msgstr "Recupero annotazioni non riuscito"
@@ -176,9 +314,6 @@ msgstr "Nessuna nuova annotazione trovata"
msgid "%1 annotations pulled"
msgstr "%1 annotazioni ricevute"
msgid "Cancel"
msgstr "Annulla"
msgid "Login"
msgstr "Accedi"
@@ -218,9 +353,6 @@ msgstr "Invio progresso di lettura non riuscito"
msgid "Pulling reading progress..."
msgstr "Recupero progresso di lettura…"
msgid "Authentication failed, please login again"
msgstr "Autenticazione non riuscita, accedi di nuovo"
msgid "Failed to pull reading progress"
msgstr "Recupero progresso di lettura non riuscito"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "KOReader と Readest のデバイス間で同期を保ちます。"
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "タイトル"
msgid "Author"
msgstr "著者"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "キャンセル"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "進捗の自動同期を設定"
@@ -41,12 +158,27 @@ msgstr "このデバイスから Readest の注釈を送信"
msgid "Pull readest annotations from other devices"
msgstr "他のデバイスから Readest の注釈を取得"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Readest アカウントにログイン"
msgid "Log out as %1"
msgstr "%1 をログアウト"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "進捗と注釈を自動同期"
@@ -92,12 +224,6 @@ msgstr "未同期"
msgid "Book Fingerprint"
msgstr "書籍の指紋"
msgid "Title"
msgstr "タイトル"
msgid "Author"
msgstr "著者"
msgid "Identifiers"
msgstr "識別子"
@@ -107,6 +233,15 @@ msgstr "前回同期"
msgid "Sync Info"
msgstr "同期情報"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "更新を確認しています…"
@@ -167,6 +302,9 @@ msgstr "フル同期: すべての注釈を取得しています…"
msgid "Pulling annotations..."
msgstr "注釈を取得しています…"
msgid "Authentication failed, please login again"
msgstr "認証に失敗しました。再度ログインしてください"
msgid "Failed to pull annotations"
msgstr "注釈の取得に失敗しました"
@@ -176,9 +314,6 @@ msgstr "新しい注釈は見つかりませんでした"
msgid "%1 annotations pulled"
msgstr "%1 件の注釈を取得しました"
msgid "Cancel"
msgstr "キャンセル"
msgid "Login"
msgstr "ログイン"
@@ -218,9 +353,6 @@ msgstr "読書の進捗の送信に失敗しました"
msgid "Pulling reading progress..."
msgstr "読書の進捗を取得しています…"
msgid "Authentication failed, please login again"
msgstr "認証に失敗しました。再度ログインしてください"
msgid "Failed to pull reading progress"
msgstr "読書の進捗の取得に失敗しました"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "KOReader와 Readest 기기를 동기화 상태로 유지합니다."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "제목"
msgid "Author"
msgstr "저자"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "취소"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "진행 상황 자동 동기화 설정"
@@ -41,12 +158,27 @@ msgstr "이 기기에서 Readest 주석 보내기"
msgid "Pull readest annotations from other devices"
msgstr "다른 기기에서 Readest 주석 가져오기"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Readest 계정에 로그인"
msgid "Log out as %1"
msgstr "%1 로그아웃"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "진행 상황과 주석을 자동으로 동기화"
@@ -92,12 +224,6 @@ msgstr "동기화한 적 없음"
msgid "Book Fingerprint"
msgstr "책 지문"
msgid "Title"
msgstr "제목"
msgid "Author"
msgstr "저자"
msgid "Identifiers"
msgstr "식별자"
@@ -107,6 +233,15 @@ msgstr "마지막 동기화"
msgid "Sync Info"
msgstr "동기화 정보"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "업데이트 확인 중…"
@@ -167,6 +302,9 @@ msgstr "전체 동기화: 모든 주석을 가져오는 중…"
msgid "Pulling annotations..."
msgstr "주석을 가져오는 중…"
msgid "Authentication failed, please login again"
msgstr "인증에 실패했습니다. 다시 로그인하세요"
msgid "Failed to pull annotations"
msgstr "주석 가져오기에 실패했습니다"
@@ -176,9 +314,6 @@ msgstr "새 주석을 찾을 수 없습니다"
msgid "%1 annotations pulled"
msgstr "%1개 주석을 가져왔습니다"
msgid "Cancel"
msgstr "취소"
msgid "Login"
msgstr "로그인"
@@ -218,9 +353,6 @@ msgstr "읽기 진행 상황 보내기에 실패했습니다"
msgid "Pulling reading progress..."
msgstr "읽기 진행 상황을 가져오는 중…"
msgid "Authentication failed, please login again"
msgstr "인증에 실패했습니다. 다시 로그인하세요"
msgid "Failed to pull reading progress"
msgstr "읽기 진행 상황 가져오기에 실패했습니다"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Memastikan peranti KOReader dan Readest anda kekal segerak."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Tajuk"
msgid "Author"
msgstr "Pengarang"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Batal"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Tetapkan penyegerakan kemajuan automatik"
@@ -41,12 +158,27 @@ msgstr "Hantar anotasi Readest dari peranti ini"
msgid "Pull readest annotations from other devices"
msgstr "Tarik anotasi Readest dari peranti lain"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Log masuk ke akaun Readest"
msgid "Log out as %1"
msgstr "Log keluar sebagai %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Segerakkan kemajuan dan anotasi secara automatik"
@@ -92,12 +224,6 @@ msgstr "Belum disegerakkan"
msgid "Book Fingerprint"
msgstr "Cap jari buku"
msgid "Title"
msgstr "Tajuk"
msgid "Author"
msgstr "Pengarang"
msgid "Identifiers"
msgstr "Pengenal"
@@ -107,6 +233,15 @@ msgstr "Penyegerakan terakhir"
msgid "Sync Info"
msgstr "Maklumat penyegerakan"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Menyemak kemas kini…"
@@ -167,6 +302,9 @@ msgstr "Penyegerakan penuh: menarik semua anotasi…"
msgid "Pulling annotations..."
msgstr "Menarik anotasi…"
msgid "Authentication failed, please login again"
msgstr "Pengesahan gagal, sila log masuk semula"
msgid "Failed to pull annotations"
msgstr "Gagal menarik anotasi"
@@ -176,9 +314,6 @@ msgstr "Tiada anotasi baharu ditemui"
msgid "%1 annotations pulled"
msgstr "%1 anotasi ditarik"
msgid "Cancel"
msgstr "Batal"
msgid "Login"
msgstr "Log masuk"
@@ -218,9 +353,6 @@ msgstr "Gagal menghantar kemajuan bacaan"
msgid "Pulling reading progress..."
msgstr "Menarik kemajuan bacaan…"
msgid "Authentication failed, please login again"
msgstr "Pengesahan gagal, sila log masuk semula"
msgid "Failed to pull reading progress"
msgstr "Gagal menarik kemajuan bacaan"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Houdt je KOReader- en Readest-apparaten gesynchroniseerd."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Titel"
msgid "Author"
msgstr "Auteur"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Annuleren"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Automatische voortgangssynchronisatie instellen"
@@ -41,12 +158,27 @@ msgstr "Readest-aantekeningen van dit apparaat verzenden"
msgid "Pull readest annotations from other devices"
msgstr "Readest-aantekeningen van andere apparaten ophalen"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Aanmelden bij Readest-account"
msgid "Log out as %1"
msgstr "Afmelden als %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Voortgang en aantekeningen automatisch synchroniseren"
@@ -92,12 +224,6 @@ msgstr "Nooit gesynchroniseerd"
msgid "Book Fingerprint"
msgstr "Vingerafdruk van boek"
msgid "Title"
msgstr "Titel"
msgid "Author"
msgstr "Auteur"
msgid "Identifiers"
msgstr "Identificatoren"
@@ -107,6 +233,15 @@ msgstr "Laatst gesynchroniseerd"
msgid "Sync Info"
msgstr "Synchronisatie-info"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Controleren op update…"
@@ -167,6 +302,9 @@ msgstr "Volledige synchronisatie: alle aantekeningen worden opgehaald…"
msgid "Pulling annotations..."
msgstr "Aantekeningen worden opgehaald…"
msgid "Authentication failed, please login again"
msgstr "Authenticatie mislukt, meld opnieuw aan"
msgid "Failed to pull annotations"
msgstr "Aantekeningen ophalen mislukt"
@@ -176,9 +314,6 @@ msgstr "Geen nieuwe aantekeningen gevonden"
msgid "%1 annotations pulled"
msgstr "%1 aantekeningen opgehaald"
msgid "Cancel"
msgstr "Annuleren"
msgid "Login"
msgstr "Aanmelden"
@@ -218,9 +353,6 @@ msgstr "Leesvoortgang verzenden mislukt"
msgid "Pulling reading progress..."
msgstr "Leesvoortgang wordt opgehaald…"
msgid "Authentication failed, please login again"
msgstr "Authenticatie mislukt, meld opnieuw aan"
msgid "Failed to pull reading progress"
msgstr "Leesvoortgang ophalen mislukt"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Synchronizuje urządzenia KOReader i Readest."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Tytuł"
msgid "Author"
msgstr "Autor"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Anuluj"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Ustaw automatyczną synchronizację postępu"
@@ -41,12 +158,27 @@ msgstr "Wyślij notatki Readest z tego urządzenia"
msgid "Pull readest annotations from other devices"
msgstr "Pobierz notatki Readest z innych urządzeń"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Zaloguj się do konta Readest"
msgid "Log out as %1"
msgstr "Wyloguj jako %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Automatycznie synchronizuj postęp i notatki"
@@ -92,12 +224,6 @@ msgstr "Nigdy nie synchronizowano"
msgid "Book Fingerprint"
msgstr "Odcisk książki"
msgid "Title"
msgstr "Tytuł"
msgid "Author"
msgstr "Autor"
msgid "Identifiers"
msgstr "Identyfikatory"
@@ -107,6 +233,15 @@ msgstr "Ostatnia synchronizacja"
msgid "Sync Info"
msgstr "Informacje o synchronizacji"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Sprawdzanie aktualizacji…"
@@ -167,6 +302,9 @@ msgstr "Pełna synchronizacja: pobieranie wszystkich notatek…"
msgid "Pulling annotations..."
msgstr "Pobieranie notatek…"
msgid "Authentication failed, please login again"
msgstr "Błąd uwierzytelniania, zaloguj się ponownie"
msgid "Failed to pull annotations"
msgstr "Nie udało się pobrać notatek"
@@ -176,9 +314,6 @@ msgstr "Nie znaleziono nowych notatek"
msgid "%1 annotations pulled"
msgstr "Pobrano %1 notatek"
msgid "Cancel"
msgstr "Anuluj"
msgid "Login"
msgstr "Zaloguj"
@@ -218,9 +353,6 @@ msgstr "Nie udało się wysłać postępu czytania"
msgid "Pulling reading progress..."
msgstr "Pobieranie postępu czytania…"
msgid "Authentication failed, please login again"
msgstr "Błąd uwierzytelniania, zaloguj się ponownie"
msgid "Failed to pull reading progress"
msgstr "Nie udało się pobrać postępu czytania"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Mantém os seus dispositivos KOReader e Readest sincronizados."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Título"
msgid "Author"
msgstr "Autor"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Cancelar"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Definir sincronização automática do progresso"
@@ -41,12 +158,27 @@ msgstr "Enviar anotações Readest deste dispositivo"
msgid "Pull readest annotations from other devices"
msgstr "Obter anotações Readest de outros dispositivos"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Entrar na conta Readest"
msgid "Log out as %1"
msgstr "Sair como %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Sincronizar progresso e anotações automaticamente"
@@ -92,12 +224,6 @@ msgstr "Nunca sincronizado"
msgid "Book Fingerprint"
msgstr "Impressão digital do livro"
msgid "Title"
msgstr "Título"
msgid "Author"
msgstr "Autor"
msgid "Identifiers"
msgstr "Identificadores"
@@ -107,6 +233,15 @@ msgstr "Última sincronização"
msgid "Sync Info"
msgstr "Informações de sincronização"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "A procurar atualização…"
@@ -167,6 +302,9 @@ msgstr "Sincronização completa: a obter todas as anotações…"
msgid "Pulling annotations..."
msgstr "A obter anotações…"
msgid "Authentication failed, please login again"
msgstr "Falha de autenticação, inicie sessão novamente"
msgid "Failed to pull annotations"
msgstr "Falha ao obter as anotações"
@@ -176,9 +314,6 @@ msgstr "Nenhuma nova anotação encontrada"
msgid "%1 annotations pulled"
msgstr "%1 anotações obtidas"
msgid "Cancel"
msgstr "Cancelar"
msgid "Login"
msgstr "Entrar"
@@ -218,9 +353,6 @@ msgstr "Falha ao enviar o progresso de leitura"
msgid "Pulling reading progress..."
msgstr "A obter progresso de leitura…"
msgid "Authentication failed, please login again"
msgstr "Falha de autenticação, inicie sessão novamente"
msgid "Failed to pull reading progress"
msgstr "Falha ao obter o progresso de leitura"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Menține dispozitivele tale KOReader și Readest sincronizate."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Titlu"
msgid "Author"
msgstr "Autor"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Anulează"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Setează sincronizarea automată a progresului"
@@ -41,12 +158,27 @@ msgstr "Trimite adnotările Readest de pe acest dispozitiv"
msgid "Pull readest annotations from other devices"
msgstr "Obține adnotările Readest de pe alte dispozitive"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Autentificare în contul Readest"
msgid "Log out as %1"
msgstr "Deconectare de la %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Sincronizează automat progresul și adnotările"
@@ -92,12 +224,6 @@ msgstr "Nicio sincronizare"
msgid "Book Fingerprint"
msgstr "Amprenta cărții"
msgid "Title"
msgstr "Titlu"
msgid "Author"
msgstr "Autor"
msgid "Identifiers"
msgstr "Identificatori"
@@ -107,6 +233,15 @@ msgstr "Ultima sincronizare"
msgid "Sync Info"
msgstr "Informații de sincronizare"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Se verifică actualizările…"
@@ -167,6 +302,9 @@ msgstr "Sincronizare completă: se obțin toate adnotările…"
msgid "Pulling annotations..."
msgstr "Se obțin adnotările…"
msgid "Authentication failed, please login again"
msgstr "Autentificare eșuată, conectează-te din nou"
msgid "Failed to pull annotations"
msgstr "Obținerea adnotărilor a eșuat"
@@ -176,9 +314,6 @@ msgstr "Nu s-au găsit adnotări noi"
msgid "%1 annotations pulled"
msgstr "%1 adnotări obținute"
msgid "Cancel"
msgstr "Anulează"
msgid "Login"
msgstr "Autentificare"
@@ -218,9 +353,6 @@ msgstr "Trimiterea progresului de citire a eșuat"
msgid "Pulling reading progress..."
msgstr "Se obține progresul de citire…"
msgid "Authentication failed, please login again"
msgstr "Autentificare eșuată, conectează-te din nou"
msgid "Failed to pull reading progress"
msgstr "Obținerea progresului de citire a eșuat"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Поддерживает синхронизацию устройств KOReader и Readest."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Название"
msgid "Author"
msgstr "Автор"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Отмена"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Настроить автосинхронизацию прогресса"
@@ -41,12 +158,27 @@ msgstr "Отправить заметки Readest с этого устройст
msgid "Pull readest annotations from other devices"
msgstr "Получить заметки Readest с других устройств"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Войти в аккаунт Readest"
msgid "Log out as %1"
msgstr "Выйти из %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Автоматически синхронизировать прогресс и заметки"
@@ -92,12 +224,6 @@ msgstr "Ещё не синхронизировано"
msgid "Book Fingerprint"
msgstr "Отпечаток книги"
msgid "Title"
msgstr "Название"
msgid "Author"
msgstr "Автор"
msgid "Identifiers"
msgstr "Идентификаторы"
@@ -107,6 +233,15 @@ msgstr "Последняя синхронизация"
msgid "Sync Info"
msgstr "Сведения о синхронизации"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Проверка обновлений…"
@@ -167,6 +302,9 @@ msgstr "Полная синхронизация: получение всех з
msgid "Pulling annotations..."
msgstr "Получение заметок…"
msgid "Authentication failed, please login again"
msgstr "Ошибка аутентификации, войдите снова"
msgid "Failed to pull annotations"
msgstr "Не удалось получить заметки"
@@ -176,9 +314,6 @@ msgstr "Новых заметок не найдено"
msgid "%1 annotations pulled"
msgstr "Получено заметок: %1"
msgid "Cancel"
msgstr "Отмена"
msgid "Login"
msgstr "Войти"
@@ -218,9 +353,6 @@ msgstr "Не удалось отправить прогресс чтения"
msgid "Pulling reading progress..."
msgstr "Получение прогресса чтения…"
msgid "Authentication failed, please login again"
msgstr "Ошибка аутентификации, войдите снова"
msgid "Failed to pull reading progress"
msgstr "Не удалось получить прогресс чтения"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "ඔබේ KOReader සහ Readest උපාංග සමමුහූර්තව තබා ගනී."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "නම"
msgid "Author"
msgstr "කතෘ"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "අවලංගු කරන්න"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "ස්වයංක්‍රීය ප්‍රගති සමමුහූර්තකරණය සකසන්න"
@@ -41,12 +158,27 @@ msgstr "මෙම උපාංගයෙන් Readest සටහන් යවන
msgid "Pull readest annotations from other devices"
msgstr "වෙනත් උපාංගවලින් Readest සටහන් ලබාගන්න"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Readest ගිණුමට පිවිසෙන්න"
msgid "Log out as %1"
msgstr "%1 ලෙස ඉවත් වන්න"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "ප්‍රගතිය සහ සටහන් ස්වයංක්‍රීයව සමමුහූර්ත කරන්න"
@@ -92,12 +224,6 @@ msgstr "කිසිදා සමමුහූර්ත වී නැත"
msgid "Book Fingerprint"
msgstr "පොතේ ඇඟිලි සලකුණ"
msgid "Title"
msgstr "නම"
msgid "Author"
msgstr "කතෘ"
msgid "Identifiers"
msgstr "හඳුනාගැනීම්"
@@ -107,6 +233,15 @@ msgstr "අවසන් සමමුහූර්තකරණය"
msgid "Sync Info"
msgstr "සමමුහූර්ත තොරතුරු"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "යාවත්කාලීන පරීක්ෂා කරමින්…"
@@ -167,6 +302,9 @@ msgstr "සම්පූර්ණ සමමුහූර්තකරණය: සි
msgid "Pulling annotations..."
msgstr "සටහන් ලබාගනිමින්…"
msgid "Authentication failed, please login again"
msgstr "සත්‍යාපනය අසාර්ථකයි, කරුණාකර නැවත පිවිසෙන්න"
msgid "Failed to pull annotations"
msgstr "සටහන් ලබාගැනීම අසාර්ථකයි"
@@ -176,9 +314,6 @@ msgstr "නව සටහන් හමු නොවුණි"
msgid "%1 annotations pulled"
msgstr "%1 සටහන් ලබා ගන්නා ලදී"
msgid "Cancel"
msgstr "අවලංගු කරන්න"
msgid "Login"
msgstr "පිවිසෙන්න"
@@ -218,9 +353,6 @@ msgstr "කියවීමේ ප්‍රගතිය යැවීම අසා
msgid "Pulling reading progress..."
msgstr "කියවීමේ ප්‍රගතිය ලබාගනිමින්…"
msgid "Authentication failed, please login again"
msgstr "සත්‍යාපනය අසාර්ථකයි, කරුණාකර නැවත පිවිසෙන්න"
msgid "Failed to pull reading progress"
msgstr "කියවීමේ ප්‍රගතිය ලබාගැනීම අසාර්ථකයි"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Vaše naprave KOReader in Readest ostajajo sinhronizirane."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Naslov"
msgid "Author"
msgstr "Avtor"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Prekliči"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Nastavi samodejno sinhronizacijo napredka"
@@ -41,12 +158,27 @@ msgstr "Pošlji opombe Readest iz te naprave"
msgid "Pull readest annotations from other devices"
msgstr "Pridobi opombe Readest iz drugih naprav"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Prijava v račun Readest"
msgid "Log out as %1"
msgstr "Odjava kot %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Samodejno sinhroniziraj napredek in opombe"
@@ -92,12 +224,6 @@ msgstr "Nikoli sinhronizirano"
msgid "Book Fingerprint"
msgstr "Prstni odtis knjige"
msgid "Title"
msgstr "Naslov"
msgid "Author"
msgstr "Avtor"
msgid "Identifiers"
msgstr "Identifikatorji"
@@ -107,6 +233,15 @@ msgstr "Zadnja sinhronizacija"
msgid "Sync Info"
msgstr "Podatki o sinhronizaciji"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Preverjanje posodobitev…"
@@ -167,6 +302,9 @@ msgstr "Polna sinhronizacija: pridobivanje vseh opomb…"
msgid "Pulling annotations..."
msgstr "Pridobivanje opomb…"
msgid "Authentication failed, please login again"
msgstr "Preverjanje pristnosti ni uspelo, prijavite se znova"
msgid "Failed to pull annotations"
msgstr "Pridobivanje opomb ni uspelo"
@@ -176,9 +314,6 @@ msgstr "Novih opomb ni"
msgid "%1 annotations pulled"
msgstr "Pridobljenih %1 opomb"
msgid "Cancel"
msgstr "Prekliči"
msgid "Login"
msgstr "Prijava"
@@ -218,9 +353,6 @@ msgstr "Pošiljanje napredka branja ni uspelo"
msgid "Pulling reading progress..."
msgstr "Pridobivanje napredka branja…"
msgid "Authentication failed, please login again"
msgstr "Preverjanje pristnosti ni uspelo, prijavite se znova"
msgid "Failed to pull reading progress"
msgstr "Pridobivanje napredka branja ni uspelo"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Håller dina KOReader- och Readest-enheter synkroniserade."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Titel"
msgid "Author"
msgstr "Författare"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Avbryt"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Ställ in automatisk synkronisering av läsförloppet"
@@ -41,12 +158,27 @@ msgstr "Skicka Readest-anteckningar från den här enheten"
msgid "Pull readest annotations from other devices"
msgstr "Hämta Readest-anteckningar från andra enheter"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Logga in på Readest-konto"
msgid "Log out as %1"
msgstr "Logga ut från %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Synkronisera läsförlopp och anteckningar automatiskt"
@@ -92,12 +224,6 @@ msgstr "Aldrig synkroniserad"
msgid "Book Fingerprint"
msgstr "Bokens fingeravtryck"
msgid "Title"
msgstr "Titel"
msgid "Author"
msgstr "Författare"
msgid "Identifiers"
msgstr "Identifierare"
@@ -107,6 +233,15 @@ msgstr "Senast synkroniserad"
msgid "Sync Info"
msgstr "Synkroniseringsinfo"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Söker efter uppdatering…"
@@ -167,6 +302,9 @@ msgstr "Fullständig synkronisering: hämtar alla anteckningar…"
msgid "Pulling annotations..."
msgstr "Hämtar anteckningar…"
msgid "Authentication failed, please login again"
msgstr "Autentisering misslyckades, logga in igen"
msgid "Failed to pull annotations"
msgstr "Det gick inte att hämta anteckningarna"
@@ -176,9 +314,6 @@ msgstr "Inga nya anteckningar hittades"
msgid "%1 annotations pulled"
msgstr "%1 anteckningar hämtade"
msgid "Cancel"
msgstr "Avbryt"
msgid "Login"
msgstr "Logga in"
@@ -218,9 +353,6 @@ msgstr "Det gick inte att skicka läsförloppet"
msgid "Pulling reading progress..."
msgstr "Hämtar läsförloppet…"
msgid "Authentication failed, please login again"
msgstr "Autentisering misslyckades, logga in igen"
msgid "Failed to pull reading progress"
msgstr "Det gick inte att hämta läsförloppet"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "உங்கள் KOReader மற்றும் Readest சாதனங்களை ஒத்திசைவில் வைத்திருக்கிறது."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "தலைப்பு"
msgid "Author"
msgstr "ஆசிரியர்"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "ரத்து செய்யவும்"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "தானியங்கி முன்னேற்ற ஒத்திசைவை அமைக்கவும்"
@@ -41,12 +158,27 @@ msgstr "இந்தச் சாதனத்திலிருந்து Read
msgid "Pull readest annotations from other devices"
msgstr "மற்ற சாதனங்களிலிருந்து Readest குறிப்புகளைப் பெறு"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Readest கணக்கில் உள்நுழைக"
msgid "Log out as %1"
msgstr "%1 ஆக வெளியேறு"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "முன்னேற்றம் மற்றும் குறிப்புகளை தானாக ஒத்திசை"
@@ -92,12 +224,6 @@ msgstr "இதுவரை ஒத்திசைக்கப்படவில
msgid "Book Fingerprint"
msgstr "புத்தக கைரேகை"
msgid "Title"
msgstr "தலைப்பு"
msgid "Author"
msgstr "ஆசிரியர்"
msgid "Identifiers"
msgstr "அடையாளங்காட்டிகள்"
@@ -107,6 +233,15 @@ msgstr "கடைசி ஒத்திசைவு"
msgid "Sync Info"
msgstr "ஒத்திசைவு தகவல்"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "புதுப்பிப்பைச் சரிபார்க்கிறது…"
@@ -167,6 +302,9 @@ msgstr "முழு ஒத்திசைவு: அனைத்து கு
msgid "Pulling annotations..."
msgstr "குறிப்புகள் பெறப்படுகின்றன…"
msgid "Authentication failed, please login again"
msgstr "அங்கீகாரம் தோல்வி, மீண்டும் உள்நுழைக"
msgid "Failed to pull annotations"
msgstr "குறிப்புகளைப் பெறுவதில் தோல்வி"
@@ -176,9 +314,6 @@ msgstr "புதிய குறிப்புகள் காணப்பட
msgid "%1 annotations pulled"
msgstr "%1 குறிப்புகள் பெறப்பட்டன"
msgid "Cancel"
msgstr "ரத்து செய்யவும்"
msgid "Login"
msgstr "உள்நுழை"
@@ -218,9 +353,6 @@ msgstr "வாசிப்பு முன்னேற்றத்தை அன
msgid "Pulling reading progress..."
msgstr "வாசிப்பு முன்னேற்றம் பெறப்படுகிறது…"
msgid "Authentication failed, please login again"
msgstr "அங்கீகாரம் தோல்வி, மீண்டும் உள்நுழைக"
msgid "Failed to pull reading progress"
msgstr "வாசிப்பு முன்னேற்றத்தைப் பெறுவதில் தோல்வி"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "รักษาการซิงค์ระหว่างอุปกรณ์ KOReader และ Readest ของคุณ"
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "ชื่อเรื่อง"
msgid "Author"
msgstr "ผู้แต่ง"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "ยกเลิก"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "ตั้งค่าการซิงค์ความคืบหน้าอัตโนมัติ"
@@ -41,12 +158,27 @@ msgstr "ส่งคำอธิบาย Readest จากอุปกรณ์
msgid "Pull readest annotations from other devices"
msgstr "ดึงคำอธิบาย Readest จากอุปกรณ์อื่น"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "เข้าสู่ระบบบัญชี Readest"
msgid "Log out as %1"
msgstr "ออกจากระบบในชื่อ %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "ซิงค์ความคืบหน้าและคำอธิบายอัตโนมัติ"
@@ -92,12 +224,6 @@ msgstr "ไม่เคยซิงค์"
msgid "Book Fingerprint"
msgstr "ลายนิ้วมือหนังสือ"
msgid "Title"
msgstr "ชื่อเรื่อง"
msgid "Author"
msgstr "ผู้แต่ง"
msgid "Identifiers"
msgstr "ตัวระบุ"
@@ -107,6 +233,15 @@ msgstr "ซิงค์ล่าสุด"
msgid "Sync Info"
msgstr "ข้อมูลการซิงค์"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "กำลังตรวจสอบการอัปเดต…"
@@ -167,6 +302,9 @@ msgstr "ซิงค์เต็มรูปแบบ: กำลังดึง
msgid "Pulling annotations..."
msgstr "กำลังดึงคำอธิบาย…"
msgid "Authentication failed, please login again"
msgstr "การยืนยันตัวตนล้มเหลว โปรดเข้าสู่ระบบอีกครั้ง"
msgid "Failed to pull annotations"
msgstr "ดึงคำอธิบายไม่สำเร็จ"
@@ -176,9 +314,6 @@ msgstr "ไม่พบคำอธิบายใหม่"
msgid "%1 annotations pulled"
msgstr "ดึงคำอธิบาย %1 รายการ"
msgid "Cancel"
msgstr "ยกเลิก"
msgid "Login"
msgstr "เข้าสู่ระบบ"
@@ -218,9 +353,6 @@ msgstr "ส่งความคืบหน้าการอ่านไม่
msgid "Pulling reading progress..."
msgstr "กำลังดึงความคืบหน้าการอ่าน…"
msgid "Authentication failed, please login again"
msgstr "การยืนยันตัวตนล้มเหลว โปรดเข้าสู่ระบบอีกครั้ง"
msgid "Failed to pull reading progress"
msgstr "ดึงความคืบหน้าการอ่านไม่สำเร็จ"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "KOReader ve Readest cihazlarınızı senkronize tutar."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Başlık"
msgid "Author"
msgstr "Yazar"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "İptal"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Otomatik ilerleme senkronizasyonunu ayarla"
@@ -41,12 +158,27 @@ msgstr "Bu cihazdan Readest açıklamalarını gönder"
msgid "Pull readest annotations from other devices"
msgstr "Diğer cihazlardan Readest açıklamalarını al"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Readest hesabına giriş yap"
msgid "Log out as %1"
msgstr "%1 olarak çıkış yap"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "İlerlemeyi ve açıklamaları otomatik senkronize et"
@@ -92,12 +224,6 @@ msgstr "Hiç senkronize edilmedi"
msgid "Book Fingerprint"
msgstr "Kitap parmak izi"
msgid "Title"
msgstr "Başlık"
msgid "Author"
msgstr "Yazar"
msgid "Identifiers"
msgstr "Tanımlayıcılar"
@@ -107,6 +233,15 @@ msgstr "Son senkronizasyon"
msgid "Sync Info"
msgstr "Senkronizasyon bilgisi"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Güncelleme kontrol ediliyor…"
@@ -167,6 +302,9 @@ msgstr "Tam senkronizasyon: tüm açıklamalar alınıyor…"
msgid "Pulling annotations..."
msgstr "Açıklamalar alınıyor…"
msgid "Authentication failed, please login again"
msgstr "Kimlik doğrulama başarısız, tekrar giriş yapın"
msgid "Failed to pull annotations"
msgstr "Açıklamalar alınamadı"
@@ -176,9 +314,6 @@ msgstr "Yeni açıklama bulunamadı"
msgid "%1 annotations pulled"
msgstr "%1 açıklama alındı"
msgid "Cancel"
msgstr "İptal"
msgid "Login"
msgstr "Giriş"
@@ -218,9 +353,6 @@ msgstr "Okuma ilerlemesi gönderilemedi"
msgid "Pulling reading progress..."
msgstr "Okuma ilerlemesi alınıyor…"
msgid "Authentication failed, please login again"
msgstr "Kimlik doğrulama başarısız, tekrar giriş yapın"
msgid "Failed to pull reading progress"
msgstr "Okuma ilerlemesi alınamadı"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Підтримує синхронізацію пристроїв KOReader і Readest."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Назва"
msgid "Author"
msgstr "Автор"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Скасувати"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Налаштувати автосинхронізацію прогресу"
@@ -41,12 +158,27 @@ msgstr "Надіслати нотатки Readest з цього пристрою
msgid "Pull readest annotations from other devices"
msgstr "Отримати нотатки Readest з інших пристроїв"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Увійти до облікового запису Readest"
msgid "Log out as %1"
msgstr "Вийти з %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Автоматично синхронізувати прогрес і нотатки"
@@ -92,12 +224,6 @@ msgstr "Ще не синхронізовано"
msgid "Book Fingerprint"
msgstr "Відбиток книги"
msgid "Title"
msgstr "Назва"
msgid "Author"
msgstr "Автор"
msgid "Identifiers"
msgstr "Ідентифікатори"
@@ -107,6 +233,15 @@ msgstr "Остання синхронізація"
msgid "Sync Info"
msgstr "Відомості про синхронізацію"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Перевірка оновлень…"
@@ -167,6 +302,9 @@ msgstr "Повна синхронізація: отримання всіх но
msgid "Pulling annotations..."
msgstr "Отримання нотаток…"
msgid "Authentication failed, please login again"
msgstr "Помилка автентифікації, увійдіть знову"
msgid "Failed to pull annotations"
msgstr "Не вдалося отримати нотатки"
@@ -176,9 +314,6 @@ msgstr "Нових нотаток не знайдено"
msgid "%1 annotations pulled"
msgstr "Отримано нотаток: %1"
msgid "Cancel"
msgstr "Скасувати"
msgid "Login"
msgstr "Увійти"
@@ -218,9 +353,6 @@ msgstr "Не вдалося надіслати прогрес читання"
msgid "Pulling reading progress..."
msgstr "Отримання прогресу читання…"
msgid "Authentication failed, please login again"
msgstr "Помилка автентифікації, увійдіть знову"
msgid "Failed to pull reading progress"
msgstr "Не вдалося отримати прогрес читання"
+144 -12
View File
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "Giữ các thiết bị KOReader và Readest của bạn đồng bộ."
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "Tiêu đề"
msgid "Author"
msgstr "Tác giả"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "Hủy"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "Đặt đồng bộ tiến trình tự động"
@@ -41,12 +158,27 @@ msgstr "Đẩy ghi chú Readest từ thiết bị này"
msgid "Pull readest annotations from other devices"
msgstr "Lấy ghi chú Readest từ thiết bị khác"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "Đăng nhập tài khoản Readest"
msgid "Log out as %1"
msgstr "Đăng xuất khỏi %1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "Tự động đồng bộ tiến trình và ghi chú"
@@ -92,12 +224,6 @@ msgstr "Chưa đồng bộ"
msgid "Book Fingerprint"
msgstr "Dấu vân tay sách"
msgid "Title"
msgstr "Tiêu đề"
msgid "Author"
msgstr "Tác giả"
msgid "Identifiers"
msgstr "Định Danh"
@@ -107,6 +233,15 @@ msgstr "Lần đồng bộ cuối"
msgid "Sync Info"
msgstr "Thông tin đồng bộ"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "Đang kiểm tra cập nhật…"
@@ -167,6 +302,9 @@ msgstr "Đồng bộ đầy đủ: đang lấy tất cả ghi chú…"
msgid "Pulling annotations..."
msgstr "Đang lấy ghi chú…"
msgid "Authentication failed, please login again"
msgstr "Xác thực thất bại, vui lòng đăng nhập lại"
msgid "Failed to pull annotations"
msgstr "Không thể lấy ghi chú"
@@ -176,9 +314,6 @@ msgstr "Không tìm thấy ghi chú mới"
msgid "%1 annotations pulled"
msgstr "Đã lấy %1 ghi chú"
msgid "Cancel"
msgstr "Hủy"
msgid "Login"
msgstr "Đăng nhập"
@@ -218,9 +353,6 @@ msgstr "Không thể đẩy tiến trình đọc"
msgid "Pulling reading progress..."
msgstr "Đang lấy tiến trình đọc…"
msgid "Authentication failed, please login again"
msgstr "Xác thực thất bại, vui lòng đăng nhập lại"
msgid "Failed to pull reading progress"
msgstr "Không thể lấy tiến trình đọc"
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "让你的 KOReader 与 Readest 设备保持同步。"
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "标题"
msgid "Author"
msgstr "作者"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "取消"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "设置自动同步阅读进度"
@@ -41,12 +158,27 @@ msgstr "从此设备推送 Readest 标注"
msgid "Pull readest annotations from other devices"
msgstr "从其他设备拉取 Readest 标注"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "登录 Readest 账户"
msgid "Log out as %1"
msgstr "退出登录(%1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "自动同步阅读进度和标注"
@@ -92,12 +224,6 @@ msgstr "尚未同步"
msgid "Book Fingerprint"
msgstr "书籍指纹"
msgid "Title"
msgstr "标题"
msgid "Author"
msgstr "作者"
msgid "Identifiers"
msgstr "标识符"
@@ -107,6 +233,15 @@ msgstr "上次同步"
msgid "Sync Info"
msgstr "同步信息"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "正在检查更新…"
@@ -167,6 +302,9 @@ msgstr "完整同步:正在拉取所有标注..."
msgid "Pulling annotations..."
msgstr "正在拉取标注..."
msgid "Authentication failed, please login again"
msgstr "认证失败,请重新登录"
msgid "Failed to pull annotations"
msgstr "拉取标注失败"
@@ -176,9 +314,6 @@ msgstr "未发现新标注"
msgid "%1 annotations pulled"
msgstr "已拉取 %1 条标注"
msgid "Cancel"
msgstr "取消"
msgid "Login"
msgstr "登录"
@@ -218,9 +353,6 @@ msgstr "推送阅读进度失败"
msgid "Pulling reading progress..."
msgstr "正在拉取阅读进度..."
msgid "Authentication failed, please login again"
msgstr "认证失败,请重新登录"
msgid "Failed to pull reading progress"
msgstr "拉取阅读进度失败"
@@ -17,6 +17,123 @@ msgstr "Readest"
msgid "Keeps your KOReader and Readest devices in sync."
msgstr "讓你的 KOReader 與 Readest 裝置保持同步。"
msgid "Library view"
msgstr ""
msgid "View"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Cover"
msgstr ""
msgid "Crop"
msgstr ""
msgid "Fit"
msgstr ""
msgid "Group by"
msgstr ""
msgid "None"
msgstr ""
msgid "Authors"
msgstr ""
msgid "Series"
msgstr ""
msgid "Groups"
msgstr ""
msgid "Sort by"
msgstr ""
msgid "Last Updated"
msgstr ""
msgid "Date Read"
msgstr ""
msgid "Date Added"
msgstr ""
msgid "Title"
msgstr "書名"
msgid "Author"
msgstr "作者"
msgid "Descending"
msgstr ""
msgid "Ascending"
msgstr ""
msgid "Actions"
msgstr ""
msgid "Rescan library"
msgstr ""
msgid "Download folder…"
msgstr ""
msgid "Pick a folder for downloaded books"
msgstr ""
msgid "Sign in to Readest to open the Library"
msgstr ""
msgid "Library"
msgstr ""
msgid "Cancel"
msgstr "取消"
msgid "Clear"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search library"
msgstr ""
msgid "Search title or author"
msgstr ""
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
msgstr ""
msgid "File moved or deleted. Rescan library?"
msgstr ""
msgid "Download this book from Readest?"
msgstr ""
msgid "Download"
msgstr ""
msgid "Set Home folder in File Manager first to enable downloads."
msgstr ""
msgid "Downloading…"
msgstr ""
msgid "Cloud copy unavailable."
msgstr ""
msgid "Download failed."
msgstr ""
msgid "Set auto progress sync"
msgstr "設定自動同步閱讀進度"
@@ -41,12 +158,27 @@ msgstr "從此裝置推送 Readest 註釋"
msgid "Pull readest annotations from other devices"
msgstr "從其他裝置拉取 Readest 註釋"
msgid "Open Readest Library"
msgstr ""
msgid "Push Readest book library"
msgstr ""
msgid "Pull Readest book library"
msgstr ""
msgid "Log in Readest Account"
msgstr "登入 Readest 帳戶"
msgid "Log out as %1"
msgstr "登出(%1"
msgid "Push books now"
msgstr ""
msgid "Pull books now"
msgstr ""
msgid "Auto sync progress and annotations"
msgstr "自動同步閱讀進度和註釋"
@@ -92,12 +224,6 @@ msgstr "尚未同步"
msgid "Book Fingerprint"
msgstr "書籍指紋"
msgid "Title"
msgstr "書名"
msgid "Author"
msgstr "作者"
msgid "Identifiers"
msgstr "識別碼"
@@ -107,6 +233,15 @@ msgstr "上次同步"
msgid "Sync Info"
msgstr "同步資訊"
msgid "Library not initialized"
msgstr ""
msgid "Books synced"
msgstr ""
msgid "Books sync failed"
msgstr ""
msgid "Checking for update…"
msgstr "正在檢查更新…"
@@ -167,6 +302,9 @@ msgstr "完整同步:正在拉取所有註釋…"
msgid "Pulling annotations..."
msgstr "正在拉取註釋…"
msgid "Authentication failed, please login again"
msgstr "驗證失敗,請重新登入"
msgid "Failed to pull annotations"
msgstr "拉取註釋失敗"
@@ -176,9 +314,6 @@ msgstr "未發現新註釋"
msgid "%1 annotations pulled"
msgstr "已拉取 %1 條註釋"
msgid "Cancel"
msgstr "取消"
msgid "Login"
msgstr "登入"
@@ -218,9 +353,6 @@ msgstr "推送閱讀進度失敗"
msgid "Pulling reading progress..."
msgstr "正在拉取閱讀進度…"
msgid "Authentication failed, please login again"
msgstr "驗證失敗,請重新登入"
msgid "Failed to pull reading progress"
msgstr "拉取閱讀進度失敗"
+375 -3
View File
@@ -4,6 +4,7 @@ local KeyValuePage = require("ui/widget/keyvaluepage")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local NetworkMgr = require("ui/network/manager")
local UIManager = require("ui/uimanager")
local logger = require("logger")
local sha2 = require("ffi/sha2")
local T = require("ffi/util").template
local _ = require("i18n")
@@ -46,9 +47,35 @@ function ReadestSync:init()
self.installed_version = meta and meta.version and tostring(meta.version)
self.ui.menu:registerToMainMenu(self)
-- Dispatcher actions (gestures / quick menu entries). Registering
-- here in init() rather than onReaderReady so the FileManager-only
-- actions (Readest Library, Push books, Pull books — flagged with
-- general=true) show up in FileManager → Settings → Taps and gestures
-- when no document is open. Reader-only actions (auto sync etc.)
-- still get registered on first plugin load too — they just don't
-- fire until a document opens.
self:onDispatcherRegisterActions()
-- Long-press file menu in FileManager: add an "Add to Readest"
-- entry that hashes the file, registers it in the LibraryStore, and
-- uploads it to the user's Readest cloud. Skipped in reader context
-- (FileManager.instance is nil there).
self:registerFileDialogButton()
end
-- Register Library actions (Open / Push / Pull) — available in both
-- reader and FileManager via the `general=true` flag.
function ReadestSync:onDispatcherRegisterActions()
Dispatcher:registerAction("readest_open_library", { category="none", event="ReadestOpenLibrary", title=_("Open Readest Library"), general=true,})
Dispatcher:registerAction("readest_push_books", { category="none", event="ReadestPushBooks", title=_("Push Readest book library"), general=true,})
Dispatcher:registerAction("readest_pull_books", { category="none", event="ReadestPullBooks", title=_("Pull Readest book library"), general=true,})
end
-- Reader-only actions (progress + annotations sync) — registered only
-- after a document opens, so they don't appear in the gesture picker
-- when FileManager is the foreground context. They'd never fire from
-- FileManager anyway (the handlers operate on the active ReaderUI), so
-- showing them there would just be noise.
function ReadestSync:onDispatcherRegisterReaderActions()
Dispatcher:registerAction("readest_sync_set_autosync",
{ category="string", event="ReadestSyncToggleAutoSync", title=_("Set auto progress sync"), reader=true,
args={true, false}, toggle={_("on"), _("off")},})
@@ -66,7 +93,199 @@ function ReadestSync:onReaderReady()
self:pullBookNotes(false)
end)
end
self:onDispatcherRegisterActions()
self:onDispatcherRegisterReaderActions()
end
-- Reverse-lookup table: file extension (lowercase) → Readest format
-- token (uppercase). Computed once at module load from EXTS so we don't
-- pay the iteration cost per long-press.
local _readest_format_for_ext = nil
local function readest_format_for_ext(ext)
if not _readest_format_for_ext then
_readest_format_for_ext = {}
local EXTS = require("library.exts")
for fmt, e in pairs(EXTS) do _readest_format_for_ext[e] = fmt end
end
return ext and _readest_format_for_ext[ext:lower()]
end
-- Register an "Add to Readest" button in FileManager's long-press file
-- dialog (the one with Paste / Cut / Delete / Copy / Reading / On hold
-- / etc.). The button only renders for supported book formats and
-- when the user is signed in to Readest.
--
-- Why scheduleIn(0): plugin init() runs while KOReader is still
-- constructing the FileManager — FileManager.instance is not yet
-- assigned. Deferring to the next event-loop tick (same trick simpleui
-- uses at plugins/simpleui.koplugin/main.lua:261) lets the FileManager
-- finish its own init first so .instance is populated when we look it
-- up. Safe in reader context too: .instance stays nil so we no-op.
function ReadestSync:registerFileDialogButton()
local plugin = self
UIManager:scheduleIn(0, function()
local ok_FM, FileManager = pcall(require, "apps/filemanager/filemanager")
if not ok_FM or not FileManager.instance then return end
FileManager.instance:addFileDialogButtons("readest_add_to_library",
function(file, is_file, _book_props)
if not is_file then return nil end
local ext = file:match("%.([^./\\]+)$")
if not readest_format_for_ext(ext) then return nil end
return {
{
text = _("Add to Readest"),
enabled = plugin.settings.access_token ~= nil,
callback = function()
local fc = FileManager.instance and FileManager.instance.file_chooser
local dlg = fc and fc.file_dialog
if dlg then UIManager:close(dlg) end
plugin:addToReadest(file)
end,
},
}
end)
end)
end
-- Hash the file, upsert a Library row, then upload to cloud. Mirrors
-- the upload flow we already use in librarywidget's "Upload to Cloud"
-- action — the only new thing here is computing the partial_md5 from
-- the file directly, since long-pressing in FileManager doesn't go
-- through the Library row path.
function ReadestSync:addToReadest(file)
local lfs = require("libs/libkoreader-lfs")
local util = require("util")
if not self.settings.access_token then
UIManager:show(InfoMessage:new{
text = _("Sign in to Readest first."), timeout = 3,
})
return
end
local attr = lfs.attributes(file)
if not attr or attr.mode ~= "file" then
UIManager:show(InfoMessage:new{
text = _("File not found."), timeout = 3,
})
return
end
local ext = file:match("%.([^./\\]+)$")
local format = readest_format_for_ext(ext)
if not format then
UIManager:show(InfoMessage:new{
text = _("Unsupported book format."), timeout = 3,
})
return
end
-- Hash via util.partialMD5 — same algorithm Readest uses, fast
-- (reads small chunks at fixed offsets, no full-file scan).
local progress = InfoMessage:new{
text = _("Hashing book…"),
}
UIManager:show(progress)
UIManager:nextTick(function()
local hash = util.partialMD5(file)
UIManager:close(progress)
if not hash then
UIManager:show(InfoMessage:new{
text = _("Could not read file."), timeout = 3,
})
return
end
self:_addLocalRow(file, hash, format, attr.size)
end)
end
function ReadestSync:_addLocalRow(file, hash, format, _size)
local store = self:getLibraryStore()
if not store then
UIManager:show(InfoMessage:new{
text = _("Sign in to Readest first."), timeout = 3,
})
return
end
-- Title: filename minus extension. BIM might have richer metadata
-- if the user has opened the book before, but a filename-derived
-- title is good enough for the row to round-trip — the user can
-- later trigger an Upload to Cloud which carries it to Readest.
local basename = file:match("([^/]+)$") or file
local title = basename:gsub("%.[^.]+$", "")
local now = math.floor(os.time() * 1000)
logger.info("ReadestSync addToReadest: hash=" .. hash:sub(1, 8)
.. " title=" .. tostring(title)
.. " format=" .. tostring(format)
.. " path=" .. tostring(file)
.. " now=" .. tostring(now))
-- Dedupe by partial_md5: if a non-tombstoned row with this hash is
-- already in the user's library, just bump its updated_at and skip.
-- Tombstoned rows (deleted_at set) are treated as "not in library"
-- — re-adding writes a fresh local-only row with cloud_present=0.
local existing = store:_getRowRaw(hash)
if existing then
logger.info("ReadestSync addToReadest: existing row found"
.. " cloud_present=" .. tostring(existing.cloud_present)
.. " local_present=" .. tostring(existing.local_present)
.. " deleted_at=" .. tostring(existing.deleted_at)
.. " updated_at=" .. tostring(existing.updated_at))
else
logger.info("ReadestSync addToReadest: no existing row — inserting")
end
if existing and existing.deleted_at == nil then
store:upsertBook({
hash = hash,
title = existing.title or title,
file_path = file,
local_present = 1,
updated_at = now,
})
logger.info("ReadestSync addToReadest dedupe: bumped updated_at for "
.. hash:sub(1, 8) .. " to " .. tostring(now))
local LibraryWidget = require("library.librarywidget")
if LibraryWidget._menu then LibraryWidget.refresh() end
UIManager:show(InfoMessage:new{
text = _("Already in your Readest library:") .. " "
.. (existing.title or title),
timeout = 2,
})
return
end
-- Add as a local-only row (cloud_present defaults to 0). Stamp
-- created_at + updated_at explicitly so the row sorts under "Date
-- Added" / "Last Updated" right away, and so the next pushChangedBooks
-- pass picks it up (its query is `updated_at > since`).
-- _clear_fields un-tombstones the row when re-adding a previously-
-- deleted book — passing deleted_at = nil alone wouldn't clear it
-- because Lua tables drop nil keys and upsertBook's preserve pass
-- would copy the existing tombstone forward.
store:upsertBook({
hash = hash,
title = title,
format = format,
file_path = file,
local_present = 1,
created_at = now,
updated_at = now,
_clear_fields = { "deleted_at" },
})
local row = store:_getRowRaw(hash)
logger.info("ReadestSync addToReadest insert: stored row"
.. " updated_at=" .. tostring(row and row.updated_at)
.. " created_at=" .. tostring(row and row.created_at)
.. " cloud_present=" .. tostring(row and row.cloud_present)
.. " local_present=" .. tostring(row and row.local_present))
local LibraryWidget = require("library.librarywidget")
if LibraryWidget._menu then LibraryWidget.refresh() end
UIManager:show(InfoMessage:new{
text = _("Added to Readest:") .. " " .. title,
timeout = 2,
})
end
function ReadestSync:onAddToReadest(file)
self:addToReadest(file)
end
-- ── Menu ───────────────────────────────────────────────────────────
@@ -92,16 +311,43 @@ function ReadestSync:addToMainMenu(menu_items)
end
end
end,
separator = true,
},
{
text = _("Auto sync progress and annotations"),
text = _("Auto sync"),
checked_func = function() return self.settings.auto_sync end,
callback = function()
self:onReadestSyncToggleAutoSync()
end,
separator = true,
},
{
text = _("Readest library"),
enabled_func = function()
return self.settings.access_token ~= nil and self.settings.user_id ~= nil
end,
callback = function()
self:openLibrary()
end,
},
{
text = _("Push books now"),
enabled_func = function()
return self.settings.access_token ~= nil and self.settings.user_id ~= nil
end,
callback = function()
self:syncBooksLibrary("push", true)
end,
},
{
text = _("Pull books now"),
enabled_func = function()
return self.settings.access_token ~= nil and self.settings.user_id ~= nil
end,
callback = function()
self:syncBooksLibrary("pull", true)
end,
separator = true,
},
{
text = _("Push reading progress now"),
enabled_func = function()
@@ -351,15 +597,141 @@ function ReadestSync:onReadestSyncPullAnnotations()
self:pullBookNotes(true)
end
function ReadestSync:openLibrary()
if not self.settings.access_token or not self.settings.user_id then
UIManager:show(InfoMessage:new{ text = _("Please login first"), timeout = 2 })
return
end
local LibraryWidget = require("library.librarywidget")
LibraryWidget.open({
settings = self.settings,
sync_path = self.path,
sync_auth = SyncAuth,
})
end
function ReadestSync:onReadestOpenLibrary()
self:openLibrary()
end
-- Lazy-open a LibraryStore for the current user. The Library widget may
-- already have one open via librarywidget._store; we share it when present
-- to avoid two SQLite handles to the same file. Returns nil if user_id
-- isn't set (shouldn't happen — caller should gate on access_token).
function ReadestSync:getLibraryStore()
if not self.settings.user_id or self.settings.user_id == "" then return nil end
local LibraryWidget = require("library.librarywidget")
if LibraryWidget._store and LibraryWidget._current_user == self.settings.user_id then
return LibraryWidget._store
end
if self.library_store and self.library_store.user_id == self.settings.user_id then
return self.library_store
end
if self.library_store then self.library_store:close() end
local LibraryStore = require("library.librarystore")
local DataStorage = require("datastorage")
self.library_store = LibraryStore.new({
user_id = self.settings.user_id,
db_path = DataStorage:getSettingsDir() .. "/readest_library.sqlite3",
})
return self.library_store
end
-- Bump updated_at + last_read_at on the local row for the currently-open
-- book. Called before a sync push so the row's timestamp is fresh. Returns
-- the touched row, or nil if there's nothing to touch (no book open, no
-- partial_md5, or hash not in the LibraryStore index).
function ReadestSync:touchOpenBook()
if not self.ui or not self.ui.doc_settings then return nil end
local hash = self.ui.doc_settings:readSetting("partial_md5_checksum")
if not hash or hash == "" then return nil end
local store = self:getLibraryStore()
if not store then return nil end
local progress_lib
if self.ui.document and self.ui.document.getPageCount and self.ui.getCurrentPage then
local cur = self.ui:getCurrentPage()
local total = self.ui.document:getPageCount()
if cur and total then
progress_lib = require("json").encode({ cur, total })
end
end
local touched = store:touchBook(hash, { progress_lib = progress_lib })
if not touched then
logger.dbg("ReadestSync touchOpenBook: no row for " .. hash
.. " (book not in LibraryStore index)")
end
return touched
end
-- syncBooksLibrary(mode, interactive) — bidirectional book-row sync,
-- mirroring useBooksSync.handleAutoSync at apps/readest-app/src/app/
-- library/hooks/useBooksSync.ts:66-78. mode: "push"|"pull"|"both".
-- Touches the currently-open book first so its updated_at gets included
-- in the push delta. Interactive=true shows toast feedback.
function ReadestSync:syncBooksLibrary(mode, interactive)
if not self.settings.access_token or not self.settings.user_id then
if interactive then
UIManager:show(InfoMessage:new{ text = _("Please login first"), timeout = 2 })
end
return
end
local store = self:getLibraryStore()
if not store then
if interactive then
UIManager:show(InfoMessage:new{ text = _("Library not initialized"), timeout = 2 })
end
return
end
if mode ~= "pull" then
-- Touch only matters for push (and 'both' which includes push)
self:touchOpenBook()
end
local syncbooks = require("library.syncbooks")
syncbooks.syncBooks({
sync_auth = SyncAuth,
sync_path = self.path,
settings = self.settings,
store = store,
}, mode, function(success, msg, status)
logger.info("ReadestSync syncBooksLibrary[" .. mode .. "] done: success="
.. tostring(success) .. " msg=" .. tostring(msg))
if interactive then
UIManager:show(InfoMessage:new{
text = success
and _("Books synced")
or _("Books sync failed"),
timeout = 2,
})
end
-- If the Library widget is open, refresh it so newly-pulled rows show
local LibraryWidget = require("library.librarywidget")
if LibraryWidget._menu then LibraryWidget.refresh() end
end)
end
function ReadestSync:onCloseDocument()
if self.settings.auto_sync and self.settings.access_token then
NetworkMgr:goOnlineToRun(function()
self:pushBookConfig(false)
self:pushBookNotes(false)
self:syncBooksLibrary("both", false)
end)
end
end
function ReadestSync:onReadestPushBooks()
self:syncBooksLibrary("push", true)
end
function ReadestSync:onReadestPullBooks()
self:syncBooksLibrary("pull", true)
end
function ReadestSync:onPageUpdate(page)
if self.settings.auto_sync and self.settings.access_token and page then
if self.delayed_push_task then
@@ -14,6 +14,37 @@
"required_params": ["books", "notes", "configs"],
"payload": ["books", "notes", "configs"],
"expected_status": [200, 201, 301, 400, 401, 403]
},
"pullBooks": {
"path": "/sync?type=books",
"method": "GET",
"required_params": ["since"],
"expected_status": [200, 400, 301, 401, 403]
},
"getDownloadUrl": {
"path": "/storage/download",
"method": "GET",
"required_params": ["fileKey"],
"expected_status": [200, 400, 301, 401, 403, 404]
},
"listFiles": {
"path": "/storage/list",
"method": "GET",
"required_params": ["bookHash"],
"expected_status": [200, 400, 301, 401, 403]
},
"deleteFile": {
"path": "/storage/delete",
"method": "DELETE",
"required_params": ["fileKey"],
"expected_status": [200, 400, 301, 401, 403, 404]
},
"getUploadUrl": {
"path": "/storage/upload",
"method": "POST",
"required_params": ["fileName", "fileSize", "bookHash"],
"payload": ["fileName", "fileSize", "bookHash"],
"expected_status": [200, 400, 301, 401, 403]
}
}
}
+70 -31
View File
@@ -68,27 +68,30 @@ function ReadestSyncClient:init()
end
end
function ReadestSyncClient:pullChanges(params, callback)
-- Internal: prepare the Spore client with our standard middleware stack.
-- Call before each RPC.
function ReadestSyncClient:_prepare()
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("ReadestHeaders", {})
self.client:enable("ReadestAuth", {})
end
-- Internal: dispatch a Spore RPC and invoke `callback(success, body, status)`
-- when the async response arrives. The status is forwarded so callers can
-- distinguish 401/403/404 from generic failure (codex round 1 finding 15).
function ReadestSyncClient:_dispatch(name, args, callback)
self:_prepare()
socketutil:set_timeout(SYNC_TIMEOUTS[1], SYNC_TIMEOUTS[2])
local co = coroutine.create(function()
local ok, res = pcall(function()
return self.client:pullChanges({
since = params.since,
type = params.type,
book = params.book,
meta_hash = params.meta_hash,
})
return self.client[name](self.client, args)
end)
if ok then
callback(res.status == 200, res.body)
callback(res.status == 200, res.body, res.status)
else
logger.dbg("ReadestSyncClient:pullChanges failure:", res)
callback(false, res.body)
logger.dbg("ReadestSyncClient:" .. name .. " failure:", res)
callback(false, res and res.body or nil, res and res.status or nil)
end
end)
self.client:enable("AsyncHTTP", {thread = co})
@@ -97,28 +100,64 @@ function ReadestSyncClient:pullChanges(params, callback)
socketutil:reset_timeout()
end
function ReadestSyncClient:pullChanges(params, callback)
self:_dispatch("pullChanges", {
since = params.since,
type = params.type,
book = params.book,
meta_hash = params.meta_hash,
}, callback)
end
function ReadestSyncClient:pushChanges(changes, callback)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("ReadestHeaders", {})
self.client:enable("ReadestAuth", {})
self:_dispatch("pushChanges", changes or {}, callback)
end
socketutil:set_timeout(SYNC_TIMEOUTS[1], SYNC_TIMEOUTS[2])
local co = coroutine.create(function()
local ok, res = pcall(function()
return self.client:pushChanges(changes or {})
end)
if ok then
callback(res.status == 200, res.body)
else
logger.dbg("ReadestSyncClient:pushChanges failure:", res)
callback(false, res.body)
end
end)
self.client:enable("AsyncHTTP", {thread = co})
coroutine.resume(co)
if UIManager.looper then UIManager:setInputTimeout() end
socketutil:reset_timeout()
-- pullBooks: incremental fetch of the books table since the watermark.
-- Returns body shape `{ books: [...] }`. Drives Library:open() refresh.
function ReadestSyncClient:pullBooks(params, callback)
self:_dispatch("pullBooks", { since = params.since }, callback)
end
-- getDownloadUrl: resolve a storage fileKey to a signed URL. The server's
-- processFileKeys fallback at apps/readest-app/src/pages/api/storage/
-- download.ts:99-107 lets us send the simple {hash}/{hash}.{ext} variant
-- and have R2 deployments resolve to the actual stored filename
-- transparently. Body shape on success: { downloadUrl }.
function ReadestSyncClient:getDownloadUrl(params, callback)
self:_dispatch("getDownloadUrl", { fileKey = params.fileKey }, callback)
end
-- listFiles: enumerate the rows of the `files` table for a given book
-- hash. Used to discover the EXACT fileKeys (book object + cover.png)
-- before deletion — the DELETE endpoint requires a literal match (no
-- extension fallback like /storage/download has). Body shape on success:
-- { files: [ { file_key, file_size, book_hash, ... } ] }.
function ReadestSyncClient:listFiles(params, callback)
self:_dispatch("listFiles", { bookHash = params.bookHash }, callback)
end
-- deleteFile: remove one storage object plus its `files` row. Caller
-- usually iterates over listFiles output. The `books` row stays put;
-- cleanup of the books table is a separate /sync push (with deletedAt).
function ReadestSyncClient:deleteFile(params, callback)
self:_dispatch("deleteFile", { fileKey = params.fileKey }, callback)
end
-- getUploadUrl: ask the server to issue a presigned PUT URL for a new
-- storage object. Two-step flow (`apps/readest-app/src/pages/api/storage/
-- upload.ts`): server inserts a row in the `files` table for
-- (user, bookHash, fileKey) BEFORE the actual bytes move. Body shape on
-- success: { uploadUrl, fileKey, usage, quota }. Quota-exceeded → 403.
-- The fileName must be the cloud-relative path (eg
-- "Readest/Books/<hash>/<hash>.epub"); the server prepends "<user.id>/"
-- to form the final fileKey.
function ReadestSyncClient:getUploadUrl(params, callback)
self:_dispatch("getUploadUrl", {
fileName = params.fileName,
fileSize = params.fileSize,
bookHash = params.bookHash,
}, callback)
end
return ReadestSyncClient
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env node
/**
* Build a Readest-${version}-1.koplugin.zip locally for sideloading
* onto a real KOReader device. Mirrors the same exclusions the CI
* release workflow uses (.github/workflows/release.yml: "create
* KOReader plugin zip"):
* scripts/ — i18n + build helpers
* docs/ — design notes
* spec/ — busted test suite
* .busted — busted runner config
*
* Usage:
* node apps/readest.koplugin/scripts/build-koplugin.js [--version X.Y.Z]
* [--out PATH]
* [--keep-meta]
*
* --version Stamp _meta.lua with this version before zipping. If
* omitted, a "dev-<sha>" placeholder is used so the
* installed plugin is identifiable. _meta.lua is restored
* after zipping unless --keep-meta is passed.
*
* --out PATH Destination zip path. Default:
* ./Readest-${version}-1.koplugin.zip
*
* --keep-meta Don't restore _meta.lua after the build. Useful when
* chaining a release tag.
*
* Requirements: a working `zip` binary on PATH (macOS/Linux/WSL/git-bash).
*/
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PLUGIN_DIR = path.resolve(__dirname, '..');
const PLUGIN_NAME = path.basename(PLUGIN_DIR); // "readest.koplugin"
const APPS_DIR = path.resolve(PLUGIN_DIR, '..');
const META_FILE = path.join(PLUGIN_DIR, '_meta.lua');
function parseArgs(argv) {
const out = { keepMeta: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--version') out.version = argv[++i];
else if (a === '--out') out.out = argv[++i];
else if (a === '--keep-meta') out.keepMeta = true;
else if (a === '-h' || a === '--help') {
console.log(`Usage:
node build-koplugin.js [--version X.Y.Z] [--out PATH] [--keep-meta]
Default version: dev-<git-sha>
Default out: ./Readest-<version>-1.koplugin.zip`);
process.exit(0);
} else {
console.error(`Unknown argument: ${a}`);
process.exit(2);
}
}
return out;
}
function shortGitSha() {
try {
return execFileSync('git', ['rev-parse', '--short=8', 'HEAD'], {
cwd: PLUGIN_DIR,
encoding: 'utf8',
}).trim();
} catch {
return 'unknown';
}
}
function which(cmd) {
const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd]);
return r.status === 0;
}
function stampMeta(version) {
const original = fs.readFileSync(META_FILE, 'utf8');
// Inject ` version = "X",` immediately before the closing `}`.
// Same regex shape as the CI step's perl one-liner; matches a `}`
// at the start of a line and prepends the version line.
const stamped = original.replace(/^}/m, ` version = "${version}",\n}`);
fs.writeFileSync(META_FILE, stamped);
return original;
}
function restoreMeta(original) {
fs.writeFileSync(META_FILE, original);
}
function main() {
const args = parseArgs(process.argv.slice(2));
const version = args.version || `dev-${shortGitSha()}`;
const out = path.resolve(args.out || `Readest-${version}-1.koplugin.zip`);
if (!which('zip')) {
console.error('error: `zip` not found on PATH. Install via your package manager.');
process.exit(1);
}
// Remove an existing zip so `zip -r` doesn't accidentally append.
if (fs.existsSync(out)) fs.unlinkSync(out);
const originalMeta = stampMeta(version);
let zipExit = 1;
try {
const exclusions = [
`${PLUGIN_NAME}/scripts/*`,
`${PLUGIN_NAME}/docs/*`,
`${PLUGIN_NAME}/spec/*`,
`${PLUGIN_NAME}/.busted`,
];
const zipArgs = ['-r', out, PLUGIN_NAME, '-x', ...exclusions];
console.log(`Building ${path.basename(out)} from ${PLUGIN_DIR}`);
console.log(` excluding: ${exclusions.join(', ')}`);
const r = spawnSync('zip', zipArgs, {
cwd: APPS_DIR,
stdio: 'inherit',
});
zipExit = r.status === null ? 1 : r.status;
} finally {
if (!args.keepMeta) restoreMeta(originalMeta);
}
if (zipExit !== 0) {
console.error(`zip exited with code ${zipExit}`);
process.exit(zipExit);
}
const stat = fs.statSync(out);
console.log(`${out} (${(stat.size / 1024).toFixed(1)} KiB)`);
}
main();
Binary file not shown.
@@ -17,18 +17,33 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const KOPLUGIN_DIR = path.resolve(__dirname, '..', '..', 'readest.koplugin');
// Script lives at apps/readest.koplugin/scripts/; one parent up is the
// koplugin root.
const KOPLUGIN_DIR = path.resolve(__dirname, '..');
if (!fs.existsSync(KOPLUGIN_DIR)) {
console.error(`koplugin directory not found at ${KOPLUGIN_DIR}`);
process.exit(1);
}
const luaFiles = fs
.readdirSync(KOPLUGIN_DIR)
.filter((f) => f.endsWith('.lua'))
.map((f) => path.join(KOPLUGIN_DIR, f))
.sort();
// Recurse into subdirectories so files under `library/` and `spec/` get
// syntax-checked too. Skip dotfiles/dirs (e.g. `.busted`) and `node_modules`
// in case anyone vendors a JS dep here later.
function collectLuaFiles(dir) {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...collectLuaFiles(full));
} else if (entry.isFile() && entry.name.endsWith('.lua')) {
out.push(full);
}
}
return out;
}
const luaFiles = collectLuaFiles(KOPLUGIN_DIR).sort();
if (luaFiles.length === 0) {
console.log('No .lua files to check.');
@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* Run busted unit tests against apps/readest.koplugin under LuaJIT (the
* runtime KOReader uses). Spec files live in `apps/readest.koplugin/spec/`
* and are auto-discovered via `.busted` config.
*
* Invoked from `pnpm test:lua`. Soft-skips with a notice when busted or
* luajit is not installed; CI installs both and runs unconditionally.
*
* Required toolchain (one-time):
* brew install luajit luarocks (or apt-get install …)
* luarocks --lua-version=5.1 install busted
* luarocks --lua-version=5.1 install lsqlite3complete
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Script lives at apps/readest.koplugin/scripts/; one parent up is the
// koplugin root.
const KOPLUGIN_DIR = path.resolve(__dirname, '..');
if (!fs.existsSync(KOPLUGIN_DIR)) {
console.error(`koplugin directory not found at ${KOPLUGIN_DIR}`);
process.exit(1);
}
if (!fs.existsSync(path.join(KOPLUGIN_DIR, 'spec'))) {
console.log('No spec/ directory under apps/readest.koplugin — nothing to test.');
process.exit(0);
}
const which = (cmd, env = process.env) =>
spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { env });
// In CI a missing toolchain is a hard failure (exit 1); locally it's a
// soft skip (exit 0) so devs without busted installed don't get blocked.
// GitHub Actions sets CI=true; respect any other CI's convention too.
const HARD_FAIL = !!process.env.CI;
const onMissing = (tool, install_hint) => {
const msg =
`test:lua: ${tool} not found. ` +
`Install via ${install_hint}.` +
(HARD_FAIL ? '' : ' Skipping (set CI=1 to make this an error).');
if (HARD_FAIL) {
console.error(msg);
process.exit(1);
} else {
console.warn(msg);
process.exit(0);
}
};
if (which('luajit').status !== 0) {
onMissing(
'luajit',
'`brew install luajit` on macOS, `apt-get install luajit` on Linux',
);
}
if (which('luarocks').status !== 0) {
onMissing('luarocks', '`brew install luarocks` (or `apt-get install luarocks`)');
}
// `luarocks --lua-version=5.1 path` emits export lines for PATH, LUA_PATH,
// LUA_CPATH that point at the user's 5.1 rocks tree. pnpm's spawned shells
// don't source the user's rc files, so ~/.luarocks/bin is usually missing
// from PATH and `which busted` fails even when busted is installed. We
// re-inject these vars into the spawn env here so subsequent lookups +
// busted's own `require('busted.runner')` both succeed.
const lrPath = spawnSync('luarocks', ['--lua-version=5.1', 'path'], { encoding: 'utf8' });
if (lrPath.status !== 0) {
if (HARD_FAIL) {
console.error('test:lua: `luarocks --lua-version=5.1 path` failed.');
process.exit(1);
}
console.warn('test:lua: `luarocks --lua-version=5.1 path` failed.');
process.exit(0);
}
const env = { ...process.env };
for (const line of lrPath.stdout.split('\n')) {
const m = line.match(/^export (LUA_PATH|LUA_CPATH|PATH)='(.*)'$/);
if (m) {
if (m[1] === 'PATH') {
// luarocks's PATH already includes the rest of the user's PATH (it
// re-emits the value it inherited), so an outright assignment is
// safe and avoids a double-prepend.
env.PATH = m[2];
} else {
env[m[1]] = m[2];
}
}
}
if (which('busted', env).status !== 0) {
onMissing(
'busted',
'`luarocks --lua-version=5.1 install busted` ' +
'(and `lsqlite3complete` for SQLite tests)',
);
}
const luajitPath = which('luajit', env).stdout.toString().trim();
const r = spawnSync('busted', [`--lua=${luajitPath}`], {
cwd: KOPLUGIN_DIR,
stdio: 'inherit',
env,
});
process.exit(r.status === null ? 1 : r.status);
+69
View File
@@ -0,0 +1,69 @@
# readest.koplugin tests
Unit tests for `apps/readest.koplugin/library/` modules. Runs under **LuaJIT
2.1** (the runtime KOReader uses) via [busted](https://lunarmodules.github.io/busted/).
## Toolchain
One-time per machine:
```bash
# macOS
brew install luajit luarocks
# Linux (Debian/Ubuntu)
sudo apt-get install luajit luarocks
# Then, regardless of OS:
luarocks --lua-version=5.1 install busted
luarocks --lua-version=5.1 install lsqlite3complete
```
The `--lua-version=5.1` flag is required: LuaJIT identifies itself as Lua 5.1, and we install rocks against that runtime so production code (which targets LuaJIT) and test code share a Lua interpreter.
## Running
From the repo root:
```bash
pnpm test:lua
```
Or from this directory:
```bash
eval "$(luarocks --lua-version=5.1 path)"
busted --lua=$(which luajit)
```
## Layout
```
spec/
├── spec_helper.lua # KOReader stubs + lua-ljsqlite3 shim (loaded once)
├── library/
│ ├── smoke_spec.lua # Sanity check that the harness boots
│ └── *_spec.lua # One per module under library/
└── README.md # This file
```
## What `spec_helper` provides
- **`require("lua-ljsqlite3/init")`** → returns a SQLite shim wrapping `lsqlite3complete`. Exposes the subset of the lua-ljsqlite3 API our library modules use (`open`, `exec`, `prepare`, `bind1`, `step`, `reset`, `clearbind`, `close`, etc).
- **`require("logger")`** → no-op logger (`warn`/`info`/`dbg`/`err` callable).
- **`require("datastorage")`** → fake `DataStorage:getSettingsDir()` returning a per-test `mktemp -d` path.
- **`require("device")`** → stub `Device.canUseWAL() == true`, `Device.screen` with `getWidth/getHeight`.
- **`G_reader_settings`** (global) → in-memory `readSetting`/`saveSetting`/`flush`.
Each spec calls `require("spec_helper").reset()` in `before_each` to wipe state.
## Adding a new module
1. Write production code at `apps/readest.koplugin/library/foo.lua`.
2. Write `apps/readest.koplugin/spec/library/foo_spec.lua`.
3. Run `pnpm test:lua` from the repo root.
4. Run `pnpm lint:lua` to syntax-check (LuaJIT bytecode compile).
## Why LuaJIT and not stock Lua?
KOReader runs LuaJIT exclusively. LuaJIT extends Lua 5.1 with FFI and a few syntax tweaks; stock Lua 5.4 has features (integer division `//`, bit operators `~`, `<const>` annotations) that LuaJIT rejects. Running tests under LuaJIT catches these incompatibilities at test time instead of when KOReader fails to load the plugin.
@@ -0,0 +1,55 @@
-- coverprovider_spec.lua
-- Pure-function tests for library/coverprovider.lua. The blitbuffer-returning
-- functions wrap BookInfoManager (coverbrowser plugin) and a streaming HTTP
-- download; both require live KOReader and are exercised manually.
require("spec_helper")
describe("library.coverprovider", function()
local cp
before_each(function()
require("spec_helper").reset()
package.loaded["library.coverprovider"] = nil
cp = require("library.coverprovider")
end)
describe("coverbrowser_loaded", function()
it("returns false when the covermenu module isn't on package.path", function()
-- Make absolutely sure no leftover stub is hanging around
package.loaded["covermenu"] = nil
package.preload["covermenu"] = nil
assert.is_false(cp.coverbrowser_loaded())
end)
it("returns true when the covermenu module IS available", function()
package.preload["covermenu"] = function() return { _stub = true } end
assert.is_true(cp.coverbrowser_loaded())
package.preload["covermenu"] = nil
package.loaded["covermenu"] = nil
end)
end)
describe("cached_cover_path", function()
it("returns {covers_dir}/{hash}.png", function()
assert.are.equal("/cache/abc123.png",
cp.cached_cover_path("/cache", "abc123"))
end)
it("returns nil when either input is missing/empty", function()
assert.is_nil(cp.cached_cover_path(nil, "h"))
assert.is_nil(cp.cached_cover_path("/cache", nil))
assert.is_nil(cp.cached_cover_path("", "h"))
assert.is_nil(cp.cached_cover_path("/cache", ""))
end)
end)
describe("MISSING sentinel", function()
it("exposes a stable 'no cover available' marker", function()
assert.is_string(cp.MISSING)
assert.is_truthy(cp.MISSING)
-- Truthy + distinguishable from any real path
assert.are.equal("_missing", cp.MISSING)
end)
end)
end)
@@ -0,0 +1,35 @@
-- exts_spec.lua
-- The EXTS table is small enough that a direct equality assertion is the
-- right test: it doubles as a regression guard against accidental drift
-- from `apps/readest-app/src/libs/document.ts`.
local EXTS = require("library.exts")
describe("library.exts", function()
it("matches the web-side EXTS mapping verbatim", function()
assert.are.same({
EPUB = "epub",
PDF = "pdf",
MOBI = "mobi",
AZW = "azw",
AZW3 = "azw3",
CBZ = "cbz",
FB2 = "fb2",
FBZ = "fbz",
TXT = "txt",
MD = "md",
}, EXTS)
end)
it("covers every format the cloud `book.format` field can hold", function()
-- If the web side adds a new format, the syncbooks.lua fileKey builder
-- will silently produce nil for the extension. This assertion catches
-- the omission early.
local required = {
"EPUB", "PDF", "MOBI", "AZW", "AZW3", "CBZ", "FB2", "FBZ", "TXT", "MD",
}
for _, fmt in ipairs(required) do
assert.is_string(EXTS[fmt], "missing extension for " .. fmt)
end
end)
end)
@@ -0,0 +1,870 @@
-- librarystore_spec.lua
-- Defines the contract for library/librarystore.lua. Written before the
-- implementation so the spec is the source of truth for "how the store
-- should behave".
local helper = require("spec_helper")
-- Convenient time stamps for sorting tests (unix ms)
local T_OLD = 1700000000000 -- 2023-11-14
local T_MID = 1750000000000 -- 2025-06-15
local T_RECENT = 1770000000000 -- 2026-02-01
-- Minimal book row factory; tests override fields they care about.
local function book(over)
local row = {
hash = "h" .. tostring(math.random(2 ^ 31)),
meta_hash = nil,
title = "Untitled",
source_title = nil,
author = "Anon",
format = "EPUB",
metadata_json = nil,
series = nil,
series_index = nil,
group_id = nil,
group_name = nil,
cover_path = nil,
file_path = nil,
cloud_present = 0,
local_present = 0,
uploaded_at = nil,
progress_lib = nil,
reading_status = nil,
last_read_at = nil,
created_at = T_OLD,
updated_at = T_OLD,
deleted_at = nil,
}
if over then for k, v in pairs(over) do row[k] = v end end
return row
end
describe("LibraryStore", function()
local LibraryStore
before_each(function()
helper.reset()
package.loaded["library.librarystore"] = nil
LibraryStore = require("library.librarystore")
end)
-- =====================================================================
-- Schema construction
-- =====================================================================
describe("schema", function()
it("opens an in-memory DB and creates the books + sync_state tables", function()
local store = LibraryStore.new({ user_id = "alice" })
assert.is_not_nil(store)
assert.are.equal(0, #store:listBooks({}))
store:close()
end)
it("survives being constructed twice (CREATE IF NOT EXISTS)", function()
local s1 = LibraryStore.new({ user_id = "alice" })
s1:upsertBook(book({ hash = "h1", title = "A", cloud_present = 1 }))
s1:close()
-- Re-open the same on-disk file: state survives.
local DataStorage = require("datastorage")
local path = DataStorage:getSettingsDir() .. "/test.sqlite3"
local s2 = LibraryStore.new({ user_id = "alice", db_path = path })
s2:upsertBook(book({ hash = "h2", title = "B", cloud_present = 1 }))
s2:close()
local s3 = LibraryStore.new({ user_id = "alice", db_path = path })
assert.are.equal(1, #s3:listBooks({})) -- only h2; h1 was in-memory
s3:close()
end)
it("sets PRAGMA user_version = 1", function()
local store = LibraryStore.new({ user_id = "alice" })
assert.are.equal(1, store:getUserVersion())
store:close()
end)
end)
-- =====================================================================
-- upsertBook
-- =====================================================================
describe("upsertBook", function()
local store
before_each(function() store = LibraryStore.new({ user_id = "alice" }) end)
after_each(function() store:close() end)
it("inserts a new row", function()
store:upsertBook(book({ hash = "h1", title = "Foundation", cloud_present = 1 }))
local rows = store:listBooks({})
assert.are.equal(1, #rows)
assert.are.equal("Foundation", rows[1].title)
assert.are.equal("h1", rows[1].hash)
end)
it("updates an existing row by hash", function()
store:upsertBook(book({ hash = "h1", title = "Foundation", cloud_present = 1 }))
store:upsertBook(book({ hash = "h1", title = "Foundation (Revised)", cloud_present = 1 }))
local rows = store:listBooks({})
assert.are.equal(1, #rows)
assert.are.equal("Foundation (Revised)", rows[1].title)
end)
it("OR-merges cloud_present and local_present flags", function()
store:upsertBook(book({ hash = "h1", title = "X", cloud_present = 1, local_present = 0 }))
-- Local scanner discovers the file and upserts with local_present=1.
-- It doesn't know about cloud state, so it omits cloud_present (or sends 0).
store:upsertBook(book({ hash = "h1", title = "X", local_present = 1 }))
local rows = store:listBooks({})
assert.are.equal(1, rows[1].cloud_present)
assert.are.equal(1, rows[1].local_present)
end)
it("explicit cloud_present=0 from a deleted_at sync update DOES set it to 0", function()
store:upsertBook(book({ hash = "h1", title = "X", cloud_present = 1, local_present = 1 }))
-- Tombstone update from sync: explicit cloud_present=0 + deleted_at set
store:upsertBook(book({
hash = "h1", title = "X",
cloud_present = 0, local_present = 1,
deleted_at = T_RECENT,
_force_cloud_present = true, -- explicit override sentinel
}))
local row = store:_getRowRaw("h1")
assert.are.equal(0, row.cloud_present)
assert.are.equal(T_RECENT, row.deleted_at)
end)
it("_clear_fields explicitly nulls listed columns even when existing has them set", function()
-- Tombstoned existing row
store:upsertBook(book({
hash = "h1", title = "X",
cloud_present = 0, local_present = 0,
deleted_at = T_OLD,
_force_cloud_present = true,
}))
assert.are.equal(T_OLD, store:_getRowRaw("h1").deleted_at)
-- Re-add with _clear_fields → deleted_at goes to NULL
store:upsertBook({
hash = "h1",
title = "X",
local_present = 1,
updated_at = T_RECENT,
_clear_fields = { "deleted_at" },
})
local row = store:_getRowRaw("h1")
assert.is_nil(row.deleted_at)
assert.are.equal(1, row.local_present)
assert.are.equal(T_RECENT, row.updated_at)
end)
end)
-- =====================================================================
-- touchBook: bumps updated_at + last_read_at to "now", merges other
-- fields. Used after the user closes a book in the reader so the
-- next /sync push carries the up-to-date timestamps + progress.
-- =====================================================================
describe("touchBook", function()
local store
before_each(function() store = LibraryStore.new({ user_id = "alice" }) end)
after_each(function() store:close() end)
it("returns nil for an unknown hash (no insert)", function()
local out = store:touchBook("nope")
assert.is_nil(out)
assert.are.equal(0, #store:listBooks({}))
end)
it("updates updated_at + last_read_at to current time", function()
store:upsertBook(book({
hash = "h1", title = "X",
cloud_present = 1,
updated_at = T_OLD, last_read_at = T_OLD,
}))
local before = os.time() * 1000
local touched = store:touchBook("h1")
local after = os.time() * 1000
assert.is_not_nil(touched)
assert.is_true(touched.updated_at >= before, "updated_at not bumped: " .. tostring(touched.updated_at))
assert.is_true(touched.updated_at <= after, "updated_at exceeds now")
assert.is_true(touched.last_read_at >= before)
end)
it("merges extra fields (e.g. progress_lib) into the row", function()
store:upsertBook(book({
hash = "h1", title = "X", cloud_present = 1,
}))
local touched = store:touchBook("h1", { progress_lib = "[42,250]" })
assert.are.equal("[42,250]", touched.progress_lib)
end)
it("preserves cloud_present + local_present (no flag downgrade)", function()
store:upsertBook(book({
hash = "h1", title = "X",
cloud_present = 1, local_present = 1,
}))
local touched = store:touchBook("h1")
assert.are.equal(1, touched.cloud_present)
assert.are.equal(1, touched.local_present)
end)
end)
-- =====================================================================
-- getChangedBooks: returns rows with updated_at > since OR deleted_at
-- > since. Mirrors useBooksSync.getNewBooks at
-- apps/readest-app/src/app/library/hooks/useBooksSync.ts:22-35.
-- =====================================================================
describe("getChangedBooks", function()
local store
before_each(function()
store = LibraryStore.new({ user_id = "alice" })
store:upsertBook(book({ hash = "old", title = "Old", cloud_present = 1, updated_at = T_OLD }))
store:upsertBook(book({ hash = "mid", title = "Mid", cloud_present = 1, updated_at = T_MID }))
store:upsertBook(book({ hash = "new", title = "New", cloud_present = 1, updated_at = T_RECENT }))
end)
after_each(function() store:close() end)
it("returns ALL books when since=0 (initial sync)", function()
local out = store:getChangedBooks(0)
assert.are.equal(3, #out)
end)
it("returns only books changed since the watermark", function()
local out = store:getChangedBooks(T_MID)
assert.are.equal(1, #out)
assert.are.equal("New", out[1].title)
end)
it("returns empty when nothing has changed since the watermark", function()
local out = store:getChangedBooks(T_RECENT + 1)
assert.are.equal(0, #out)
end)
it("includes rows where deleted_at > since (tombstone push)", function()
-- Mark "old" as deleted at T_RECENT — its updated_at is T_OLD
-- (< since=T_MID), but deleted_at (T_RECENT > T_MID) means
-- this push needs to carry the tombstone to the server.
store:upsertBook(book({
hash = "old", title = "Old",
cloud_present = 0, _force_cloud_present = true,
deleted_at = T_RECENT,
}))
local out = store:getChangedBooks(T_MID)
local hashes = {}
for _, r in ipairs(out) do hashes[r.hash] = true end
assert.is_true(hashes["old"], "deleted row missing from changed set")
assert.is_true(hashes["new"], "recent row missing")
end)
it("scopes by user_id", function()
local DataStorage = require("datastorage")
local path = DataStorage:getSettingsDir() .. "/changed_multi.sqlite3"
local alice = LibraryStore.new({ user_id = "alice", db_path = path })
alice:upsertBook(book({ hash = "a1", title = "A", cloud_present = 1, updated_at = T_RECENT }))
local bob = LibraryStore.new({ user_id = "bob", db_path = path })
bob:upsertBook(book({ hash = "b1", title = "B", cloud_present = 1, updated_at = T_RECENT }))
assert.are.equal(1, #alice:getChangedBooks(0))
assert.are.equal(1, #bob:getChangedBooks(0))
alice:close(); bob:close()
end)
end)
-- =====================================================================
-- listBooks: filters, sort, hides ghosts and tombstones
-- =====================================================================
describe("listBooks", function()
local store
before_each(function()
store = LibraryStore.new({ user_id = "alice" })
store:upsertBook(book({
hash = "h1", title = "Foundation", author = "Asimov",
cloud_present = 1, updated_at = T_OLD, last_read_at = T_OLD,
}))
store:upsertBook(book({
hash = "h2", title = "Dune", author = "Herbert",
cloud_present = 1, local_present = 1,
updated_at = T_RECENT, last_read_at = T_RECENT,
}))
store:upsertBook(book({
hash = "h3", title = "Hyperion", author = "Simmons",
local_present = 1, updated_at = T_MID, last_read_at = T_MID,
}))
end)
after_each(function() store:close() end)
it("returns all visible books by default sorted by updated_at desc", function()
local rows = store:listBooks({})
assert.are.equal(3, #rows)
assert.are.equal("Dune", rows[1].title) -- T_RECENT
assert.are.equal("Hyperion", rows[2].title) -- T_MID
assert.are.equal("Foundation", rows[3].title) -- T_OLD
end)
it("hides rows with deleted_at != null even when local_present=1", function()
-- Cloud delete arrives; local file still on disk
store:upsertBook(book({
hash = "h2", title = "Dune", author = "Herbert",
cloud_present = 0, local_present = 1,
deleted_at = T_RECENT,
_force_cloud_present = true,
}))
local rows = store:listBooks({})
assert.are.equal(2, #rows)
for _, r in ipairs(rows) do assert.is_not.equal("Dune", r.title) end
end)
it("hides rows where neither flag is set (ghost rows)", function()
-- Manually craft a ghost via direct upsert without flags
store:upsertBook(book({ hash = "h-ghost", title = "Ghost" }))
local rows = store:listBooks({})
assert.are.equal(3, #rows)
for _, r in ipairs(rows) do assert.is_not.equal("Ghost", r.title) end
end)
it("filters by case-insensitive substring search across title and author", function()
local r1 = store:listBooks({ search = "asimov" })
assert.are.equal(1, #r1)
assert.are.equal("Foundation", r1[1].title)
local r2 = store:listBooks({ search = "DUNE" })
assert.are.equal(1, #r2)
assert.are.equal("Dune", r2[1].title)
local r3 = store:listBooks({ search = "z-no-match" })
assert.are.equal(0, #r3)
end)
it("sorts by last_read_at desc", function()
local rows = store:listBooks({ sort_by = "last_read_at", sort_asc = false })
assert.are.equal("Dune", rows[1].title)
end)
it("'last_read_at' sort uses COALESCE(updated_at, last_read_at) — bumped updated_at floats the row", function()
-- Foundation has the OLDEST last_read_at + updated_at in
-- the fixture, so it sorts last under the default. Now
-- simulate "Add to Readest" dedupe bumping ONLY its
-- updated_at: the row should jump to the top because
-- COALESCE(T_FUTURE, …) = T_FUTURE > all others.
local T_FUTURE = T_RECENT + 60000 -- one minute after the freshest row
store:upsertBook({
hash = "h1", title = "Foundation",
updated_at = T_FUTURE,
})
local rows = store:listBooks({ sort_by = "last_read_at", sort_asc = false })
assert.are.equal("Foundation", rows[1].title)
end)
it("'last_read_at' sort still falls back to updated_at when last_read_at is NULL", function()
-- Cloud-only book: last_read_at NULL, updated_at = the freshest.
store:upsertBook(book({
hash = "h-cloud", title = "Anathem", author = "Stephenson",
cloud_present = 1, last_read_at = nil,
updated_at = T_RECENT + 1000,
}))
local rows = store:listBooks({ sort_by = "last_read_at", sort_asc = false })
assert.are.equal("Anathem", rows[1].title)
end)
it("sorts by title ascending", function()
local rows = store:listBooks({ sort_by = "title", sort_asc = true })
assert.are.equal("Dune", rows[1].title)
assert.are.equal("Foundation", rows[2].title)
assert.are.equal("Hyperion", rows[3].title)
end)
it("sorts by author descending", function()
local rows = store:listBooks({ sort_by = "author", sort_asc = false })
assert.are.equal("Simmons", rows[1].author)
assert.are.equal("Herbert", rows[2].author)
assert.are.equal("Asimov", rows[3].author)
end)
it("filters by group when group_by + group_filter are set", function()
local rows = store:listBooks({ group_by = "author", group_filter = "Asimov" })
assert.are.equal(1, #rows)
assert.are.equal("Foundation", rows[1].title)
end)
end)
-- =====================================================================
-- getGroups
-- =====================================================================
describe("getGroups", function()
local store
before_each(function()
store = LibraryStore.new({ user_id = "alice" })
store:upsertBook(book({ hash = "a1", title = "F1", author = "Asimov", cloud_present = 1, updated_at = T_OLD }))
store:upsertBook(book({ hash = "a2", title = "F2", author = "Asimov", cloud_present = 1, updated_at = T_MID }))
store:upsertBook(book({ hash = "h1", title = "D", author = "Herbert", cloud_present = 1, updated_at = T_RECENT }))
store:upsertBook(book({ hash = "g1", title = "G1", author = "Anon", cloud_present = 1, group_name = "Sci-Fi", updated_at = T_OLD }))
store:upsertBook(book({ hash = "g2", title = "G2", author = "Anon", cloud_present = 1, group_name = "Sci-Fi", updated_at = T_MID }))
end)
after_each(function() store:close() end)
it("group by author returns {name, count, latest_updated_at}", function()
local groups = store:getGroups("author")
local by_name = {}
for _, g in ipairs(groups) do by_name[g.name] = g end
assert.are.equal(2, by_name["Asimov"].count)
assert.are.equal(T_MID, by_name["Asimov"].latest_updated_at)
assert.are.equal(1, by_name["Herbert"].count)
assert.are.equal(2, by_name["Anon"].count)
end)
it("group by group_name skips rows with null group_name", function()
local groups = store:getGroups("group_name")
assert.are.equal(1, #groups)
assert.are.equal("Sci-Fi", groups[1].name)
assert.are.equal(2, groups[1].count)
end)
it("memoizes results across calls", function()
local g1 = store:getGroups("author")
local g2 = store:getGroups("author")
-- Same Lua table reference (cache hit, not just equal contents)
assert.are.equal(g1, g2)
end)
it("invalidates cache after upsertBook", function()
local g1 = store:getGroups("author")
store:upsertBook(book({ hash = "h2", title = "D2", author = "Herbert", cloud_present = 1 }))
local g2 = store:getGroups("author")
assert.is_not.equal(g1, g2)
local by_name = {}
for _, g in ipairs(g2) do by_name[g.name] = g end
assert.are.equal(2, by_name["Herbert"].count)
end)
end)
-- =====================================================================
-- listBookshelfGroups: nested folders for group_name, flat for others
-- =====================================================================
describe("listBookshelfGroups", function()
local store
before_each(function()
store = LibraryStore.new({ user_id = "alice" })
-- Top-level folders Fantasy + SciFi, plus a nested Fantasy/Tolkien
-- and Fantasy/Tolkien/LOTR. One book at root (no group_name).
store:upsertBook(book({ hash = "f1", title = "Conan", group_name = "Fantasy", cloud_present = 1, updated_at = T_OLD }))
store:upsertBook(book({ hash = "t1", title = "Hobbit", group_name = "Fantasy/Tolkien", cloud_present = 1, updated_at = T_MID }))
store:upsertBook(book({ hash = "t2", title = "Silmarillion", group_name = "Fantasy/Tolkien", cloud_present = 1, updated_at = T_RECENT }))
store:upsertBook(book({ hash = "l1", title = "FotR", group_name = "Fantasy/Tolkien/LOTR", cloud_present = 1, updated_at = T_RECENT }))
store:upsertBook(book({ hash = "s1", title = "Dune", group_name = "SciFi", cloud_present = 1, updated_at = T_OLD }))
store:upsertBook(book({ hash = "u1", title = "Loose", cloud_present = 1, updated_at = T_OLD }))
end)
after_each(function() store:close() end)
it("at root: returns top-level segments with aggregate counts and per-sort latest_*", function()
local groups = store:listBookshelfGroups("group_name", nil)
assert.are.equal(2, #groups) -- Fantasy + SciFi (Loose has no group)
local by_name = {}
for _, g in ipairs(groups) do by_name[g.display_name] = g end
-- Fantasy aggregates Fantasy + Fantasy/Tolkien*2 + Fantasy/Tolkien/LOTR
assert.are.equal(4, by_name["Fantasy"].count)
assert.are.equal("Fantasy", by_name["Fantasy"].name)
assert.are.equal(1, by_name["SciFi"].count)
-- Per-sort aggregates: Fantasy's max updated = T_RECENT (from
-- the Tolkien/Silmarillion + LOTR rows). SciFi only has Dune
-- with T_OLD. Created defaults to T_OLD via the test factory.
assert.are.equal(T_RECENT, by_name["Fantasy"].latest_updated_at)
assert.are.equal(T_RECENT, by_name["Fantasy"].latest_last_read_at)
assert.are.equal(T_OLD, by_name["Fantasy"].latest_created_at)
assert.are.equal(T_OLD, by_name["SciFi"].latest_updated_at)
end)
it("drilled in: returns immediate children (not descendants)", function()
local groups = store:listBookshelfGroups("group_name", "Fantasy")
assert.are.equal(1, #groups) -- only "Tolkien" is a folder; Fantasy itself is a direct-child book
assert.are.equal("Tolkien", groups[1].display_name)
assert.are.equal("Fantasy/Tolkien", groups[1].name)
-- Tolkien aggregate = Tolkien*2 + Tolkien/LOTR
assert.are.equal(3, groups[1].count)
end)
it("two levels deep: returns LOTR sub-folder only", function()
local groups = store:listBookshelfGroups("group_name", "Fantasy/Tolkien")
assert.are.equal(1, #groups)
assert.are.equal("LOTR", groups[1].display_name)
assert.are.equal("Fantasy/Tolkien/LOTR", groups[1].name)
assert.are.equal(1, groups[1].count)
end)
it("at leaf folder: returns no further sub-folders", function()
local groups = store:listBookshelfGroups("group_name", "Fantasy/Tolkien/LOTR")
assert.are.equal(0, #groups)
end)
-- BookshelfItem.tsx parity: a single-segment path like "Fantasy"
-- is itself a folder; the book living at that path is a direct
-- child of that folder, not a root-level book.
it("creates a folder for a single-segment group_name", function()
local s = LibraryStore.new({ user_id = "single" })
s:upsertBook(book({ hash = "x1", title = "Conan", group_name = "Fantasy", cloud_present = 1 }))
local root_groups = s:listBookshelfGroups("group_name", nil)
assert.are.equal(1, #root_groups)
assert.are.equal("Fantasy", root_groups[1].name)
assert.are.equal(1, root_groups[1].count)
-- And the book itself isn't visible at root, only inside Fantasy
assert.are.equal(0, #s:listBookshelfBooks({}, "group_name", nil))
assert.are.equal(1, #s:listBookshelfBooks({}, "group_name", "Fantasy"))
s:close()
end)
-- BookshelfItem.tsx:43-44 — slashIndex > 0, so a leading slash
-- doesn't split. "/foo" stays as one segment named "/foo".
it("leading-slash group_name keeps the slash as part of the segment", function()
local s = LibraryStore.new({ user_id = "ls" })
s:upsertBook(book({ hash = "x1", title = "X", group_name = "/foo", cloud_present = 1 }))
local groups = s:listBookshelfGroups("group_name", nil)
assert.are.equal(1, #groups)
assert.are.equal("/foo", groups[1].name)
assert.are.equal("/foo", groups[1].display_name)
s:close()
end)
it("author/series: parent_path ignored at root, returns flat groups", function()
store:upsertBook(book({ hash = "a1", title = "F", author = "Asimov", cloud_present = 1 }))
store:upsertBook(book({ hash = "h1", title = "D", author = "Herbert", cloud_present = 1 }))
local groups = store:listBookshelfGroups("author", nil)
local names = {}
for _, g in ipairs(groups) do names[g.display_name] = true end
assert.is_true(names["Asimov"])
assert.is_true(names["Herbert"])
end)
it("author/series: returns empty when called with non-nil parent_path", function()
store:upsertBook(book({ hash = "a1", title = "F", author = "Asimov", cloud_present = 1 }))
local groups = store:listBookshelfGroups("author", "Asimov")
assert.are.equal(0, #groups)
end)
end)
-- =====================================================================
-- listBookshelfBooks: ungrouped at root, exact match when drilled in
-- =====================================================================
describe("listBookshelfBooks", function()
local store
before_each(function()
store = LibraryStore.new({ user_id = "alice" })
store:upsertBook(book({ hash = "f1", title = "Conan", group_name = "Fantasy", cloud_present = 1 }))
store:upsertBook(book({ hash = "t1", title = "Hobbit", group_name = "Fantasy/Tolkien", cloud_present = 1 }))
store:upsertBook(book({ hash = "u1", title = "Loose", author = "", cloud_present = 1 }))
store:upsertBook(book({ hash = "a1", title = "F", author = "Asimov", cloud_present = 1 }))
store:upsertBook(book({ hash = "n1", title = "NoAuth", author = "", cloud_present = 1 }))
end)
after_each(function() store:close() end)
it("group_name root: returns books with null/empty group_name", function()
local books = store:listBookshelfBooks({}, "group_name", nil)
local titles = {}
for _, b in ipairs(books) do titles[b.title] = true end
-- Loose, F, NoAuth all lack group_name; Conan/Hobbit don't appear.
assert.is_true(titles["Loose"])
assert.is_true(titles["F"])
assert.is_true(titles["NoAuth"])
assert.is_nil(titles["Conan"])
assert.is_nil(titles["Hobbit"])
end)
it("group_name drill-in: returns only direct-child books, not descendants", function()
local books = store:listBookshelfBooks({}, "group_name", "Fantasy")
assert.are.equal(1, #books)
assert.are.equal("Conan", books[1].title)
-- Hobbit is in Fantasy/Tolkien — listed by drilling further
end)
it("author root: returns books with no author", function()
local books = store:listBookshelfBooks({}, "author", nil)
local titles = {}
for _, b in ipairs(books) do titles[b.title] = true end
-- Only Loose and NoAuth have empty author; F has Asimov,
-- Conan/Hobbit default to "Anon" via the factory.
assert.is_nil(titles["F"])
assert.is_true(titles["Loose"])
assert.is_true(titles["NoAuth"])
end)
it("author drill-in: returns books matching the author", function()
local books = store:listBookshelfBooks({}, "author", "Asimov")
assert.are.equal(1, #books)
assert.are.equal("F", books[1].title)
end)
it("group_by=nil: delegates to listBooks (no ungrouped filter)", function()
local books = store:listBookshelfBooks({}, nil, nil)
assert.are.equal(5, #books)
end)
end)
-- =====================================================================
-- listBooksInGroup: feeds the group-cover composer
-- =====================================================================
describe("listBooksInGroup", function()
local store
before_each(function()
store = LibraryStore.new({ user_id = "alice" })
-- Same fixture as listBookshelfGroups: nested Fantasy tree.
store:upsertBook(book({ hash = "f1", title = "Conan", group_name = "Fantasy", cloud_present = 1, last_read_at = T_OLD, updated_at = T_OLD }))
store:upsertBook(book({ hash = "t1", title = "Hobbit", group_name = "Fantasy/Tolkien", cloud_present = 1, last_read_at = T_RECENT, updated_at = T_RECENT }))
store:upsertBook(book({ hash = "t2", title = "Silmarillion", group_name = "Fantasy/Tolkien", cloud_present = 1, last_read_at = T_MID, updated_at = T_MID }))
store:upsertBook(book({ hash = "l1", title = "FotR", group_name = "Fantasy/Tolkien/LOTR", cloud_present = 1, last_read_at = T_RECENT, updated_at = T_RECENT }))
store:upsertBook(book({ hash = "s1", title = "Dune", group_name = "SciFi", cloud_present = 1 }))
store:upsertBook(book({ hash = "a1", title = "Foundation", author = "Asimov", cloud_present = 1 }))
store:upsertBook(book({ hash = "a2", title = "Robots", author = "Asimov", cloud_present = 1 }))
store:upsertBook(book({ hash = "a3", title = "Empire", author = "Asimov", cloud_present = 1 }))
end)
after_each(function() store:close() end)
it("group_name root: includes books across the whole subtree", function()
local books = store:listBooksInGroup("group_name", "Fantasy", 4)
local titles = {}
for _, b in ipairs(books) do titles[b.title] = true end
-- All four Fantasy-subtree books visible (Conan + Hobbit + Silmarillion + FotR)
assert.is_true(titles["Conan"])
assert.is_true(titles["Hobbit"])
assert.is_true(titles["Silmarillion"])
assert.is_true(titles["FotR"])
end)
it("group_name: respects limit", function()
local books = store:listBooksInGroup("group_name", "Fantasy", 2)
assert.are.equal(2, #books)
-- Sorted by COALESCE(updated_at, last_read_at) DESC,
-- so the two T_RECENT books come first (Hobbit + FotR).
local titles = {}
for _, b in ipairs(books) do titles[b.title] = true end
assert.is_true(titles["Hobbit"] or titles["FotR"])
end)
it("author: matches just that author", function()
local books = store:listBooksInGroup("author", "Asimov", 4)
assert.are.equal(3, #books)
end)
it("group_name nested path: only its own subtree", function()
local books = store:listBooksInGroup("group_name", "Fantasy/Tolkien", 4)
local titles = {}
for _, b in ipairs(books) do titles[b.title] = true end
-- Hobbit + Silmarillion (direct) + FotR (descendant) = 3
assert.are.equal(3, #books)
assert.is_true(titles["Hobbit"])
assert.is_true(titles["Silmarillion"])
assert.is_true(titles["FotR"])
assert.is_nil(titles["Conan"]) -- direct child of Fantasy, not Tolkien
end)
end)
-- =====================================================================
-- Multi-account scoping
-- =====================================================================
describe("multi-account", function()
it("scopes books by user_id; alice's books invisible to bob", function()
local DataStorage = require("datastorage")
local path = DataStorage:getSettingsDir() .. "/multi.sqlite3"
local alice = LibraryStore.new({ user_id = "alice", db_path = path })
alice:upsertBook(book({ hash = "h1", title = "Alice's Book", cloud_present = 1 }))
assert.are.equal(1, #alice:listBooks({}))
alice:close()
local bob = LibraryStore.new({ user_id = "bob", db_path = path })
assert.are.equal(0, #bob:listBooks({}))
bob:upsertBook(book({ hash = "h1", title = "Bob's Book", cloud_present = 1 }))
assert.are.equal(1, #bob:listBooks({}))
assert.are.equal("Bob's Book", bob:listBooks({})[1].title)
bob:close()
-- Re-open as alice; her copy of h1 still says "Alice's Book"
local alice2 = LibraryStore.new({ user_id = "alice", db_path = path })
assert.are.equal("Alice's Book", alice2:listBooks({})[1].title)
alice2:close()
end)
it("scopes sync_state by user_id", function()
local DataStorage = require("datastorage")
local path = DataStorage:getSettingsDir() .. "/state.sqlite3"
local alice = LibraryStore.new({ user_id = "alice", db_path = path })
alice:setLastPulledAt(T_RECENT)
alice:close()
local bob = LibraryStore.new({ user_id = "bob", db_path = path })
assert.is_nil(bob:getLastPulledAt())
bob:setLastPulledAt(T_OLD)
assert.are.equal(T_OLD, bob:getLastPulledAt())
bob:close()
local alice2 = LibraryStore.new({ user_id = "alice", db_path = path })
assert.are.equal(T_RECENT, alice2:getLastPulledAt())
alice2:close()
end)
end)
-- =====================================================================
-- Sync state
-- =====================================================================
describe("sync state", function()
local store
before_each(function() store = LibraryStore.new({ user_id = "alice" }) end)
after_each(function() store:close() end)
it("getLastPulledAt returns nil before any setLastPulledAt", function()
assert.is_nil(store:getLastPulledAt())
end)
it("setLastPulledAt round-trips", function()
store:setLastPulledAt(T_RECENT)
assert.are.equal(T_RECENT, store:getLastPulledAt())
end)
end)
-- =====================================================================
-- parseSyncRow: pure helper, no DB access required
-- =====================================================================
describe("parseSyncRow", function()
local DUMMY_HASH = "00000000000000000000000000000000"
it("returns nil for the dummy initial-sync hash", function()
local out = LibraryStore.parseSyncRow({
book_hash = DUMMY_HASH,
title = "Dummy", format = "EPUB", author = "",
deleted_at = "2026-01-01T00:00:00Z",
})
assert.is_nil(out)
end)
it("maps snake_case fields to our internal row shape", function()
local out = LibraryStore.parseSyncRow({
book_hash = "h1",
meta_hash = "m1",
title = "Foundation",
source_title = "Foundation (Original)",
author = "Asimov",
format = "EPUB",
group_id = "gid1",
group_name = "Sci-Fi/Classics",
uploaded_at = "2026-01-01T00:00:00Z",
updated_at = "2026-02-01T00:00:00Z",
created_at = "2025-12-01T00:00:00Z",
deleted_at = nil,
})
assert.are.equal("h1", out.hash)
assert.are.equal("m1", out.meta_hash)
assert.are.equal("Foundation", out.title)
assert.are.equal("Foundation (Original)", out.source_title)
assert.are.equal("Asimov", out.author)
assert.are.equal("EPUB", out.format)
assert.are.equal("gid1", out.group_id)
assert.are.equal("Sci-Fi/Classics", out.group_name)
assert.are.equal(1, out.cloud_present)
assert.is_nil(out.deleted_at)
end)
it("converts ISO timestamps to unix ms", function()
local out = LibraryStore.parseSyncRow({
book_hash = "h1", title = "T", format = "EPUB", author = "A",
updated_at = "2026-02-01T00:00:00Z",
uploaded_at = "2026-01-01T00:00:00Z",
deleted_at = nil,
})
-- 2026-02-01T00:00:00Z = 1769904000 unix seconds
-- 2026-01-01T00:00:00Z = 1767225600 unix seconds
assert.are.equal(1769904000000, out.updated_at)
assert.are.equal(1767225600000, out.uploaded_at)
end)
it("accepts Supabase / Postgres timestamps with +HH:MM offset", function()
-- This is the format Supabase actually emits in /sync responses;
-- discovered when 1221 cloud books all came back with
-- updated_at=NULL because the parser only matched Z-suffix.
local out = LibraryStore.parseSyncRow({
book_hash = "h1", title = "T", format = "EPUB", author = "A",
updated_at = "2026-02-01T00:00:00+00:00",
uploaded_at = "2024-07-13T16:00:00.123456+00:00",
})
assert.are.equal(1769904000000, out.updated_at)
-- 2024-07-13T16:00:00.123 UTC = 1720886400.123 sec → 1720886400123 ms
assert.are.equal(1720886400123, out.uploaded_at)
end)
it("accepts non-UTC offsets and shifts back to UTC", function()
-- "+05:30" means the wall-clock is 5h30m ahead of UTC; subtract
-- to get unix epoch (which is always UTC).
local out = LibraryStore.parseSyncRow({
book_hash = "h1", title = "T", format = "EPUB", author = "A",
updated_at = "2026-02-01T05:30:00+05:30",
})
assert.are.equal(1769904000000, out.updated_at)
end)
it("accepts Postgres native (space separator, no T)", function()
local out = LibraryStore.parseSyncRow({
book_hash = "h1", title = "T", format = "EPUB", author = "A",
updated_at = "2026-02-01 00:00:00+00",
})
assert.are.equal(1769904000000, out.updated_at)
end)
it("JSON-parses metadata string and extracts series/series_index", function()
local out = LibraryStore.parseSyncRow({
book_hash = "h1", title = "T", format = "EPUB", author = "A",
updated_at = "2026-02-01T00:00:00Z",
metadata = '{"series":"Foundation","seriesIndex":1.0,"description":"hello"}',
})
assert.are.equal("Foundation", out.series)
assert.are.equal(1.0, out.series_index)
assert.is_string(out.metadata_json)
-- Round-trip the JSON and check the description survives
local json = require("json")
assert.are.equal("hello", json.decode(out.metadata_json).description)
end)
it("accepts metadata as an already-parsed table (not just a string)", function()
local out = LibraryStore.parseSyncRow({
book_hash = "h1", title = "T", format = "EPUB", author = "A",
updated_at = "2026-02-01T00:00:00Z",
metadata = { series = "Dune", seriesIndex = 2 },
})
assert.are.equal("Dune", out.series)
assert.are.equal(2, out.series_index)
end)
it("handles missing metadata gracefully", function()
local out = LibraryStore.parseSyncRow({
book_hash = "h1", title = "T", format = "EPUB", author = "A",
updated_at = "2026-02-01T00:00:00Z",
})
assert.is_nil(out.series)
assert.is_nil(out.series_index)
assert.is_nil(out.metadata_json)
end)
it("non-null deleted_at sets cloud_present=0 and preserves deleted_at", function()
local out = LibraryStore.parseSyncRow({
book_hash = "h1", title = "T", format = "EPUB", author = "A",
updated_at = "2026-02-01T00:00:00Z",
deleted_at = "2026-02-15T00:00:00Z",
})
assert.are.equal(0, out.cloud_present)
assert.are.equal(1771113600000, out.deleted_at)
assert.is_true(out._force_cloud_present)
end)
it("preserves null group_name without crashing", function()
local out = LibraryStore.parseSyncRow({
book_hash = "h1", title = "T", format = "EPUB", author = "A",
updated_at = "2026-02-01T00:00:00Z",
group_name = nil,
})
assert.is_nil(out.group_name)
end)
it("stringifies progress tuple to progress_lib JSON", function()
local out = LibraryStore.parseSyncRow({
book_hash = "h1", title = "T", format = "EPUB", author = "A",
updated_at = "2026-02-01T00:00:00Z",
progress = { 42, 250 },
})
local json = require("json")
local p = json.decode(out.progress_lib)
assert.are.equal(42, p[1])
assert.are.equal(250, p[2])
end)
end)
end)
@@ -0,0 +1,149 @@
-- localscanner_spec.lua
-- Unit-tests the pure helpers in library/localscanner.lua.
--
-- Out of scope: lightScan() and fullSidecarWalk() require live KOReader
-- modules (ReadHistory, DocSettings, FFIUtil.runInSubProcess); they're
-- exercised manually via the test matrix in docs/library-design.md.
require("spec_helper")
local lfs = require("lfs")
describe("library.localscanner", function()
local localscanner, datastorage
before_each(function()
require("spec_helper").reset()
package.loaded["library.localscanner"] = nil
localscanner = require("library.localscanner")
datastorage = require("datastorage")
end)
-- =====================================================================
-- sidecar_to_book_path: { "/foo/bar.sdr/metadata.epub.lua" → "/foo/bar.epub" }
-- =====================================================================
describe("sidecar_to_book_path", function()
it("strips .sdr/metadata.<ext>.lua and re-appends .ext to the parent", function()
assert.are.equal("/foo/bar.epub",
localscanner.sidecar_to_book_path("/foo/bar.sdr/metadata.epub.lua"))
assert.are.equal("/books/Asimov - Foundation.pdf",
localscanner.sidecar_to_book_path(
"/books/Asimov - Foundation.sdr/metadata.pdf.lua"))
assert.are.equal("/h/Dune.fb2",
localscanner.sidecar_to_book_path("/h/Dune.sdr/metadata.fb2.lua"))
end)
it("preserves multi-dot book names", function()
assert.are.equal("/x/Foo. Vol. 1.epub",
localscanner.sidecar_to_book_path("/x/Foo. Vol. 1.sdr/metadata.epub.lua"))
end)
it("returns nil for paths that aren't sidecar metadata", function()
assert.is_nil(localscanner.sidecar_to_book_path("/foo/bar.epub"))
assert.is_nil(localscanner.sidecar_to_book_path("/foo/bar.sdr/cover.png"))
assert.is_nil(localscanner.sidecar_to_book_path("/foo/random.lua"))
assert.is_nil(localscanner.sidecar_to_book_path(""))
assert.is_nil(localscanner.sidecar_to_book_path(nil))
end)
end)
-- =====================================================================
-- parse_sidecar: load a sidecar Lua file, extract partial_md5_checksum
-- + a few meta fields. Defensive against bad input.
-- =====================================================================
describe("parse_sidecar", function()
local function write(path, text)
local f = assert(io.open(path, "w"))
f:write(text)
f:close()
end
it("extracts partial_md5_checksum from a well-formed sidecar", function()
local sdir = datastorage:getSettingsDir() .. "/Foundation.sdr"
assert(lfs.mkdir(sdir))
local spath = sdir .. "/metadata.epub.lua"
write(spath, [[
return {
["partial_md5_checksum"] = "abc123def456",
["doc_props"] = {
["title"] = "Foundation",
["authors"] = "Isaac Asimov",
},
["page"] = 42,
}
]])
local out = localscanner.parse_sidecar(spath)
assert.are.equal("abc123def456", out.hash)
assert.are.equal("Foundation", out.title)
assert.are.equal("Isaac Asimov", out.author)
-- book_path computed from sidecar location
assert.are.equal(sdir:gsub("%.sdr$", ".epub"), out.file_path)
end)
it("returns nil for a sidecar without partial_md5_checksum", function()
local sdir = datastorage:getSettingsDir() .. "/Untracked.sdr"
assert(lfs.mkdir(sdir))
local spath = sdir .. "/metadata.epub.lua"
write(spath, "return { doc_props = { title = 'X' }, page = 1 }")
assert.is_nil(localscanner.parse_sidecar(spath))
end)
it("returns nil for a malformed Lua file", function()
local sdir = datastorage:getSettingsDir() .. "/Bad.sdr"
assert(lfs.mkdir(sdir))
local spath = sdir .. "/metadata.epub.lua"
write(spath, "return { -- unclosed table")
assert.is_nil(localscanner.parse_sidecar(spath))
end)
it("returns nil when the file does not exist", function()
assert.is_nil(localscanner.parse_sidecar(
datastorage:getSettingsDir() .. "/Missing.sdr/metadata.epub.lua"))
end)
it("survives a sidecar that returns a non-table", function()
local sdir = datastorage:getSettingsDir() .. "/Weird.sdr"
assert(lfs.mkdir(sdir))
local spath = sdir .. "/metadata.epub.lua"
write(spath, "return 'string instead of table'")
assert.is_nil(localscanner.parse_sidecar(spath))
end)
it("survives a sidecar that errors at load (e.g. forbidden global)", function()
local sdir = datastorage:getSettingsDir() .. "/Throws.sdr"
assert(lfs.mkdir(sdir))
local spath = sdir .. "/metadata.epub.lua"
write(spath, "error('boom'); return {}")
assert.is_nil(localscanner.parse_sidecar(spath))
end)
end)
-- =====================================================================
-- should_skip_dir: predicate for the recursive walk
-- =====================================================================
describe("should_skip_dir", function()
it("skips system / VCS / IDE dirs", function()
assert.is_true(localscanner.should_skip_dir(".git"))
assert.is_true(localscanner.should_skip_dir(".svn"))
assert.is_true(localscanner.should_skip_dir("node_modules"))
assert.is_true(localscanner.should_skip_dir(".Trash"))
assert.is_true(localscanner.should_skip_dir("$RECYCLE.BIN"))
assert.is_true(localscanner.should_skip_dir(".adobe-digital-editions"))
end)
it("skips macOS metadata dirs", function()
assert.is_true(localscanner.should_skip_dir(".Spotlight-V100"))
assert.is_true(localscanner.should_skip_dir(".fseventsd"))
end)
it("does NOT skip '.' / '..' (caller handles those)", function()
assert.is_false(localscanner.should_skip_dir("."))
assert.is_false(localscanner.should_skip_dir(".."))
end)
it("does NOT skip ordinary book directories", function()
assert.is_false(localscanner.should_skip_dir("Books"))
assert.is_false(localscanner.should_skip_dir("Sci-Fi"))
assert.is_false(localscanner.should_skip_dir("Foo.sdr")) -- the walk visits these
end)
end)
end)
@@ -0,0 +1,81 @@
-- smoke_spec.lua
-- Minimal sanity check that the busted harness + spec_helper are wired up.
-- If this passes we know:
-- 1. busted finds spec files via .busted ROOT/pattern
-- 2. spec_helper preloads run (KOReader stubs available globally)
-- 3. The lua-ljsqlite3/init shim works against an in-memory SQLite DB
-- Other spec files can then assume all of the above.
local helper = require("spec_helper")
describe("spec harness smoke test", function()
before_each(function()
helper.reset()
end)
it("runs at all", function()
assert.are.equal(4, 2 + 2)
end)
it("exposes G_reader_settings as a global", function()
assert.is_not_nil(_G.G_reader_settings)
G_reader_settings:saveSetting("hello", "world")
assert.are.equal("world", G_reader_settings:readSetting("hello"))
end)
it("reset wipes G_reader_settings between tests", function()
assert.is_nil(G_reader_settings:readSetting("hello"))
end)
it("DataStorage:getSettingsDir() returns a usable temp dir", function()
local DataStorage = require("datastorage")
local dir = DataStorage:getSettingsDir()
assert.is_string(dir)
assert.is_truthy(dir:match("^/tmp/readest%-koplugin%-spec%-"))
-- Confirm we can write into it
local f = assert(io.open(dir .. "/probe.txt", "w"))
f:write("ok")
f:close()
local r = assert(io.open(dir .. "/probe.txt", "r"))
assert.are.equal("ok", r:read("*a"))
r:close()
end)
it("opens an in-memory SQLite via the lua-ljsqlite3 shim", function()
local SQ3 = require("lua-ljsqlite3/init")
local db = SQ3.open(":memory:")
db:exec([[
CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);
INSERT INTO t (id, name) VALUES (1, 'alpha'), (2, 'beta');
]])
-- rowexec helper
assert.are.equal(2, db:rowexec("SELECT COUNT(*) FROM t"))
-- prepare/bind/step round-trip
local sel = db:prepare("SELECT name FROM t WHERE id = ?")
local row = sel:reset():bind1(1, 1):step()
assert.is_table(row)
assert.are.equal("alpha", row[1])
row = sel:reset():bind1(1, 2):step()
assert.are.equal("beta", row[1])
-- step returns nil after exhausting matches
local none = sel:reset():bind1(1, 999):step()
assert.is_nil(none)
sel:close()
db:close()
end)
it("logger stub is silently callable", function()
local logger = require("logger")
assert.has_no.errors(function()
logger.warn("a warning")
logger.info("an info")
logger.dbg("debug")
logger.err("an error")
end)
end)
end)
@@ -0,0 +1,253 @@
-- syncbooks_spec.lua
-- Pure-function tests for library/syncbooks.lua. The network-touching parts
-- (pullBooks, downloadBook, downloadCover) require Spore + httpclient +
-- NetworkMgr + UIManager.looper at require-time and aren't unit-testable;
-- they're covered by the manual matrix. These specs lock down the bits we
-- CAN exercise in isolation: the cloud fileKey / local-filename builders
-- that the UI tier depends on.
require("spec_helper")
describe("library.syncbooks", function()
local syncbooks
before_each(function()
package.loaded["library.syncbooks"] = nil
syncbooks = require("library.syncbooks")
end)
-- =====================================================================
-- build_file_key — cloud download fileKey for a book file
-- =====================================================================
describe("build_file_key", function()
it("returns the canonical {user_id}/Readest/Books/{hash}/{hash}.{ext} shape", function()
local key = syncbooks.build_file_key({
user_id = "u-alice",
hash = "abc123",
format = "EPUB",
})
assert.are.equal("u-alice/Readest/Books/abc123/abc123.epub", key)
end)
it("derives extension from format via library.exts", function()
local cases = {
EPUB = "epub", PDF = "pdf", MOBI = "mobi", AZW3 = "azw3",
CBZ = "cbz", FB2 = "fb2", TXT = "txt", MD = "md",
}
for fmt, ext in pairs(cases) do
local key = syncbooks.build_file_key({
user_id = "u",
hash = "h",
format = fmt,
})
assert.are.equal("u/Readest/Books/h/h." .. ext, key,
"wrong key for format " .. fmt)
end
end)
it("returns nil for an unknown format (caller decides what to do)", function()
local key = syncbooks.build_file_key({
user_id = "u",
hash = "h",
format = "ROT13",
})
assert.is_nil(key)
end)
it("returns nil when user_id is missing or empty", function()
assert.is_nil(syncbooks.build_file_key({ hash = "h", format = "EPUB" }))
assert.is_nil(syncbooks.build_file_key({ user_id = "", hash = "h", format = "EPUB" }))
end)
it("returns nil when hash is missing or empty", function()
assert.is_nil(syncbooks.build_file_key({ user_id = "u", format = "EPUB" }))
assert.is_nil(syncbooks.build_file_key({ user_id = "u", hash = "", format = "EPUB" }))
end)
end)
-- =====================================================================
-- build_cover_key — cloud download fileKey for a cover image
-- =====================================================================
describe("build_cover_key", function()
it("returns {user_id}/Readest/Books/{hash}/cover.png — same on R2 and S3", function()
local key = syncbooks.build_cover_key({ user_id = "u-bob", hash = "deadbeef" })
assert.are.equal("u-bob/Readest/Books/deadbeef/cover.png", key)
end)
it("returns nil with missing user_id or hash", function()
assert.is_nil(syncbooks.build_cover_key({ hash = "h" }))
assert.is_nil(syncbooks.build_cover_key({ user_id = "u" }))
assert.is_nil(syncbooks.build_cover_key({ user_id = "", hash = "h" }))
assert.is_nil(syncbooks.build_cover_key({ user_id = "u", hash = "" }))
end)
end)
-- =====================================================================
-- build_local_filename — what we save the cloud download as on disk
-- =====================================================================
-- Per design doc: local layout is FLAT (KOReader users prefer flat
-- dirs). Filename derived from source_title || title, sanitized for
-- filesystem-illegal chars. No JS-parity port required.
describe("build_local_filename", function()
it("uses source_title when present, else title", function()
assert.are.equal("Foundation.epub", syncbooks.build_local_filename({
title = "anything", source_title = "Foundation", format = "EPUB",
}))
assert.are.equal("Dune.pdf", syncbooks.build_local_filename({
title = "Dune", format = "PDF",
}))
end)
it("replaces filesystem-illegal chars with _ (FAT/NTFS-safe)", function()
assert.are.equal("Sci-Fi_Classics_Foundation.epub", syncbooks.build_local_filename({
title = "Sci-Fi/Classics/Foundation", format = "EPUB",
}))
assert.are.equal("Title_with_pipe_and_quote.epub", syncbooks.build_local_filename({
title = 'Title|with|pipe"and"quote', format = "EPUB",
}))
end)
it("strips control characters", function()
assert.are.equal("Foo_Bar.epub", syncbooks.build_local_filename({
title = "Foo\1Bar", format = "EPUB",
}))
end)
it("clamps long names (no Lua-side surprise on 1KB titles)", function()
local long = string.rep("a", 500)
local out = syncbooks.build_local_filename({ title = long, format = "EPUB" })
-- 200 byte body + ".epub" extension = 205 max
assert.is_true(#out <= 205, "filename too long: " .. #out)
assert.is_true(out:match("%.epub$") ~= nil, "extension lost")
end)
it("preserves UTF-8 (CJK + emoji) without mangling code points", function()
local out = syncbooks.build_local_filename({
title = "三体 — 第一部 📖", format = "EPUB",
})
-- Em dash is allowed; CJK + emoji preserved verbatim.
assert.is_true(out:match("三体") ~= nil)
assert.is_true(out:match("📖") ~= nil)
assert.is_true(out:match("%.epub$") ~= nil)
end)
it("returns 'book.{ext}' when title is empty/missing", function()
assert.are.equal("book.epub", syncbooks.build_local_filename({ format = "EPUB" }))
assert.are.equal("book.epub", syncbooks.build_local_filename({ title = "", format = "EPUB" }))
end)
it("returns nil for unknown format (matches build_file_key behavior)", function()
assert.is_nil(syncbooks.build_local_filename({ title = "X", format = "ROT13" }))
end)
end)
-- =====================================================================
-- _row_to_wire — converts our snake_case store row to the camelCase
-- Book the server expects on POST /sync. Locks the field mapping
-- against drift from transformBookToDB at
-- apps/readest-app/src/utils/transform.ts:66-105.
-- =====================================================================
describe("_row_to_wire", function()
it("maps snake_case row fields to camelCase wire fields", function()
local out = syncbooks._row_to_wire({
hash = "h1",
meta_hash = "m1",
format = "EPUB",
title = "Foundation",
source_title = "Foundation (Original)",
author = "Asimov",
group_id = "gid1",
group_name = "Sci-Fi",
reading_status = "reading",
created_at = 1700000000000,
updated_at = 1770000000000,
deleted_at = nil,
uploaded_at = 1750000000000,
})
assert.are.equal("h1", out.bookHash)
assert.are.equal("h1", out.hash)
assert.are.equal("m1", out.metaHash)
assert.are.equal("EPUB", out.format)
assert.are.equal("Foundation", out.title)
assert.are.equal("Foundation (Original)", out.sourceTitle)
assert.are.equal("Asimov", out.author)
assert.are.equal("gid1", out.groupId)
assert.are.equal("Sci-Fi", out.groupName)
assert.are.equal("reading", out.readingStatus)
assert.are.equal(1700000000000, out.createdAt)
assert.are.equal(1770000000000, out.updatedAt)
assert.is_nil(out.deletedAt)
assert.are.equal(1750000000000, out.uploadedAt)
end)
it("parses metadata_json back to a table for the server", function()
local out = syncbooks._row_to_wire({
hash = "h1", title = "T",
metadata_json = '{"series":"Foundation","seriesIndex":1}',
})
assert.is_table(out.metadata)
assert.are.equal("Foundation", out.metadata.series)
assert.are.equal(1, out.metadata.seriesIndex)
end)
it("parses progress_lib back to a [cur, total] tuple", function()
local out = syncbooks._row_to_wire({
hash = "h1", title = "T",
progress_lib = "[42,250]",
})
assert.is_table(out.progress)
assert.are.equal(42, out.progress[1])
assert.are.equal(250, out.progress[2])
end)
it("survives missing/malformed metadata + progress without crashing", function()
local out = syncbooks._row_to_wire({
hash = "h1", title = "T",
metadata_json = "{not valid json",
progress_lib = "garbage",
})
assert.is_nil(out.metadata)
assert.is_nil(out.progress)
end)
it("returns nil for nil input (defensive)", function()
assert.is_nil(syncbooks._row_to_wire(nil))
end)
end)
-- =====================================================================
-- resolve_collision — bumps {name}.ext → {name} (1).ext on collision
-- =====================================================================
-- Pure helper; takes a list of existing filenames + a candidate, returns
-- a non-colliding name. Real downloadBook calls lfs.attributes; this
-- helper takes a "does it exist" predicate so we can test it.
describe("resolve_collision", function()
it("returns the input when nothing collides", function()
local out = syncbooks.resolve_collision("Dune.epub", function() return false end)
assert.are.equal("Dune.epub", out)
end)
it("appends (1) on first collision", function()
local existing = { ["Dune.epub"] = true }
local out = syncbooks.resolve_collision("Dune.epub",
function(name) return existing[name] == true end)
assert.are.equal("Dune (1).epub", out)
end)
it("walks (1)..(N) until a free slot is found", function()
local existing = {}
existing["Dune.epub"] = true
existing["Dune (1).epub"] = true
existing["Dune (2).epub"] = true
local out = syncbooks.resolve_collision("Dune.epub",
function(name) return existing[name] == true end)
assert.are.equal("Dune (3).epub", out)
end)
it("handles names without an extension", function()
local out = syncbooks.resolve_collision("README",
function(name) return name == "README" end)
assert.are.equal("README (1)", out)
end)
end)
end)
+230
View File
@@ -0,0 +1,230 @@
-- spec_helper.lua
-- Loaded once by busted before any spec file. Stubs the KOReader globals
-- our library/* modules pull at require-time, then exposes a reset() so
-- each spec's `before_each` can wipe state.
--
-- Production code runs inside KOReader (LuaJIT, lots of globals available).
-- These stubs reproduce ONLY the methods the library/* modules actually
-- call. If a new module needs a new KOReader API, add a stub here.
local M = {}
-- ---------------------------------------------------------------------------
-- Resolve `require("library.foo")` and `require("spec.…")` from the koplugin
-- root, regardless of the cwd busted is invoked from.
-- ---------------------------------------------------------------------------
local lfs = require("lfs")
local script_dir = debug.getinfo(1, "S").source:match("@(.*/)")
local koplugin_root = lfs.currentdir()
if script_dir then
-- script_dir = "<koplugin_root>/spec/"
koplugin_root = script_dir:gsub("/spec/?$", "")
end
package.path = koplugin_root .. "/?.lua;"
.. koplugin_root .. "/?/init.lua;"
.. package.path
-- ---------------------------------------------------------------------------
-- SQLite shim: production code does `require("lua-ljsqlite3/init")` and
-- gets KOReader's FFI binding to libsqlite3. In tests we wrap lsqlite3complete
-- (a Lua C extension that ships the same SQLite engine) and expose the
-- subset of the lua-ljsqlite3 API the library modules use:
--
-- SQ3.open(path) -> conn
-- conn:exec(sql)
-- conn:rowexec(sql) -> first cell
-- conn:prepare(sql) -> stmt
-- conn:close()
-- stmt:bind1(idx, value)
-- stmt:bind(...) -- positional bind from varargs
-- stmt:step() -> { col1, col2, ... } or nil
-- stmt:resultset() -> rows array or nil
-- stmt:reset() -> stmt (chainable)
-- stmt:clearbind() -> stmt (chainable)
-- stmt:close()
-- ---------------------------------------------------------------------------
local sqlite = require("lsqlite3complete")
local Stmt = {}
Stmt.__index = Stmt
function Stmt:bind1(idx, value)
if value == nil then
self._stmt:bind(idx, nil)
else
self._stmt:bind(idx, value)
end
return self
end
function Stmt:bind(...)
local args = { ... }
for i = 1, select("#", ...) do
self:bind1(i, args[i])
end
return self
end
function Stmt:step()
local rc = self._stmt:step()
if rc == sqlite.ROW then
local row = {}
for i = 1, self._stmt:columns() do
row[i] = self._stmt:get_value(i - 1)
end
self._row = row
return row
elseif rc == sqlite.DONE then
self._row = nil
return nil
else
error("sqlite step failed: " .. tostring(self._stmt:error_message()))
end
end
function Stmt:resultset()
local rows = {}
while true do
local row = self:step()
if not row then break end
rows[#rows + 1] = row
end
if #rows == 0 then return nil end
return rows
end
function Stmt:reset()
self._stmt:reset()
return self
end
function Stmt:clearbind()
self._stmt:clear_bindings()
return self
end
function Stmt:close()
self._stmt:finalize()
end
local Conn = {}
Conn.__index = Conn
function Conn:exec(sql)
local rc = self._db:exec(sql)
if rc ~= sqlite.OK then
error("sqlite exec failed: " .. tostring(self._db:errmsg()) .. "\nSQL: " .. sql)
end
end
function Conn:rowexec(sql)
for row in self._db:rows(sql) do
return row[1]
end
end
function Conn:prepare(sql)
local raw = self._db:prepare(sql)
if not raw then
error("sqlite prepare failed: " .. tostring(self._db:errmsg()) .. "\nSQL: " .. sql)
end
return setmetatable({ _stmt = raw }, Stmt)
end
function Conn:close()
self._db:close()
end
local SQ3 = {}
function SQ3.open(path)
local db = sqlite.open(path)
if not db then
error("sqlite open failed: " .. tostring(path))
end
return setmetatable({ _db = db }, Conn)
end
package.preload["lua-ljsqlite3/init"] = function() return SQ3 end
-- ---------------------------------------------------------------------------
-- JSON shim: production code does `require("json")` and gets KOReader's
-- rapidjson-bound JSON module. Tests use dkjson (pure Lua) which exposes the
-- same `encode`/`decode` API.
-- ---------------------------------------------------------------------------
package.preload["json"] = function() return require("dkjson") end
-- ---------------------------------------------------------------------------
-- KOReader stubs: a small registry of fakes that production modules require.
-- Each module is exposed via `package.preload` so the first `require()`
-- returns the fake. `M.reset()` rebuilds the in-memory state.
-- ---------------------------------------------------------------------------
local fakes = {}
local function tmpdir()
local tpl = "/tmp/readest-koplugin-spec-XXXXXX"
local p = io.popen("mktemp -d " .. tpl)
if not p then error("mktemp failed") end
local dir = p:read("*l")
p:close()
return dir
end
local function rmrf(path)
if not path or path == "" or path == "/" then return end
os.execute("rm -rf " .. ("'" .. path:gsub("'", "'\\''") .. "'"))
end
-- The fake objects are STABLE table identities: spec_helper.reset() mutates
-- their internals in place rather than reassigning the table reference.
-- This matters because `package.preload[name]` is only consulted on the
-- first `require(name)` call; subsequent requires return the cached table.
-- If reset() reassigned fakes.DataStorage = {…}, modules that captured the
-- earlier require result would still see the previous tmpdir (already
-- rm -rf'd by reset). In-place mutation keeps the captured reference live.
fakes.logger = {
warn = function() end,
info = function() end,
dbg = function() end,
err = function() end,
}
fakes.G_reader_settings = {
_store = {},
readSetting = function(self, k) return self._store[k] end,
saveSetting = function(self, k, v) self._store[k] = v end,
flush = function() end,
}
fakes.DataStorage = {
_dir = nil,
getSettingsDir = function(self) return self._dir end,
getDataDir = function(self) return self._dir end,
}
fakes.Device = {
canUseWAL = function() return true end,
screen = { getWidth = function() return 600 end, getHeight = function() return 800 end },
}
function M.reset()
if fakes.DataStorage._dir then rmrf(fakes.DataStorage._dir) end
fakes.DataStorage._dir = tmpdir()
-- Wipe the settings store but keep the table identity stable
for k in pairs(fakes.G_reader_settings._store) do
fakes.G_reader_settings._store[k] = nil
end
_G.G_reader_settings = fakes.G_reader_settings
end
package.preload["logger"] = function() return fakes.logger end
package.preload["datastorage"] = function() return fakes.DataStorage end
package.preload["device"] = function() return fakes.Device end
-- ---------------------------------------------------------------------------
-- Run reset() once at load so the first spec doesn't crash on missing globals.
-- Each spec file is responsible for calling spec_helper.reset() in before_each
-- if it mutates state across tests.
-- ---------------------------------------------------------------------------
M.reset()
-- Make spec_helper itself accessible from spec files via require("spec_helper")
package.preload["spec_helper"] = function() return M end
return M
+10 -2
View File
@@ -214,11 +214,19 @@ function SyncAnnotations:pull(ui, settings, client, book_hash, meta_hash, dialog
book = book_hash,
meta_hash = meta_hash,
},
function(success, response)
function(success, response, status)
if not success then
-- Treat HTTP 401/403 as auth failure regardless of body shape
-- so a future server tweak to the error string doesn't
-- silently turn relogin into "Failed to pull annotations"
-- noise (codex round 1 finding 15).
local is_auth_fail = status == 401 or status == 403
or (response and response.error == "Not authenticated")
if interactive then
UIManager:show(InfoMessage:new{
text = _("Failed to pull annotations"),
text = is_auth_fail
and _("Authentication failed, please login again")
or _("Failed to pull annotations"),
timeout = 2,
})
end
+38
View File
@@ -33,6 +33,44 @@ function SyncAuth:tryRefreshToken(settings, path)
end
end
-- Block-style wrapper around tryRefreshToken: runs the refresh (if needed)
-- and invokes `callback(ok, err)` after the new token is committed to
-- settings, OR immediately with ok=true if the token is still fresh.
--
-- Codex round 1 finding 14: ensureClient() in main.lua kicks off a refresh
-- and immediately builds a Spore client with whatever token was already in
-- settings — racy. New library API calls (pullBooks, getDownloadUrl) MUST go
-- through this wrapper so the request body never carries a stale Bearer
-- header.
function SyncAuth:withFreshToken(settings, path, callback)
-- Token still has > 50% TTL remaining: nothing to do.
if not settings.refresh_token or not settings.expires_at
or settings.expires_at >= os.time() + (settings.expires_in or 0) / 2 then
if callback then callback(true) end
return
end
local client = self:getSupabaseAuthClient(settings, path)
if not client then
if callback then callback(false, "no auth client") end
return
end
client:refresh_token(settings.refresh_token, function(success, response)
if success then
settings.access_token = response.access_token
settings.refresh_token = response.refresh_token
settings.expires_at = response.expires_at
settings.expires_in = response.expires_in
G_reader_settings:saveSetting("readest_sync", settings)
if callback then callback(true) end
else
logger.err("ReadestSync: Token refresh failed:", response or "Unknown error")
if callback then callback(false, response and response.msg or "refresh failed") end
end
end)
end
function SyncAuth:getSupabaseAuthClient(settings, path)
if not settings.supabase_url or not settings.supabase_anon_key then
return nil
+8 -2
View File
@@ -233,9 +233,15 @@ function SyncConfig:pull(ui, settings, client, book_hash, meta_hash, interactive
book = book_hash,
meta_hash = meta_hash,
},
function(success, response)
function(success, response, status)
if not success then
if response and response.error == "Not authenticated" then
-- Auth failure: server returns HTTP 403 with body
-- {error="Not authenticated"} per apps/readest-app/src/pages/api/sync.ts:31.
-- Check the status code primarily so future endpoints with
-- different body shapes still trigger relogin (codex finding).
local is_auth_fail = status == 401 or status == 403
or (response and response.error == "Not authenticated")
if is_auth_fail then
if interactive then
UIManager:show(InfoMessage:new{
text = _("Authentication failed, please login again"),
+2
View File
@@ -4,7 +4,9 @@
"repository": "readest/readest",
"scripts": {
"test": "pnpm --filter @readest/readest-app test",
"test:lua": "pnpm --filter @readest/readest-app test:lua",
"lint": "pnpm --filter @readest/readest-app lint",
"lint:lua": "pnpm --filter @readest/readest-app lint:lua",
"tauri": "pnpm --filter @readest/readest-app tauri",
"dev-web": "pnpm --filter @readest/readest-app dev-web",
"prepare": "husky",