From ed8956e9eddea11247237e173d764f833d8d3aa3 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Tue, 5 May 2026 05:12:34 +0800 Subject: [PATCH] feat(koplugin): Readest Library view in KOReader (#4056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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 /readest_covers/.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) * 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) * 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) * 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 .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) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/pull-request.yml | 63 +- .github/workflows/release.yml | 13 +- .../readest-app/.claude/rules/verification.md | 9 +- apps/readest-app/package.json | 3 +- apps/readest.koplugin/.busted | 9 + apps/readest.koplugin/docs/library-design.md | 1035 +++++++++++++++ .../readest.koplugin/icons/cloud_download.svg | 4 + apps/readest.koplugin/icons/cloud_upload.svg | 4 + apps/readest.koplugin/library/bim_patch.lua | 216 ++++ .../readest.koplugin/library/cloud_covers.lua | 213 ++++ apps/readest.koplugin/library/cloud_icons.lua | 88 ++ .../library/coverprovider.lua | 116 ++ apps/readest.koplugin/library/exts.lua | 21 + .../readest.koplugin/library/group_covers.lua | 203 +++ apps/readest.koplugin/library/libraryitem.lua | 141 ++ .../readest.koplugin/library/librarypaint.lua | 47 + .../readest.koplugin/library/librarystore.lua | 751 +++++++++++ .../library/libraryviewmenu.lua | 171 +++ .../library/librarywidget.lua | 1129 +++++++++++++++++ apps/readest.koplugin/library/list_strip.lua | 166 +++ .../readest.koplugin/library/localscanner.lua | 265 ++++ apps/readest.koplugin/library/syncbooks.lua | 891 +++++++++++++ .../locales/ar/translation.po | 156 ++- .../locales/bn/translation.po | 156 ++- .../locales/bo/translation.po | 156 ++- .../locales/de/translation.po | 156 ++- .../locales/el/translation.po | 156 ++- .../locales/es/translation.po | 156 ++- .../locales/fa/translation.po | 156 ++- .../locales/fr/translation.po | 156 ++- .../locales/he/translation.po | 156 ++- .../locales/hi/translation.po | 156 ++- .../locales/hu/translation.po | 156 ++- .../locales/id/translation.po | 156 ++- .../locales/it/translation.po | 156 ++- .../locales/ja/translation.po | 156 ++- .../locales/ko/translation.po | 156 ++- .../locales/ms/translation.po | 156 ++- .../locales/nl/translation.po | 156 ++- .../locales/pl/translation.po | 156 ++- .../locales/pt/translation.po | 156 ++- .../locales/ro/translation.po | 156 ++- .../locales/ru/translation.po | 156 ++- .../locales/si/translation.po | 156 ++- .../locales/sl/translation.po | 156 ++- .../locales/sv/translation.po | 156 ++- .../locales/ta/translation.po | 156 ++- .../locales/th/translation.po | 156 ++- .../locales/tr/translation.po | 156 ++- .../locales/uk/translation.po | 156 ++- .../locales/vi/translation.po | 156 ++- .../locales/zh-CN/translation.po | 156 ++- .../locales/zh-TW/translation.po | 156 ++- apps/readest.koplugin/main.lua | 378 +++++- apps/readest.koplugin/readest-sync-api.json | 31 + apps/readest.koplugin/readestsync.lua | 101 +- .../scripts/build-koplugin.mjs | 136 ++ apps/readest.koplugin/scripts/extract-i18n.js | Bin 9529 -> 10099 bytes .../scripts/lint-koplugin.mjs} | 27 +- .../scripts/test-koplugin.mjs | 112 ++ apps/readest.koplugin/spec/README.md | 69 + .../spec/library/coverprovider_spec.lua | 55 + .../spec/library/exts_spec.lua | 35 + .../spec/library/librarystore_spec.lua | 870 +++++++++++++ .../spec/library/localscanner_spec.lua | 149 +++ .../spec/library/smoke_spec.lua | 81 ++ .../spec/library/syncbooks_spec.lua | 253 ++++ apps/readest.koplugin/spec/spec_helper.lua | 230 ++++ apps/readest.koplugin/syncannotations.lua | 12 +- apps/readest.koplugin/syncauth.lua | 38 + apps/readest.koplugin/syncconfig.lua | 10 +- package.json | 2 + 72 files changed, 12553 insertions(+), 430 deletions(-) create mode 100644 apps/readest.koplugin/.busted create mode 100644 apps/readest.koplugin/docs/library-design.md create mode 100644 apps/readest.koplugin/icons/cloud_download.svg create mode 100644 apps/readest.koplugin/icons/cloud_upload.svg create mode 100644 apps/readest.koplugin/library/bim_patch.lua create mode 100644 apps/readest.koplugin/library/cloud_covers.lua create mode 100644 apps/readest.koplugin/library/cloud_icons.lua create mode 100644 apps/readest.koplugin/library/coverprovider.lua create mode 100644 apps/readest.koplugin/library/exts.lua create mode 100644 apps/readest.koplugin/library/group_covers.lua create mode 100644 apps/readest.koplugin/library/libraryitem.lua create mode 100644 apps/readest.koplugin/library/librarypaint.lua create mode 100644 apps/readest.koplugin/library/librarystore.lua create mode 100644 apps/readest.koplugin/library/libraryviewmenu.lua create mode 100644 apps/readest.koplugin/library/librarywidget.lua create mode 100644 apps/readest.koplugin/library/list_strip.lua create mode 100644 apps/readest.koplugin/library/localscanner.lua create mode 100644 apps/readest.koplugin/library/syncbooks.lua create mode 100755 apps/readest.koplugin/scripts/build-koplugin.mjs rename apps/{readest-app/scripts/lint-koplugin.js => readest.koplugin/scripts/lint-koplugin.mjs} (67%) create mode 100644 apps/readest.koplugin/scripts/test-koplugin.mjs create mode 100644 apps/readest.koplugin/spec/README.md create mode 100644 apps/readest.koplugin/spec/library/coverprovider_spec.lua create mode 100644 apps/readest.koplugin/spec/library/exts_spec.lua create mode 100644 apps/readest.koplugin/spec/library/librarystore_spec.lua create mode 100644 apps/readest.koplugin/spec/library/localscanner_spec.lua create mode 100644 apps/readest.koplugin/spec/library/smoke_spec.lua create mode 100644 apps/readest.koplugin/spec/library/syncbooks_spec.lua create mode 100644 apps/readest.koplugin/spec/spec_helper.lua diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 0ffbc525..8519fb46 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd00f623..3533ea04 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/apps/readest-app/.claude/rules/verification.md b/apps/readest-app/.claude/rules/verification.md index 6d23c130..13af44e8 100644 --- a/apps/readest-app/.claude/rules/verification.md +++ b/apps/readest-app/.claude/rules/verification.md @@ -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) diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index d15250be..3b9c3489 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -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", diff --git a/apps/readest.koplugin/.busted b/apps/readest.koplugin/.busted new file mode 100644 index 00000000..3c4de3a3 --- /dev/null +++ b/apps/readest.koplugin/.busted @@ -0,0 +1,9 @@ +return { + default = { + ROOT = { "spec" }, + pattern = "_spec", + helper = "spec/spec_helper", + verbose = true, + ["recursive-tests"] = true, + }, +} diff --git a/apps/readest.koplugin/docs/library-design.md b/apps/readest.koplugin/docs/library-design.md new file mode 100644 index 00000000..b3b3dffe --- /dev/null +++ b/apps/readest.koplugin/docs/library-design.md @@ -0,0 +1,1035 @@ +# Readest Library View for `readest.koplugin` (v1) + +## Context + +`apps/readest.koplugin` today is a **sync-only** plugin: when a book is open in +KOReader, it pushes/pulls reading progress and annotations to/from the Readest +sync API and that's it. There is no concept of a "library" — the user has to +discover and open books via KOReader's stock FileManager, and any books that +exist in their Readest cloud account (uploaded from the web/desktop app) are +invisible inside KOReader. + +We want a **Library view** that mirrors Readest's web/desktop library +(`apps/readest-app/src/app/library`), so a KOReader user can browse, search, +group, sort, and open all of their books — both files already on disk and books +that live only in Readest cloud — from inside the plugin. + +The intended outcome: + +1. A first-class library entry point inside the existing **Readest** plugin menu. +2. Books from Readest cloud merge cleanly with local KOReader books (deduped via + the partial-md5 hash that both sides already use — proven by the existing + progress/notes sync, which already round-trips this hash with the backend). +3. View-menu controls match Readest's web UI. +4. Cloud-only books are downloadable on tap. +5. Storage backed by SQLite for fast queries on large libraries. + +This plan was reviewed by codex (see GSTACK REVIEW REPORT at the bottom) and +revised to address 24 findings. The final design below reflects those fixes. + +--- + +## Architecture overview + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ UI layer (Zen UI Menu+mixin pattern) │ +│ │ +│ librarywidget.lua — extends KOReader's `Menu`; mixes in │ +│ CoverMenu.updateItems + CoverMenu.onCloseWidget │ +│ and either MosaicMenu._recalculateDimen + │ +│ MosaicMenu._updateItemsBuildUI (grid mode) OR │ +│ ListMenu._recalculateDimen + ListMenu._updateItemsBuildUI │ +│ (list mode); drives `item_table` from LibraryStore. │ +│ Adds search bar, view-menu button, group breadcrumb. │ +│ │ +│ libraryitem.lua — subclasses MosaicMenuItem and ListMenuItem │ +│ to handle cloud-only entries (skip BookInfoManager call, │ +│ use cached cover_path or FakeCover) AND to overlay the │ +│ cloud-up/down badge. Substantive (~150 LOC) — codex round 2 │ +│ flagged that thin badge-only patching wouldn't work because │ +│ MosaicMenuItem assumes entry.file → BIM:getBookInfo. │ +│ │ +│ librarypaint.lua — partial-page-repaint shim adapted from │ +│ zen_ui partial_page_repaint.lua: forces a full-waveform │ +│ e-ink refresh when last page has < perpage items. │ +│ │ +│ libraryviewmenu.lua — ButtonDialog: View/Columns/Cover/ │ +│ Group/Sort/Rescan/Download-folder. │ +└──────────────┬───────────────────────────────────────────────────┘ + │ loads full item_table; Menu's perpage chunks render +┌──────────────▼───────────────────────────────────────────────────┐ +│ Library service librarystore.lua │ +│ - SQLite-backed book index │ +│ - listBooks(filters) → all matching rows. │ +│ 4000 rows × ~120 bytes ≈ 500KB. Fine in │ +│ memory; data-side windowing dropped │ +│ (codex round 2: Menu computes page count │ +│ from #item_table, not external total) │ +│ - getGroups(groupBy) — cached │ +│ - upsertBook (merges cloud + local on hash) │ +│ - parseSyncRow(dbRow) — snake_case → schema │ +└──────────────┬───────────────────────────────────────────────────┘ + │ feeds +┌──────────────▼─────────────┐ ┌─────────────────────────────────┐ +│ syncbooks.lua │ │ localscanner.lua │ +│ - GET /sync?type=books │ │ - ReadHistory entries │ +│ (incremental, max-ts) │ │ - **/.sdr/ sidecar walk via │ +│ - GET /storage/download │ │ dismissableRunInSubprocess │ +│ (fileKey = │ │ (cancellable; matches │ +│ {user_id}/Readest/ │ │ KOReader's own pattern in │ +│ Books/{hash}/{hash}.ext)│ │ filemanagerfilesearcher.lua) │ +│ - server fallback │ │ - reads partial_md5_checksum │ +│ resolves R2 deployments │ │ from each sidecar; never │ +│ by extension │ │ hashes on demand │ +└────────────────────────────┘ └─────────────────────────────────┘ +``` + +**Init-time signature checks** (eng-review fix): on plugin init, verify the +expected mixin surfaces exist on `MosaicMenu`/`CoverMenu`/`ListMenu`: + +```lua +local function check_renderer_compat() + local ok_cm, CoverMenu = pcall(require, "covermenu") + local ok_mm, MosaicMenu = pcall(require, "mosaicmenu") + local ok_lm, ListMenu = pcall(require, "listmenu") + if not (ok_cm and ok_mm and ok_lm) then return false, "missing-modules" end + local needed = { + {CoverMenu, "updateItems"}, {CoverMenu, "onCloseWidget"}, + {MosaicMenu, "_recalculateDimen"}, {MosaicMenu, "_updateItemsBuildUI"}, + {ListMenu, "_recalculateDimen"}, {ListMenu, "_updateItemsBuildUI"}, + } + for _, n in ipairs(needed) do + if type(n[1][n[2]]) ~= "function" then + return false, "missing-method:" .. n[2] + end + end + return true +end +``` + +If the check fails, log loudly via `logger.warn` and fall back to a plain +`Menu` render with FakeCover-only items (still usable, no covers). Loud +failure mode — never a silent break when KOReader bumps internal API. + +**Renderer smoke test** (codex round 2 fix for shallow signature checks): +after the method-existence check passes, run a 1-item dry render in an +off-screen `Menu` instance with one synthetic `entry = {file = "/tmp/x.epub", +text = "X", is_file = true}` and one `cloud_only` entry. Catch any error in a +`pcall`; if either render fails, the renderer is incompatible — fall back to +plain Menu + FakeCover. Catches contract drift in `item_table` shape, entry +fields, or item-class internals beyond what method-existence can detect. + +**Renderer reuse pattern (validated via zen_ui.koplugin source at +`/Users/chrox/dev/koreader-plugins/zen_ui.koplugin/modules/filebrowser/patches/group_view.lua:62-82`):** + +```lua +local CoverMenu = require("covermenu") +local MosaicMenu = require("mosaicmenu") -- or ListMenu + +menu.updateItems = CoverMenu.updateItems +menu.onCloseWidget = CoverMenu.onCloseWidget +menu.nb_cols_portrait = settings.library_columns or 3 +menu.nb_rows_portrait = settings.library_rows or 3 +menu.nb_cols_landscape = settings.library_columns_landscape or 4 +menu.nb_rows_landscape = settings.library_rows_landscape or 2 +menu.files_per_page = nil -- Menu computes from rows*cols +menu.display_mode_type = "mosaic" +menu._recalculateDimen = MosaicMenu._recalculateDimen +menu._updateItemsBuildUI = MosaicMenu._updateItemsBuildUI +``` + +This collapses the original plan's `librarygrid.lua` (windowed renderer) into +zero new code — KOReader's `Menu` widget already does perpage windowing, page +navigation, item construction, and click dispatch. We add only the things +KOReader doesn't already provide: badge overlay, partial-page repaint, +SQLite-backed `item_table` population, and the search/view-menu chrome. + +**Cover handling**: hard-dependency on KOReader's bundled `coverbrowser.koplugin` +(established Zen UI pattern — verified at plugin init via `pcall(require, +"covermenu")` per `zen_ui/.../coverbrowser_check.lua`; one-time ConfirmBox if +absent — offer to enable from `plugins_disabled` settings, else FakeCover for +everything). For local books call `BookInfoManager:getBookInfo(filepath, true)`; +for missing covers fire `extractInBackground{}` (throttled to N=4 concurrent). +For cloud-only books, download `cover.png` from storage to +`/readest_covers/{hash}.png`, render via +`ImageWidget{file=path}`. After a cloud book gets downloaded, BIM extracts the +cover from the local file on next view (replacing the downloaded cover). + +--- + +## Files to add + +| File | Purpose | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/readest.koplugin/library/librarystore.lua` | SQLite open/migrate, `listBooks(filters)` (returns full match set; render windowing happens in Menu), `getGroups(by)`, `upsertBook(row)`, `parseSyncRow(dbRow)` (snake_case → schema, JSON-parses metadata, filters dummy hash) | +| `apps/readest.koplugin/library/syncbooks.lua` | `pullBooks(since, cb)` → `GET /sync?type=books`; `getDownloadUrl(fileKey, cb)` → `GET /storage/download`; `downloadBook(book, cb)` and `downloadCover(book, cb)` build R2-style fileKeys (see "Cloud download") | +| `apps/readest.koplugin/library/localscanner.lua` | Enumerate ReadHistory entries that still exist + walk `home_dir/**/.sdr/` directories for sidecars containing `partial_md5_checksum`. Never compute hashes on demand. | +| `apps/readest.koplugin/library/coverprovider.lua` | Wrapper around `BookInfoManager:getBookInfo` (local) + cloud cover download cache; coverbrowser presence check at init (offers to enable if `plugins_disabled` contains it, else FakeCover for everything) | +| `apps/readest.koplugin/library/librarywidget.lua` | Top-level full-screen view. Constructs a vanilla `Menu` and method-mixes in `CoverMenu` + `MosaicMenu`/`ListMenu` per zen_ui's `group_view.lua` pattern. Owns the search bar widget, view-menu button, group breadcrumb. Drives `item_table` from `LibraryStore:listBooks(filters)`. | +| `apps/readest.koplugin/library/libraryitem.lua` | **Substantive (~150 LOC)** — subclasses `MosaicMenuItem` and `ListMenuItem`. Detects `entry.cloud_only=true` and: (a) skips `BookInfoManager:getBookInfo` call (which would fail on a non-existent filepath), (b) renders cover from `entry.cover_path` via `ImageWidget{file=path}` if cached, else `FakeCover` placeholder, (c) overlays cloud-up/down badge using paintRect technique from `zen_ui/.../browser_cover_badges.lua:42-110`. For local entries (`entry.cloud_only=false`), defers to the parent class's BIM-driven path with the same badge overlay added on top. | +| `apps/readest.koplugin/library/librarypaint.lua` | Partial-page repaint shim adapted from `zen_ui/.../partial_page_repaint.lua`. Hooks our menu's `updateItems` to schedule a `UIManager:setDirty(nil, "full")` + `forceRePaint` on the next tick when `items_on_page < perpage`, eliminating e-ink ghost rows. | +| `apps/readest.koplugin/library/libraryviewmenu.lua` | `ButtonDialog` with sections: View Mode, Columns (per orientation), Cover Fit, Group By, Sort By, Rescan, Download Folder. | +| `apps/readest.koplugin/library/exts.lua` | `EXTS` table — `EPUB→epub`, `PDF→pdf`, `MOBI→mobi`, `AZW→azw`, `AZW3→azw3`, `CBZ→cbz`, `FB2→fb2`, `FBZ→fbz`, `TXT→txt`, `MD→md`. Verbatim copy from `apps/readest-app/src/libs/document.ts`. | + +**Removed from earlier draft** (per codex round 2): + +- ~~`safefilename.lua`~~ — not needed. The cloud `fileKey` we send is `{user_id}/Readest/Books/{hash}/{hash}.{ext}` (S3-style; the filename middle is irrelevant because the server's `processFileKeys` fallback at `apps/readest-app/src/pages/api/storage/download.ts:99-107` matches by `(book_hash, file_key endsWith .ext)`). For the **local** download filename we still want something readable, but it's a trivial 5-line helper inlined in `syncbooks.lua` (`name:gsub('[<>:|"?*\x00-\x1F/\\]', '_')`) — no JS-parity port required. + +### Test harness (new in v1) + +The plugin has no test infrastructure today (only `extract-i18n.js` / +`apply-translations.js` scripts). v1 brings up a **busted** harness — but +**scoped narrowly** (codex round 2 fix): only pure functions and the SQLite +store layer get tested. Network/UI/Device modules pull KOReader globals at +require-time and would explode the stub surface, so they stay as +manual-tested only. + +| File | Purpose | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/readest.koplugin/spec/spec_helper.lua` | KOReader stub loader for the modules we DO test: stubs `logger`, `G_reader_settings` (in-memory table), `DataStorage:getSettingsDir()` (per-test `mktemp -d`), `lua-ljsqlite3` (real binding loaded against `:memory:`). Sets `package.path` so `require("library.foo")` works. | +| `apps/readest.koplugin/spec/library/parsesync_spec.lua` | `parseSyncRow` — dummy hash filter, metadata-as-string vs metadata-as-table, ISO→unix, null group_name, `deleted_at` → `cloud_present=0` mapping. | +| `apps/readest.koplugin/spec/library/exts_spec.lua` | `EXTS` mapping completeness vs the 10 documented formats. | +| `apps/readest.koplugin/spec/library/librarystore_spec.lua` | Schema migration from `user_version=0`, `upsertBook` cloud+local merge, `listBooks` filters/sort, `getGroups` cache invalidation, multi-account scoping (insert as user A, query as user B → empty). Uses real `:memory:` SQLite. | +| `apps/readest.koplugin/spec/library/filekey_spec.lua` | Pure-function tests for the cloud `fileKey` builder in `syncbooks.lua` (extracted as a pure helper specifically for testability). Asserts shape `{user_id}/Readest/Books/{hash}/{hash}.{ext}` for each format. | +| `apps/readest.koplugin/.busted` | Busted runner config (`return { default = { ROOT = {"spec"} } }`). | + +**Removed from earlier draft** (codex round 2 — stub surface explodes): + +- ~~`spec/library/safefilename_spec.lua`~~ — no JS-parity port to test. +- ~~`spec/library/syncbooks_spec.lua`~~ — full syncbooks would need stubs for Spore, httpclient, NetworkMgr, withFreshToken… too much. Replaced by the narrower `filekey_spec.lua` for the pure-function piece. + +**Run via:** `pnpm test:lua` — added to BOTH root `package.json` AND +`apps/readest-app/package.json` (codex round 2: paths were inconsistent). Each +script invokes `cd && busted`. Add to +`.claude/rules/verification.md` as a done-condition. + +**Install path**: `luarocks install busted --local` documented in +the koplugin README. + +## Files to modify + +| File | Change | +| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/readest.koplugin/main.lua` | Register `Library` action in `addToMainMenu` and `onDispatcherRegisterActions`; add `openLibrary()` handler. Replace `ensureClient()`'s fire-and-forget refresh with a `withFreshToken(cb)` wrapper that **awaits** refresh completion before invoking the callback. | +| `apps/readest.koplugin/syncauth.lua` | Add `withFreshToken(callback)` that triggers `tryRefreshToken` then invokes callback on completion (success or no-refresh-needed). Existing call sites migrate to it. | +| `apps/readest.koplugin/readestsync.lua` | Add `pullBooks(since, callback)` hitting `GET /sync?type=books`; add `getDownloadUrl(fileKey, callback)` hitting `GET /storage/download?fileKey=…`. Both go through `withFreshToken`. | +| `apps/readest.koplugin/readest-sync-api.json` | Add new method `pullBooks` requiring only `since` (and optional `type=books`); add `getDownloadUrl` method. **Do not change existing `pullChanges`** — it still requires `book` + `meta_hash` for per-book pull, which the existing config/notes sync uses. | +| `apps/readest.koplugin/syncconfig.lua` | Update 401/403 handling: treat HTTP 403 (not just response body string `"Not authenticated"`) as auth failure → trigger logout. Same change in `syncannotations.lua`. | +| `apps/readest.koplugin/syncannotations.lua` | Same 403 unification. | +| `apps/readest.koplugin/locales/en/translation.po` (run `node scripts/extract-i18n.js`) | New strings: "Library", "Search…", "Grid", "List", "Auto", "Columns", "Crop", "Fit", "Group by", "None", "Books", "Authors", "Series", "Groups", "Sort by", "Title", "Author", "Date Read", "Date Added", "Format", "Ascending", "Descending", "Download book", "Local only", "Cover Browser plugin required", "Rescan library", "Download folder…", etc. | +| `apps/readest-app/scripts/lint-koplugin.js` | Update path glob to **recurse** into `apps/readest.koplugin/library/**/*.lua` and `apps/readest.koplugin/spec/**/*.lua` — codex round 2 caught that the existing script (line 27) only scans top-level `*.lua`, so new code under `library/` would silently bypass luacheck. | +| `apps/readest-app/package.json` | Add `"test:lua": "cd ../readest.koplugin && busted"` script alongside existing `lint:lua`. | +| `package.json` (root) | Add `"test:lua": "pnpm --filter @readest/readest-app run test:lua"` so the documented root command works. | +| `.claude/rules/verification.md` | Add `pnpm test:lua` to the done-conditions list. | + +**No backend (`apps/readest-app`) changes are required for v1.** The existing +`/storage/download` endpoint already resolves paths transparently via the +`files` table fallback (see "Cloud download" below). + +--- + +## SQLite schema + +Single DB file at `/readest_library.sqlite3`, +opened via `lua-ljsqlite3` (the established KOReader pattern, see +`coverbrowser.koplugin/bookinfomanager.lua`). PRAGMA `journal_mode` follows +`Device:canUseWAL()`. + +```sql +CREATE TABLE IF NOT EXISTS books ( + user_id TEXT NOT NULL, -- Readest auth user.id; scopes all queries + hash TEXT NOT NULL, -- partial md5 (KOReader == Readest) + meta_hash TEXT, + title TEXT NOT NULL, + source_title TEXT, + author TEXT, + format TEXT, -- 'EPUB' | 'PDF' | ... + metadata_json TEXT, -- raw JSON from /sync; series/seriesIndex parsed lazily + series TEXT, -- denormalized from metadata_json on upsert + series_index REAL, -- denormalized from metadata_json on upsert + group_id TEXT, -- nullable; from cloud only + group_name TEXT, -- nullable; from cloud only + cover_path TEXT, -- absolute path on disk if cached + file_path TEXT, -- absolute path on disk if local + cloud_present INTEGER NOT NULL DEFAULT 0, -- 1 if seen in /sync (and not deleted) + local_present INTEGER NOT NULL DEFAULT 0, -- 1 if file_path resolves + uploaded_at INTEGER, -- cloud's uploaded_at (object exists in storage) + progress_lib TEXT, -- books.progress from /sync (JSON tuple [cur, total]) + reading_status TEXT, -- 'unread'|'reading'|'finished' + last_read_at INTEGER, -- unix ms; from ReadHistory or cloud updated_at + created_at INTEGER, -- unix ms + updated_at INTEGER, -- unix ms; max(cloud.updated_at, local mtime) + deleted_at INTEGER, -- unix ms; tombstone (cloud-side delete) + 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) +); +-- keys (per-user): +-- 'last_books_pulled_at' (unix ms) — MAX of returned updated_at|deleted_at, NOT local now +-- 'last_full_scan_at' (unix ms) — gates the 24h-throttled sidecar walk + +PRAGMA user_version = 1; +``` + +**Multi-account**: The `user_id` column scopes every query, so signing out of +account A and into account B doesn't show A's books. Local file rows +(populated by sidecar walks) are stored per active account too — when account B +is logged in and the scanner finds book X locally, it's recorded as B's book +even if the same file is also A's book. `local_present` is therefore +per-account; the underlying file is shared, but the index entries are not. On +account switch, we **do not** delete rows for the previous user — they remain +queryable if the user signs back in. Library queries always include +`WHERE user_id = ?` (the currently-authenticated user); when no user is +logged in, the Library shows a "Sign in" placeholder. + +**Composite-FK note for future child tables** (codex round 2 fix): if v1.1 +adds local `annotations` or `configs` tables that need to reference a book, +they must FK on the **composite** `(user_id, hash)`, not on `hash` alone — +hash is no longer globally unique in this schema. Document this in the +schema comment block so future contributors don't accidentally create a row +that orphans on account switch: + +```sql +-- FUTURE-PROOFING: +-- Any child table referencing books MUST use a composite FK: +-- FOREIGN KEY (user_id, book_hash) REFERENCES books(user_id, hash) +-- NOT just FOREIGN KEY (book_hash) REFERENCES books(hash) — `hash` alone +-- is not unique across users in this schema. +``` + +**Notes on schema** (responding to codex review): + +- `hash TEXT PRIMARY KEY` is safe because we **never** insert placeholder rows. + Local discovery only enumerates books that already have a real + `partial_md5_checksum` (in DocSettings sidecars or via ReadHistory). No + `'pending:'` keys. +- `metadata_json` stores the raw `metadata` JSON string from `/sync`; `series` + and `series_index` are denormalized into columns at upsert time so they're + indexable. Other metadata fields stay in `metadata_json` (read on demand). +- `progress_lib` is `books.progress` from `/sync` (a tuple-shaped JSON like + `[42, 250]`). It's distinct from KOReader's per-document reading position + and from Readest's `book_configs.progress` (xpointer). Library view shows + `progress_lib` as the progress bar; tapping a book hands off to KOReader + which uses its own DocSettings progress. +- `uploaded_at` mirrors the cloud field — its presence is a hint that storage + has the object, but the authoritative check is the `files` table on the + server (see "Cloud download"). + +`librarystore.lua` exposes: + +- `LibraryStore:listBooks(filters) -> rows[]` — returns all matching rows + for the current filter/group/sort. The Menu widget chunks them into pages + via `perpage`. (Earlier draft had `getPage(offset, limit)` for SQL + windowing — dropped per codex round 2 because Menu computes page count + from `#item_table` and can't accept an external total.) +- `LibraryStore:getGroups(groupBy) -> {{name, count, latest_updated_at}, …}` + for Authors/Series/Groups headers. Cached, invalidated on + upsert/sort/group-by change. +- `LibraryStore:upsertBook(row)` — merges by `hash` PK; **OR-merges** + `cloud_present`/`local_present` (an existing local row that gets a cloud + pull keeps `local_present=1` while gaining `cloud_present=1`). +- `LibraryStore:setLastPulledAt(ts)` / `getLastPulledAt()`. + +--- + +## Merge strategy + +The hash is the join key. KOReader's `util.partialMD5` and Readest's +`partialMD5(File)` produce the same digest — proven by the existing +progress/notes sync in `apps/readest.koplugin/syncconfig.lua` and +`syncannotations.lua`, which already round-trip +`ui.doc_settings:readSetting("partial_md5_checksum")` to `/sync` as `book_hash` +and the server matches it correctly. **No further verification required.** + +Per book row, two flags + a `deleted_at` tombstone from cloud: + +| `cloud_present` | `local_present` | `deleted_at` | Meaning | UI | +| --------------- | --------------- | ------------ | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | 1 | NULL | Synced on device | Open immediately | +| 1 | 0 | NULL | Cloud-only | Tap → download → open | +| 0 | 1 | NULL | True local-only (never uploaded) | Open immediately. Cloud-up icon = informational ("Not in cloud"). v1 does not push. | +| 0 | 1 | non-NULL | Cloud-deleted, file still on device | **Hidden from Library view; file preserved on disk** (KOReader users own their filesystem — we never delete local book files). User can still find the file via FileManager and re-open it; doing so does NOT re-add it to the Library since cloud says deleted. | +| 0 | 0 | non-NULL | Cloud delete + no local | Hidden | + +**Library list filter**: `WHERE user_id = ? AND deleted_at IS NULL AND +(cloud_present = 1 OR local_present = 1)`. The `deleted_at IS NULL` clause is +the new bit — it hides cloud-deletions even when the local file remains. + +**Why preserve the local file when cloud says deleted?** A KOReader user might +have the file in `~/Books/` from a manual import that predates Readest; cloud +deletion shouldn't touch their filesystem. The Library view stops showing the +book (since they explicitly deleted it on Readest), but the file stays where +it is and the FileManager still surfaces it. + +**Sources of `local_present=1`:** + +1. Every `ReadHistory.hist` entry whose file still exists. Read + `partial_md5_checksum` from its DocSettings. +2. **Sidecar walk**: recursively scan `home_dir/**/.sdr/` directories. Each + sidecar that contains `partial_md5_checksum` represents a book KOReader has + opened at least once. We index that file. (This catches books that have + been pruned from ReadHistory but still exist locally.) +3. We **never** enumerate raw book files that lack a sidecar. This means + freshly-copied books that have never been opened in KOReader don't appear + in the library until the user opens them once via FileManager. Acceptable + for v1 — it preserves the user's "no on-demand hashing" constraint. + +**Sources of `cloud_present=1`:** rows returned by `GET /sync?type=books`. + +When a sidecar walk finds a row whose `hash` already exists in the DB with +`cloud_present=1` and `local_present=0`, we set `local_present=1` and write +the `file_path`. That's the dedupe. + +--- + +## Sync row parsing (`parseSyncRow`) + +`/sync` returns DB-shaped (snake_case) rows, **not** the camelCase `Book` type. +This is the wire format we have to handle: + +``` +{ + user_id, id, book_hash, hash, meta_hash, + title, source_title, author, format, + metadata, -- JSON string OR object; JSON-parse if string + group_id, group_name, + uploaded_at, -- ISO timestamp string OR null + updated_at, -- ISO timestamp string + deleted_at, -- ISO timestamp string OR null + created_at, + progress -- [cur, total] tuple OR null +} +``` + +`parseSyncRow(dbRow) -> ourRow` performs: + +1. Skip if `dbRow.book_hash == "00000000000000000000000000000000"` (initial- + `since=0` dummy book emitted by `apps/readest-app/src/pages/api/sync.ts:121` + for race-condition workaround). +2. ISO-string-to-unix-ms for every timestamp. +3. JSON-parse `metadata` if it's a string. Extract `metadata.series` and + `metadata.seriesIndex` into denormalized columns; store the rest in + `metadata_json`. +4. Map `book_hash → hash`, `source_title → source_title`, `group_name → +group_name` (nullable), `uploaded_at → uploaded_at`, `updated_at → +updated_at`, `deleted_at → deleted_at`. +5. JSON-stringify `progress` tuple → `progress_lib`. +6. If `deleted_at` is non-null and ≤ now: set `cloud_present=0` (book deleted + on cloud); else `cloud_present=1`. + +--- + +## Cloud download + +The koplugin **does not** need to know R2 vs S3 storage layout. The existing +`/storage/download` endpoint at +`apps/readest-app/src/pages/api/storage/download.ts` resolves paths +transparently via a fallback in its `processFileKeys` function (lines 92-131): +when the literal `fileKey` doesn't match a row in the `files` table, the +server splits the path, extracts `(book_hash, extension)`, and queries the +`files` table by `(user_id, book_hash, file_key endsWith .ext)` to find the +real key. + +This works for **any** `fileKey` shaped like: + +``` +{user_id}/Readest/Books/{hash}/{filename}.{ext} +``` + +(5-part path containing the substring `Readest/Book` — JS `String.includes` +matches `Readest/Books` too.) + +So the koplugin constructs: + +| Asset | fileKey | +| ----------- | --------------------------------------------- | +| Book file | `{user_id}/Readest/Books/{hash}/{hash}.{ext}` | +| Cover image | `{user_id}/Readest/Books/{hash}/cover.png` | + +**Why not the R2-style `{makeSafeFilename(title)}.{ext}` filename middle?** +(codex round 2): the server's `processFileKeys` fallback at +`apps/readest-app/src/pages/api/storage/download.ts:99-107` extracts the +`(book_hash, extension)` from the 5-part path and matches against the `files` +table by **extension only** — the filename middle is never used for matching. +So sending `{hash}.{ext}` works on both R2 and S3 deployments, and we avoid +porting JS's `makeSafeFilename` to Lua (which would have UTF-16-vs-UTF-8 +truncation parity hazards per the JS suite at +`apps/readest-app/src/__tests__/utils/misc.test.ts:39,98,147`). + +Inputs the koplugin already has: + +- `user_id` — stored in `G_reader_settings.readest_sync.user_id` after auth. +- `hash` — from `book_hash` in `/sync` rows. +- `ext` — from `format` field via `EXTS` mapping (`exts.lua`). + +The **local** download filename (where we write the bytes on disk) is +separate. It uses a trivial 5-line filesystem-safe helper inlined in +`syncbooks.lua:downloadBook`: + +```lua +local function safe_local(name) + return (name or "book"):gsub('[<>:|"?*\\/\x00-\x1F]', '_'):sub(1, 200) +end +local local_filename = safe_local(book.source_title or book.title) .. "." .. ext +``` + +This protects the local filesystem and gives the user a readable name when +they browse `library_download_dir` in FileManager. No JS parity required — +the only consumer is KOReader's own filesystem. + +**Download flow** (cloud-only book tap): + +1. `withFreshToken(function() ReadestSync:getDownloadUrl(fileKey, cb) end)` +2. `httpclient` streams response to + `/{safeTitle}.{ext}` — **flat directory** (KOReader + users prefer flat layouts to nested hash dirs in their book folders). + On filename collision (different book, same title-derived filename): try + `{safeTitle} (1).{ext}`, `(2)`, `(3)` etc. up to (10). +3. Update SQLite: `file_path` = new path, `local_present` = 1. +4. `ReaderUI:showReader(file_path)`. + +**Why flat instead of `{hash}/{title}.{ext}`?** Codex round 2 noted that the +nested layout would help "reconciliation by hash on full scan" since the +hash would appear as a directory name. But (a) KOReader users browse this +folder in FileManager and see ugly hash-named directories, (b) we don't +need filesystem-derived reconciliation — we already have `file_path` in +SQLite for the happy path, and the sidecar walk's reconciliation goes via +`partial_md5_checksum` from `.sdr/` files (independent of where in the +filesystem the file lives), and (c) on tap-time recovery if the file +vanished, we just mark `local_present=0` and offer rescan. Flat +download dir wins on UX. + +Status codes the new Spore method must accept: 200, 400, 401, 403, 404 +(404 = book row exists but no downloadable object — show "Cloud copy +unavailable" message; codex caught that `uploaded_at` does NOT guarantee a +file in storage, since `/storage/download` authorizes via the `files` table). + +**Cover download** (lazy, when grid item paints and cover_path is null): + +1. `getDownloadUrl({user_id}/Readest/Books/{hash}/cover.png, cb)`. +2. Stream to `/readest_covers/{hash}.png`. +3. On 404 (no cover uploaded), set `cover_path = "_missing"` sentinel so we + don't keep retrying; render `FakeCover`. + +Cover downloads run through a global throttle (max 4 concurrent) so we don't +DDoS storage when a 4000-book grid first paints. + +--- + +## Auth refresh chain (codex finding) + +Today `ensureClient()` in `main.lua:190` calls `SyncAuth:tryRefreshToken()` +and then **immediately** builds the Spore client with whatever token is in +settings — the refresh is callback-based and may not have completed when the +client is built. New library calls would race the same way. + +Fix: introduce `SyncAuth:withFreshToken(cb)`: + +- If token is fresh (>50% TTL remaining), invoke `cb()` immediately. +- Otherwise, kick off `tryRefreshToken` and only invoke `cb()` from its + completion handler (success or no-op). +- All new library API calls (`pullBooks`, `getDownloadUrl`) and existing + config/notes pushes/pulls migrate to this wrapper. (Migrating existing call + sites is in scope; they were racy already.) + +--- + +## UI — full-screen Library widget + +Layout top-to-bottom: + +1. **Title bar** — back button + "Library" + count, view-menu button. +2. **Search bar** — `InputContainer` opens `InputDialog` on tap; query is + debounced 500ms before re-querying SQLite (matches Readest web behavior at + `LibraryHeader.tsx:66-77`). +3. **Optional group breadcrumb** — when `group_by != 'none'` and user drilled + into a group ("Authors → Asimov"). +4. **Windowed grid/list area** (`librarygrid.lua`) — only renders widgets for + the visible viewport + 1 page of buffer above and below; on scroll, recycles + widget instances. This is the e-ink perf fix — building 4000 widgets up + front would stutter for seconds. +5. **View menu** opens `LibraryViewMenu` (ButtonDialog). + +`LibraryItem` (the cell) has two render modes: + +``` +Grid mode: List mode: +┌─────────┐ ☁︎↓ ┌──┐ Title ☁︎↓ +│ cover │ │ │ Author +│ │ │ │ ████░░░░ 47% +└─────────┘ └──┘ + Title +``` + +### Cloud sync indicator + +Mirror Readest's `BookItem` icon (`apps/readest-app/src/app/library/components/BookItem.tsx:161-186`): + +| `cloud_present` | `local_present` | Icon | Tap behavior | +| --------------- | --------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | 1 | **cloud-up** | **Informational only in v1** — long-press shows "Local only — upload coming in v2". Plan acknowledges this differs from web's `!uploadedAt` semantic, since v1 doesn't push. | +| 1 | 0 | **cloud-down** | Tap on icon = same as tap on cover (download + open) | +| 1 | 1 | none | — | +| 0 | 0 | none | tombstone, hidden | + +Render via `IconWidget` if `frontend/resources/icons/` ships a cloud icon +(verify at implementation), else `TextWidget` with `"☁︎↑"`/`"☁︎↓"` glyphs in an +`OverlapGroup` over the cover. + +Cover sourced via `coverprovider.lua`: + +- Local book → `BookInfoManager:getBookInfo(file_path, true).cover_bb` +- Cloud-only with `cover_path` cached → `ImageWidget{file=cover_path}` +- Cloud-only without cache → `FakeCover` placeholder, kicks off async cover + download +- Local book missing cover → `BookInfoManager:extractInBackground{file_path}`, + `FakeCover` meanwhile + +On tap: + +- `local_present=1` → `ReaderUI:showReader(file_path)` +- `local_present=0, cloud_present=1` → `Trapper:wrap` confirm dialog → + `syncbooks.lua:downloadBook(book, cb)` → set `file_path` + `local_present=1` + → open +- `local_present=1` but file vanished → "File moved or deleted. Rescan?" + ConfirmBox; don't crash. + +--- + +## View menu + +`LibraryViewMenu` is a `ButtonDialog` with sections, persisted to +`G_reader_settings:readSetting("readest_sync")` under new keys: + +| Section | Options | Default | Setting key | +| -------------------- | -------------------------------------------------------------------- | -------------------------------------------------- | -------------------------------------------- | +| **View Mode** | Grid / List | Grid | `library_view_mode` | +| **Columns** | Auto, 2, 3, 4, 5, 6 | Auto (3 phones, 4 tablets via `Screen:getWidth()`) | `library_columns` + `library_auto_columns` | +| **Cover** | Crop / Fit | Crop | `library_cover_fit` | +| **Group by** | None / Books / Authors / Series / Groups | None | `library_group_by` | +| **Sort by** | Title / Author / Date Read / Date Added / Series / Format + Asc/Desc | Date Read, Desc | `library_sort_by` + `library_sort_ascending` | +| **Rescan library** | (action) | — | triggers full sidecar walk | +| **Download folder…** | (action) | — | opens PathChooser | + +On any change: invalidate `getGroups` cache, re-query `listBooks(filters)`, +re-assign the menu's `item_table`, call `Menu:updateItems()`. Menu rebuilds +only the visible-page widgets. + +--- + +## Sync flow + +1. **Pull** (Library open + pull-to-refresh): + - `lastPulledAt = LibraryStore:getLastPulledAt() or 0` + - `withFreshToken(function() ReadestSync:pullBooks(lastPulledAt, function(rows) … end) end)` + - For each row: `parsed = parseSyncRow(row)` (skips dummy 00000…). If + `deleted_at` set: mark `cloud_present=0` on existing row (book may still + be local). Else: `upsertBook(parsed)`. + - **Watermark**: compute + `maxTs = max over rows of max(updated_at, deleted_at)`. If `maxTs > 0`, + `setLastPulledAt(maxTs)`. **Do not** use `now` (codex finding — + misses concurrent writes; clock skew). For empty response, leave + watermark unchanged. + +2. **Local discovery** (Library open + after Rescan): + - `localscanner.lua:lightScan()`: for every existing local row, stat + `file_path`; if missing, set `local_present=0`. Pull recent + `ReadHistory.hist` deltas; for each, read DocSettings sidecar's + `partial_md5_checksum` and `upsertBook{hash, file_path, +local_present=1, last_read_at=ReadHistory time}`. + - `localscanner.lua:fullSidecarWalk()`: only on first run / explicit + Rescan / 24h gate. **If `home_dir` is unset (nil or empty), skip this + entirely** — the Library shows a one-time hint suggesting "Set a Home + folder in File Manager → top-left ⚙ → Set as Home directory" so the + scanner can discover more books. Library remains functional via + ReadHistory entries. When `home_dir` is set, walk `home_dir/**/.sdr/`, + read each sidecar, upsert books with their hash. **No on-demand + hashing.** + +3. **Download** (tap of cloud-only): see "Cloud download" section above. + +v1 does **not** push books up. Local-only books stay local; the existing +config + notes sync keeps working unchanged. + +### Download directory + +New plugin setting `library_download_dir` (stored in +`G_reader_settings.readest_sync`). First download, if unset: `PathChooser` +pre-selected at `home_dir or DataStorage:getDataDir()`. Files land at +`{library_download_dir}/{safeTitle}.{ext}` (**flat directory layout** — +KOReader users prefer flat over nested hash subdirectories when browsing +in FileManager). On filename collision (different book, same safe-derived +filename), append `(1)`, `(2)`, etc. User can change the folder later from +the view-menu's "Download folder…" action. Existing downloads stay in +their original location. + +### Scan frequency + +| Trigger | Action | Cost | +| ------------------------------ | ------------------------------------- | ------------------------ | +| Library open | Light scan + cloud pull (incremental) | O(rows) | +| Pull-to-refresh | Same | O(rows) | +| "Rescan library" (view-menu) | Sidecar walk | O(.sdr dirs in home_dir) | +| Auto-full-scan | Same, gated 24h | O(.sdr dirs in home_dir) | +| `onReaderReady` for a new file | Single-file upsert | O(1) | + +--- + +## Performance — e-ink + +Codex flagged real perf risks for 4000-book libraries on 1GHz Kindles: + +- **Render-side windowing** comes free from KOReader's `Menu` widget + (`perpage` computed from `nb_cols * nb_rows`) — only widgets for the + visible page get built/laid out. **Data-side windowing is NOT possible** + (codex round 2: Menu computes page count from `#item_table`, not external + total). We `LibraryStore:listBooks(filters)` once per + filter/sort/group change and load all matching rows into `item_table`. + 4000 rows × ~120 bytes ≈ 500KB — fine in memory; SQLite query is fast + (indexed). `Menu` then chunks render across `ceil(#item_table / perpage)` + pages. +- **Partial-page repaint** via `librarypaint.lua` (zen_ui's + `partial_page_repaint` adapted): hooks `updateItems` to schedule a + full-waveform e-ink refresh when last page has fewer items than `perpage`. + Eliminates ghost rows. +- **Debounced search** (500ms, matching Readest at `LibraryHeader.tsx:66-77`). +- **Cached group lists**: `getGroups(by)` returns memoized result; invalidated + on settings change or `upsertBook`. +- **Throttled cover extraction**: max 4 concurrent `extractInBackground` calls + via a simple Lua queue (BIM already has `_subprocesses_pids` tracking — we + just gate the enqueue side). +- **No full-table re-render on settings change**: KOReader's `Menu:updateItems` + rebuilds only the visible page, not the entire table. +- **Background sidecar walk** via `dismissableRunInSubprocess` (codex round + 2: `Trapper:wrap` is a coroutine, not a worker — it can't make `lfs.dir` + / `stat` calls cancellable between yield points and will still freeze). + `localscanner.fullSidecarWalk` follows the established KOReader pattern + at `frontend/apps/filemanager/filemanagerfilesearcher.lua:130-210`: + fork a subprocess, walk `home_dir/**/.sdr/`, write discovered + `(file_path, partial_md5_checksum)` pairs to a pipe, parent process + reads them in chunks via `Trapper:info("Scanning… N books found")`, + user can cancel via Back which kills the subprocess. Avoids freezing + the UI; gives true cancellation; no risk of stalls between Lua yield + points. Library opens immediately with whatever is already in SQLite; + newly-discovered books appear as the parent reads pipe chunks and + upserts them. + +--- + +## Reuse — what we're NOT building from scratch + +| Thing | Reused from | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Auth, JWT refresh, Bearer header | `apps/readest.koplugin/syncauth.lua` (with new `withFreshToken` wrapper) | +| HTTP/JSON middleware | `apps/readest.koplugin/readestsync.lua` Spore client | +| Partial md5 (proven equivalent) | `frontend/util.lua:1111` `util.partialMD5` | +| Cover extraction pipeline | `coverbrowser.koplugin/bookinfomanager.lua` | +| Read-history & open timestamps | `frontend/readhistory.lua` `ReadHistory.hist` | +| Open a book | `frontend/apps/reader/readerui.lua:611` `ReaderUI:showReader(file)` | +| SQLite open/migrate/PRAGMA pattern | Same as `coverbrowser.koplugin/bookinfomanager.lua` openDB | +| i18n | Existing `apps/readest.koplugin/i18n.lua` and PO catalogs | +| KOReader widgets | `ScrollableContainer`, `FrameContainer`, `VerticalGroup`, `HorizontalGroup`, `OverlapGroup`, `ImageWidget`, `TextWidget`, `IconWidget`, `IconButton`, `InputDialog`, `ButtonDialog`, `PathChooser`, `Trapper`, `UIManager` | +| Storage path resolution | Existing `/storage/download` fallback at `apps/readest-app/src/pages/api/storage/download.ts:92-131` (no backend change) | + +--- + +## What already exists (reuse table) + +| Sub-problem | Existing in repo | Plan's reuse status | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| JWT auth + refresh | `apps/readest.koplugin/syncauth.lua` | Reused; adds `withFreshToken` wrapper to fix existing race | +| Spore HTTP middleware | `apps/readest.koplugin/readestsync.lua` | Reused; adds 2 new methods (`pullBooks`, `getDownloadUrl`) | +| Partial-md5 hashing | KOReader `frontend/util.lua:1111 util.partialMD5` | Reused; never recomputed in plugin (read from sidecar) | +| Cover extraction | `coverbrowser.koplugin/bookinfomanager.lua` | Reused as hard dependency | +| Mosaic/list grid renderer | `coverbrowser.koplugin/{covermenu,mosaicmenu,listmenu}.lua` | Reused via Zen UI Menu+mixin pattern | +| Badge overlay technique | `zen_ui.koplugin/modules/filebrowser/patches/browser_cover_badges.lua` | Adapted (~20 lines for cloud icon) | +| Partial-page repaint | `zen_ui.koplugin/modules/filebrowser/patches/partial_page_repaint.lua` | Adapted (~30 lines) | +| Read history + open timestamps | `frontend/readhistory.lua ReadHistory.hist` | Reused; lightScan iterates entries | +| Open a book | `frontend/apps/reader/readerui.lua:611 ReaderUI:showReader` | Reused; called on tap | +| SQLite open/migrate pattern | `coverbrowser.koplugin/bookinfomanager.lua` | Reused (`SQ3.open`, journal_mode WAL/TRUNCATE) | +| Path picker | `frontend/ui/widget/pathchooser.lua` | Reused for `library_download_dir` setting | +| Cancellable background subprocess | `frontend/apps/filemanager/filemanagerfilesearcher.lua:130-210` `dismissableRunInSubprocess` pattern | Reused for `fullSidecarWalk` (replaces earlier draft's `Trapper:wrap` which can't actually cancel filesystem IO) | +| i18n catalog | `apps/readest.koplugin/i18n.lua` + `locales//translation.po` | Reused; new strings added via existing extract script | +| Server-side storage path resolution | `apps/readest-app/src/pages/api/storage/download.ts:92-131` `processFileKeys` fallback | Reused; no backend change needed | +| `EXTS` mapping | `apps/readest-app/src/libs/document.ts` | **Copied verbatim** to `exts.lua` | + +(Earlier-draft `makeSafeFilename` Lua port deleted — codex round 2: not needed +because the cloud fileKey we send uses `{hash}.{ext}` and the server's +`processFileKeys` fallback at `apps/readest-app/src/pages/api/storage/download.ts:99-107` +matches by `(book_hash, extension)` only. Avoids JS-vs-Lua truncation parity hazards.) + +Plan does NOT rebuild any of these; the plan adds glue + new UI shell + SQLite index only. + +--- + +## Failure modes (one-line per new codepath) + +| Codepath | Realistic failure | Test? | Error handled? | User sees? | +| ------------------------------ | ---------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------ | ----------------------------------------- | +| `syncbooks.pullBooks` | HTTP timeout on slow link | manual #18 | yes (Spore timeout 5/10s) | toast | +| `syncbooks.pullBooks` | Server returns malformed JSON metadata | busted | yes (pcall around `json.decode`) | row skipped, log warn | +| `syncbooks.getDownloadUrl` | 404 cloud copy unavailable | manual #8 | yes | toast "Cloud copy unavailable" | +| `syncbooks.downloadBook` | Disk full mid-write | manual #13 | yes (catch httpclient sink error) | toast, partial file removed | +| `syncbooks.downloadBook` | User cancels (Trapper) | manual #14 | yes | partial file removed | +| `syncbooks.downloadCover` | Cover 404 | (busted) | yes (sentinel `_missing`) | FakeCover, no retry storm | +| `localscanner.lightScan` | Stat on removable storage path → nil | manual #15 | yes (lfs.attributes returns nil → set local_present=0) | row updates silently | +| `localscanner.fullSidecarWalk` | `home_dir` is nil | busted | yes (skip + show hint) | hint banner | +| `localscanner.fullSidecarWalk` | Permission denied subdir | manual #16 | yes (pcall around lfs.dir) | log warn, continue | +| `localscanner.fullSidecarWalk` | Symlink loop | manual #19 | yes (depth cap = 8) | walk stops at depth | +| `librarystore.upsertBook` | SQLite disk full / corrupted | manual (out of v1 — KOReader-wide concern) | partial (SQ3 errors logged) | unhandled error toast (acceptable for v1) | +| `librarystore.parseSyncRow` | metadata as already-parsed table (not string) | busted | yes (type-check) | row imported successfully | +| `librarywidget.init` | MosaicMenu method missing | manual #21 | yes (init signature check) | log warn + plain Menu fallback | +| `librarywidget.init` | MosaicMenuItem contract drift (entry shape) | manual #22 | yes (smoke-test dry render in pcall) | log warn + plain Menu fallback | +| `libraryitem.lua` | cloud_only entry with no cover_path AND cover download 404 | (busted-adjacent) | yes (FakeCover with cloud-down badge) | placeholder cover, no broken image | +| `librarywidget.init` | coverbrowser plugin disabled | manual #9 | yes (one-time ConfirmBox) | enable prompt or FakeCover-only | +| `coverprovider.downloadCover` | Throttle exceeded | (busted) | yes (queued via concurrency limiter) | covers fill in over time | +| Auth | JWT expires mid-Library-session | manual #17 | yes (`withFreshToken` blocks) | seamless retry | +| Auth | Logout while Library open | manual #17 | yes (auth-state listener) | returns to Sign-in placeholder | + +**No critical gaps** (no failure mode that's silent + has no test + has no error handling). + +--- + +## Out of scope for v1 (explicit) + +- Push local-only books to Readest cloud (upload). +- Edit book metadata in koplugin. +- Manual group create/move/delete. +- Bulk-select operations. +- Tags. +- Per-book backup/restore beyond a single download. +- Background scheduled sync of the books index. +- Indexing of unopened local books (require user to open once via FileManager + to generate the DocSettings sidecar containing `partial_md5_checksum`). +- R2-style title-based remote filenames (we send `{hash}.{ext}` and rely on + the server fallback to resolve to the actual R2 file). +- Coverbrowser-disabled fallback grid (we hard-require coverbrowser; if + absent, FakeCover for everything until enabled). +- Wholesale upload of local-only books to cloud (the cloud-up icon is + informational in v1; tap does nothing actionable). +- Editing book metadata or moving between groups (read-only on cloud data). +- Background syncing on a timer (sync only fires on Library open + manual refresh). +- Multi-account simultaneous use (one user at a time; previous account's + rows persist in SQLite scoped by `user_id` for return visits). + +--- + +## Worktree parallelization + +The plan splits into three reasonably independent lanes that can be implemented in parallel worktrees once the schema + i18n strings land: + +| Step | Modules touched | Depends on | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | +| 1. Schema + Store + i18n strings + lint/test infra | `library/librarystore.lua`, `library/exts.lua`, `locales/en/translation.po`, `spec/spec_helper.lua`, `spec/library/{parsesync,exts,librarystore,filekey}_spec.lua`, `.busted`, `apps/readest-app/scripts/lint-koplugin.js` (recurse), `apps/readest-app/package.json` (test:lua), root `package.json` (test:lua proxy), `.claude/rules/verification.md` | — | +| 2. Sync layer | `library/syncbooks.lua` (with `build_file_key()` pure helper), `syncauth.lua` (`withFreshToken`), `readestsync.lua`, `readest-sync-api.json`, `syncconfig.lua` (403 fix), `syncannotations.lua` (403 fix) | step 1 (parseSyncRow + EXTS) | +| 3. Local scanner | `library/localscanner.lua` (uses `dismissableRunInSubprocess`), `library/coverprovider.lua` | step 1 (LibraryStore API) | +| 4. UI shell | `library/librarywidget.lua`, `library/libraryitem.lua` (~150 LOC subclassing MosaicMenuItem/ListMenuItem for cloud-only entries + badge overlay), `library/librarypaint.lua`, `library/libraryviewmenu.lua`, `main.lua` (menu registration + signature/smoke check) | steps 1, 2, 3 | + +Lane plan: + +- **Lane A** (sequential): step 1 → step 4. Foundation + UI. +- **Lane B** (parallel after step 1 lands): step 2. Cloud sync. Independent of scanner. +- **Lane C** (parallel after step 1 lands): step 3. Local discovery. Independent of cloud sync. + +Execution: implement step 1 first. Then launch B + C in parallel worktrees. Merge both. Then complete step 4 (UI consumes both data sources). Conflict surface = `main.lua` (menu/dispatcher hooks), updated near the end. + +For solo dev: serial implementation in the order above is also fine; parallelization only helps if multiple agents work simultaneously. + +--- + +## Verification plan + +Functional tests (manual, KOReader plugins have no headless harness): + +1. **Empty state**: fresh install + signed in + no books → empty grid + "No books". +2. **Local-only**: 5 EPUBs in home_dir, 2 opened (have sidecars) → 2 rows + appear (the 3 unopened are intentionally hidden; user can open via FM + to add them). Both have covers and last-read timestamps. +3. **Cloud sync**: account with 10 cloud books, 3 also local → 12 rows total + (10 cloud + 2 local-only of the 5 above whose sidecars are present and + whose hashes are NOT in the cloud account; the 3 dual-present rows have + file paths and `cloud_present=local_present=1`); 7 cloud-only show + download icon. **Validate `parseSyncRow` against real `/sync` JSON** — + eyeball one row to confirm `book_hash`, `meta_hash`, `metadata` (JSON + string), `group_name` (nullable), `uploaded_at`, `deleted_at` parse + correctly. Watermark advances to `max(updated_at, deleted_at)`, not `now`. +4. **Search**: type "asimov" → debounce 500ms → grid filters. +5. **Group by Authors**: tap into "Asimov" → drills into group view; books + with `groupName=null` still show under "Books" (None-grouping fallback). +6. **Sort by Date Read descending**: top row = most-recently-opened per + ReadHistory. +7. **Cover fit toggle**: Crop ↔ Fit re-renders without restart. +8. **Download flow**: tap cloud-only book → ConfirmBox → download to + `{library_download_dir}/{hash}/{title}.{ext}` → opens in reader → reload + Library → row now shows `local_present=1`. **Negative case**: simulate + `uploaded_at` set but no `files` row (404 from `/storage/download`) → + "Cloud copy unavailable" toast, row stays cloud-only. +9. **No coverbrowser**: disable coverbrowser.koplugin → first open shows + one-shot ConfirmBox → if dismissed, all books render as `FakeCover` + (acceptable degraded mode; no extraction attempted). +10. **Auth flows**: pull → JWT expiring → confirm `withFreshToken` blocks + until refresh completes before the request fires; HTTP 403 from `/sync` + triggers logout (not just the body string). +11. **Initial since=0**: first ever pull on a brand-new account → server + returns dummy `00000…` deleted book → koplugin filters it; library is + empty; watermark stays at 0 (or advances past dummy). +12. **Perf benchmark**: load a 2000-row test DB on a Kobo Clara HD or Kindle + PW3 (≤1GHz CPU). Open Library → first paint < 800ms. Scroll 100 rows → + no jank > 200ms per frame. Switch sort → re-paint < 400ms. +13. **Disk full mid-download**: pre-fill download dir to leave <1MB free, + tap a 5MB cloud-only book → `httpclient` write fails → toast "Not + enough storage", row stays `local_present=0`, no partial file left + behind. Verify temp file cleanup. +14. **User cancels mid-download**: tap cloud-only book, hit Back during the + progress dialog → `Trapper:wrap` cancellation cleans up the partial + file, row stays `local_present=0`, no zombie progress dialog. +15. **Removable storage ejected mid-scan**: home_dir on SD card; eject SD + while sidecar walk is running → `lfs.dir` errors caught, scan aborts + cleanly, no crash. Existing rows from prior scans preserved. +16. **Permission-denied subdir**: `chmod 000` a subdir of home_dir before + Rescan → walk logs warning and continues with siblings; no crash. +17. **Logout while Library is open**: open Library → swipe down to + `Readest → Sign out` → Library widget detects auth loss → returns to + "Sign in" placeholder, doesn't keep showing the previous user's data. +18. **Slow connection**: throttle network to 64 kbps; tap a 5MB cloud-only + book → progress dialog updates regularly, user can cancel via Back + button (verifies step 14 + responsiveness on slow links). +19. **Symlink loop in home_dir**: create `home_dir/loop -> .` → Rescan + walks at most N levels deep (proposed: 8) and stops; no infinite + recursion or stack overflow. + +### Renderer compatibility check + +20. **MosaicMenu signature + smoke test stable**: on KOReader release upgrade, + the init signature check passes AND the off-screen 1-item dry render of + both a synthetic local entry AND a synthetic cloud_only entry returns + without `pcall` error. Library opens in mosaic mode normally. +21. **MosaicMenu signature broken**: simulate by deleting `_recalculateDimen` + method on the loaded module → init check returns false → Library falls + back to plain `Menu` with FakeCovers, logs `logger.warn` with the + missing-method name, doesn't crash. +22. **MosaicMenuItem contract drift**: simulate by hiding `entry.is_file` → + smoke test catches that the dry render fails → Library falls back to + plain Menu, logs warning. (Catches contract drift the method-existence + check alone would miss.) + +### Pure-function unit tests (busted, must pass before manual matrix) + +```bash +pnpm test:lua # runs busted spec/library/*_spec.lua +``` + +23. All `parseSyncRow` cases pass (dummy filter, metadata-as-string vs metadata-as-table, ISO timestamps, null group_name, deleted_at mapping). +24. All `librarystore` cases pass (schema, upsert merge, multi-account scoping, listBooks filters/sort, getGroups cache invalidation). +25. All `exts` cases pass (10 formats map to expected extension). +26. All `filekey` cases pass (cloud fileKey builder produces `{user_id}/Readest/Books/{hash}/{hash}.{ext}` for each format; user_id urlencoded; collision-free across 100 random hashes). + +Required project checks (per `.claude/rules/verification.md` — extended in v1): + +```bash +pnpm lint:lua # luacheck — added in commit 754639eb +pnpm test:lua # busted — NEW in v1; runs spec/library/*_spec.lua +node scripts/extract-i18n.js # confirm new strings reach PO templates +``` + +(No JS/TS/Rust code changes in v1, so `pnpm test`, `pnpm lint`, +`pnpm fmt:check`, `pnpm clippy:check` are not in scope.) + +`.claude/rules/verification.md` should be updated in this PR to add the +`pnpm test:lua` line. + +End-to-end smoke: open KOReader on macOS dev box (or sideload to an Android +device), enable both `coverbrowser.koplugin` and `readest.koplugin`, log in to +a known test Readest account, walk steps 1–12 above. + +--- + +## GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +| ------------- | --------------------- | ----------------------- | ---- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | — | +| Codex Review | `/codex review` | Independent 2nd opinion | 2 | **ADDRESSED** | Round 1: 24 findings, all addressed in round-1 revision. Round 2: 9 findings (3 architecture-breaking on the Menu+mixin pattern + 6 medium/low), all addressed in round-2 revision. | +| Eng Review | `/plan-eng-review` | Architecture & tests | 1 | **CLEAR (revisions superseded by codex round 2)** | 5 issues raised + resolved at the time. Several decisions later contradicted by codex round 2 evidence (Menu data-windowing, makeSafeFilename Lua port, Trapper:wrap cancellability) — superseded by current plan. | +| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | — | +| DX Review | `/plan-devex-review` | Developer experience | 0 | — | — | + +**REVISION HISTORY:** + +- **v1** (initial): 9 new files, custom librarygrid renderer, deferred hashing. +- **v2** (codex round 1): drop deferred hashing, fix /sync row shape, fix watermark, add multi-account, server-fallback storage paths. +- **v3** (eng review): adopt Zen UI Menu+mixin renderer (drop librarygrid), R2 filename + makeSafeFilename Lua port, Trapper:wrap sidecar walk, busted harness with 6 spec files, 25-step manual matrix. +- **v4 — CURRENT** (codex round 2): drop makeSafeFilename Lua port (server fallback resolves by extension), drop SQL data-windowing claim (Menu uses #item_table; load full 4000 rows ≈ 500KB), MosaicMenuItem/ListMenuItem subclass becomes substantive (~150 LOC for cloud-only handling), replace Trapper:wrap with `dismissableRunInSubprocess`, narrow busted scope (drop syncbooks_spec; add filekey_spec), strengthen renderer compat with smoke-test dry render, fix lint-koplugin.js to recurse, align pnpm test:lua placement (root + apps/readest-app), add composite-FK note for future child tables. + +**CODEX ROUND 2 FINDINGS (all 9 addressed in v4):** + +| # | Severity | Finding | v4 resolution | +| --- | -------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| 1 | High | Menu computes pages from `#item_table`, SQL LIMIT/OFFSET windowing impossible | Load full item_table (500KB OK in memory); Menu's perpage chunks render only | +| 2 | High | MosaicMenuItem/ListMenuItem assume `entry.file` + call BIM, can't render cloud-only | `libraryitem.lua` substantive subclass (~150 LOC) detects `cloud_only`, skips BIM, uses cached cover_path | +| 3 | High | Signature checks miss contract drift in entry shape / item_table assumptions | Added 1-item smoke-test dry render in pcall; falls back to plain Menu if it errors | +| 4 | High | makeSafeFilename Lua port has UTF-16-vs-UTF-8 truncation parity hazards | Dropped — fileKey uses `{hash}.{ext}`; server fallback resolves R2 by extension | +| 5 | Medium | Composite PK constrains future annotation tables | Documented in schema comment block: child tables must FK on `(user_id, hash)` | +| 6 | Medium | Trapper:wrap not background work, can't cancel filesystem IO | Replaced with `dismissableRunInSubprocess` per `filemanagerfilesearcher.lua:130-210` | +| 7 | Medium | Busted scope too broad — syncauth/readestsync need huge stubs | Dropped `syncbooks_spec.lua`; replaced with narrow `filekey_spec.lua` for the pure helper | +| 8 | Medium | `lint-koplugin.js` only scans top-level `*.lua`, ignores `library/` and `spec/` | Updated to recurse | +| 9 | Low | `pnpm test:lua` placement inconsistent (root vs app) | Added in BOTH locations; root proxies to apps/readest-app | +| 10 | Low | Filename text self-contradicts (`{safeTitle}.{ext}` vs `{hash}.{ext}`) | Cleaned up; only `{hash}.{ext}` referenced now | + +**WHAT'S STILL IN SCOPE & WORKING:** + +- Codex round 1: 24 findings — all still addressed (cloud paths via server fallback, sync row shape, schema PK, watermark, /sync row parsing, coverImageUrl unused, series in metadata, group_name nullable, cloud icon semantics, auth refresh, 403 unification, Spore method, deleted-book handling, dummy-hash filter, progress shape distinction, e-ink perf, coverbrowser dependency). +- Eng review code-quality fixes still hold: home_dir-unset handling, multi-account schema scoping by user_id. +- Failure modes table updated with new entries for cloud_only rendering + smoke-test dry render. +- 26-step manual matrix (was 25; added smoke-test contract drift step #22). +- Parallelization plan adjusted for the file-list changes. + +**VERDICT:** READY TO IMPLEMENT pending user approval. + +Optional next reviews: + +- `/codex review` round 3 — validate the round-2 fixes don't introduce new issues (cheap; codex quota now refreshed). +- `/plan-design-review` — would need actual mockups; defer until implementation produces something to review. +- `/plan-ceo-review` — scope is locked through 4 review rounds; not needed. + +**Codex review v1 (issues_found, gate=fail) → revised plan addresses:** + +| # | Codex finding | Resolution in revised plan | +| --- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| 1-4 | Cloud storage path was wrong (`Books/...` vs `Readest/Books/...`, missing user_id, R2 vs S3 split) | Plugin sends `{user_id}/Readest/Books/{hash}/{hash}.{ext}` for books and `{user_id}/Readest/Books/{hash}/cover.png` for covers; existing server-side `processFileKeys` fallback at `apps/readest-app/src/pages/api/storage/download.ts:92-131` resolves both transparently via `(book_hash, extension)` lookup in the `files` table. Codex misread the fallback's substring match — `'Readest/Books'.includes('Readest/Book')` is `true`. **No backend change.** | +| 5 | Partial-md5 parity unproven | User-confirmed: proven by existing `syncconfig.lua`/`syncannotations.lua` round-tripping `partial_md5_checksum` to `/sync` as `book_hash`. Skip test-vector matrix. | +| 6 | Schema PK + `'pending:'` placeholder collides | Deferred-hashing path dropped entirely (per user). Local discovery only enumerates books with existing `partial_md5_checksum` (sidecar walk + ReadHistory). `hash TEXT PRIMARY KEY` stays clean — no placeholder rows. | +| 7 | Defer-hashing breaks dedupe | Same as 6. Trade-off: unopened local files don't appear until user opens via FileManager once. Acknowledged in "Out of scope". | +| 8 | `setLastPulledAt(now)` wrong | Use `max(returned updated_at | deleted_at)`per`apps/readest-app/src/hooks/useSync.ts:22,113`. Documented in Sync flow. | +| 9 | `/sync` returns DB-shape (snake_case), not Book objects | `parseSyncRow(dbRow)` function added in `librarystore.lua` — explicit field mapping, JSON-parses `metadata`, ISO→unix-ms timestamps. | +| 10 | `coverImageUrl` not in sync rows | User-confirmed: not needed. Local covers via BIM bb; cloud-only covers downloaded as `{hash}/cover.png` from storage. | +| 11 | `series` is inside `metadata` JSON | Denormalized into `series`/`series_index` columns at upsert; raw JSON kept in `metadata_json`. | +| 12 | `groupName` nullable | Schema column is nullable; group-by-Groups falls back to "Books" bucket for null. | +| 13 | Cloud icon semantics misaligned (`uploadedAt`/`downloadedAt` vs our flags) | Cloud-up icon repurposed in v1 as **informational** ("Local only") not actionable, with long-press tooltip noting upload arrives in v2. Documented divergence. | +| 14 | Auth refresh callback race | Added `withFreshToken(cb)` wrapper in `syncauth.lua`; all new + existing API calls migrate. | +| 15 | 401/403 inconsistency | Updated `syncconfig.lua`/`syncannotations.lua` to treat HTTP 403 (not just body string) as auth failure. | +| 16 | Spore `pullChanges` requires `book`/`meta_hash` | Adding **new** Spore method `pullBooks(since)` instead of relaxing existing `pullChanges` (existing per-book pull still needs the params). | +| 17 | Deleted book leaves local-only stale row | Documented: `cloud_present=0, local_present=1` is a valid state ("you deleted from cloud but the file is still on this device"). User can delete locally via FileManager. v1 does not auto-mirror cloud deletes to local files. | +| 18 | Initial `since=0` dummy `00000…` book | `parseSyncRow` filters this hash; verification step #11 confirms. | +| 19 | Progress shape ambiguity | Schema renames to `progress_lib` to make clear it's `books.progress` from `/sync` (a `[cur, total]` tuple), distinct from KOReader's per-document position and Readest's `book_configs.progress` xpointer. | +| 20 | e-ink perf | Added `librarygrid.lua` windowing module + debounced search + cached `getGroups` + throttled cover extraction. Verification step #12 sets concrete benchmarks. | +| 21 | Coverbrowser dependency contradiction | Resolved as hard dependency; if absent, all books render `FakeCover` (no degraded grid mode). | +| 22 | Download path losing `{hash}/{title}` convention | Intentional in v4: flat `{library_download_dir}/{safeTitle}.{ext}` layout (user-confirmed — KOReader users prefer flat dirs in their book folder). Hash-based reconciliation still works via DocSettings `partial_md5_checksum` from `.sdr/` sidecars, independent of file location. | +| 23 | `uploaded_at` ≠ downloadable object | Added 404 handling: "Cloud copy unavailable"; verification step #8 covers this. | +| 24 | Verification too thin | Steps 10-12 added: auth flows, dummy filter, perf benchmark with concrete targets. Test-vector matrix dropped per user (see #5). | + +**VERDICT:** REVISION COMPLETE — ready for implementation pending user approval. +Recommend optional re-run of `/codex review` against the revised plan to +confirm the storage-path-fallback claim and the parseSyncRow design. diff --git a/apps/readest.koplugin/icons/cloud_download.svg b/apps/readest.koplugin/icons/cloud_download.svg new file mode 100644 index 00000000..59beb965 --- /dev/null +++ b/apps/readest.koplugin/icons/cloud_download.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/readest.koplugin/icons/cloud_upload.svg b/apps/readest.koplugin/icons/cloud_upload.svg new file mode 100644 index 00000000..11ff12d3 --- /dev/null +++ b/apps/readest.koplugin/icons/cloud_upload.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/readest.koplugin/library/bim_patch.lua b/apps/readest.koplugin/library/bim_patch.lua new file mode 100644 index 00000000..4966e772 --- /dev/null +++ b/apps/readest.koplugin/library/bim_patch.lua @@ -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 " 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 "x". + 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 diff --git a/apps/readest.koplugin/library/cloud_covers.lua b/apps/readest.koplugin/library/cloud_covers.lua new file mode 100644 index 00000000..06cd2303 --- /dev/null +++ b/apps/readest.koplugin/library/cloud_covers.lua @@ -0,0 +1,213 @@ +-- cloud_covers.lua +-- Per-book cover lifecycle for cloud-only rows. Owns the on-disk +-- .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 .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 + +-- " ''" 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 diff --git a/apps/readest.koplugin/library/cloud_icons.lua b/apps/readest.koplugin/library/cloud_icons.lua new file mode 100644 index 00000000..9482e2a1 --- /dev/null +++ b/apps/readest.koplugin/library/cloud_icons.lua @@ -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 diff --git a/apps/readest.koplugin/library/coverprovider.lua b/apps/readest.koplugin/library/coverprovider.lua new file mode 100644 index 00000000..861aee30 --- /dev/null +++ b/apps/readest.koplugin/library/coverprovider.lua @@ -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 diff --git a/apps/readest.koplugin/library/exts.lua b/apps/readest.koplugin/library/exts.lua new file mode 100644 index 00000000..8a9f57e7 --- /dev/null +++ b/apps/readest.koplugin/library/exts.lua @@ -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 diff --git a/apps/readest.koplugin/library/group_covers.lua b/apps/readest.koplugin/library/group_covers.lua new file mode 100644 index 00000000..5c8bcb2f --- /dev/null +++ b/apps/readest.koplugin/library/group_covers.lua @@ -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 diff --git a/apps/readest.koplugin/library/libraryitem.lua b/apps/readest.koplugin/library/libraryitem.lua new file mode 100644 index 00000000..c3dfe3a9 --- /dev/null +++ b/apps/readest.koplugin/library/libraryitem.lua @@ -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 diff --git a/apps/readest.koplugin/library/librarypaint.lua b/apps/readest.koplugin/library/librarypaint.lua new file mode 100644 index 00000000..d12a80d0 --- /dev/null +++ b/apps/readest.koplugin/library/librarypaint.lua @@ -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 diff --git a/apps/readest.koplugin/library/librarystore.lua b/apps/readest.koplugin/library/librarystore.lua new file mode 100644 index 00000000..95528585 --- /dev/null +++ b/apps/readest.koplugin/library/librarystore.lua @@ -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 diff --git a/apps/readest.koplugin/library/libraryviewmenu.lua b/apps/readest.koplugin/library/libraryviewmenu.lua new file mode 100644 index 00000000..e8c72d4b --- /dev/null +++ b/apps/readest.koplugin/library/libraryviewmenu.lua @@ -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 diff --git a/apps/readest.koplugin/library/librarywidget.lua b/apps/readest.koplugin/library/librarywidget.lua new file mode 100644 index 00000000..5a64e486 --- /dev/null +++ b/apps/readest.koplugin/library/librarywidget.lua @@ -0,0 +1,1129 @@ +-- librarywidget.lua +-- Top-level Library view. Constructs a vanilla KOReader Menu and method- +-- mixes in CoverMenu + MosaicMenu (or ListMenu) per zen_ui's group_view.lua +-- pattern, then drives item_table from LibraryStore. Owns the search bar, +-- view-menu button, and group breadcrumb. Triggers lightScan + cloud pull +-- on open. +-- +-- See apps/readest.koplugin/docs/library-design.md for the full design and +-- the runtime compatibility/smoke-test reasoning. + +local Device = require("device") +local GestureRange = require("ui/gesturerange") +local InfoMessage = require("ui/widget/infomessage") +local InputDialog = require("ui/widget/inputdialog") +local Menu = require("ui/widget/menu") +local NetworkMgr = require("ui/network/manager") +local TitleBar = require("ui/widget/titlebar") +local Trapper = require("ui/trapper") +local UIManager = require("ui/uimanager") +local logger = require("logger") +local _ = require("i18n") + +local LibraryStore = require("library.librarystore") +local libraryitem = require("library.libraryitem") +local librarypaint = require("library.librarypaint") +local localscanner = require("library.localscanner") +local syncbooks = require("library.syncbooks") + +local M = {} + +-- --------------------------------------------------------------------------- +-- Module-level state. The widget is a singleton — reopening reuses the +-- store. Account-switching closes + reopens with a fresh user_id. +-- --------------------------------------------------------------------------- +M._store = nil +M._current_user = nil +M._opts = nil -- last-used opts; needed for refresh after view-menu changes +M._menu = nil +M._search = nil -- transient per-session search query; never persisted to disk +M._group_path = nil -- transient: nil = root; "Fantasy/Tolkien" when drilled in + +-- --------------------------------------------------------------------------- +-- check_renderer_compat: signature + smoke test from the eng review. +-- Returns ok, reason; on failure, librarywidget falls back to a plain Menu +-- with FakeCover-only items so the view still loads. +-- --------------------------------------------------------------------------- +function M.check_renderer_compat() + local ok_cm, CoverMenu = pcall(require, "covermenu") + local ok_mm, MosaicMenu = pcall(require, "mosaicmenu") + local ok_lm, ListMenu = pcall(require, "listmenu") + if not (ok_cm and ok_mm and ok_lm) then + return false, "missing-modules:" .. tostring(not ok_cm and "covermenu" or not ok_mm and "mosaicmenu" or "listmenu") + end + local needed = { + { CoverMenu, "updateItems" }, + { CoverMenu, "onCloseWidget" }, + { MosaicMenu, "_recalculateDimen" }, + { MosaicMenu, "_updateItemsBuildUI" }, + { ListMenu, "_recalculateDimen" }, + { ListMenu, "_updateItemsBuildUI" }, + } + for _, n in ipairs(needed) do + if type(n[1][n[2]]) ~= "function" then + return false, "missing-method:" .. n[2] + end + end + return true +end + +-- --------------------------------------------------------------------------- +-- canOpen(settings) — used by main.lua's menu-item enable check. +-- --------------------------------------------------------------------------- +function M.canOpen(settings) + if not settings or not settings.user_id or settings.user_id == "" then + return false, _("Sign in to Readest to open the Library") + end + return true +end + +-- --------------------------------------------------------------------------- +-- ensure_store(settings) — open the LibraryStore for the active user; +-- close + reopen if the user_id changed since last open (account switch). +-- --------------------------------------------------------------------------- +local function ensure_store(settings) + local DataStorage = require("datastorage") + local db_path = DataStorage:getSettingsDir() .. "/readest_library.sqlite3" + if M._store and M._current_user == settings.user_id then return M._store end + if M._store then M._store:close() end + M._store = LibraryStore.new({ user_id = settings.user_id, db_path = db_path }) + M._current_user = settings.user_id + return M._store +end + +-- --------------------------------------------------------------------------- +-- get_filters(settings) — read the persisted view-menu state from +-- G_reader_settings.readest_sync.library_*; return a filters table for +-- LibraryStore:listBooks. +-- --------------------------------------------------------------------------- +local function get_filters(settings, search) + -- Default sort: last_read_at DESC. listBooks COALESCEs with updated_at + -- so cloud-only books (NULL last_read_at) still sort by their cloud + -- timestamp instead of falling to the bottom in arbitrary order. + -- group_by/group_filter are intentionally NOT carried here — the + -- bookshelf composer (build_item_table) handles them via + -- listBookshelfGroups + listBookshelfBooks. + return { + search = search, + sort_by = settings.library_sort_by or "last_read_at", + sort_asc = settings.library_sort_ascending == true, + } +end + +-- The picker values mirror Readest's LibraryGroupByType ("authors", +-- "groups", "series"); the store schema uses singular SQL column names +-- ("author", "group_name", "series"). This map translates one to the +-- other so the UI value and the SQL identifier can both be canonical. +-- Default first-run group-by is "groups" for parity with Readest web. +local DEFAULT_GROUP_BY = "groups" +local GROUP_BY_TO_COLUMN = { + authors = "author", + groups = "group_name", + series = "series", +} +local function active_group_by(settings) + local g = settings.library_group_by + if g == nil then g = DEFAULT_GROUP_BY end + if g == "none" then return nil end + return GROUP_BY_TO_COLUMN[g] or g +end + +-- "↩ Parent" or "↩ Library" depending on whether we'd land at a sub-path +-- or back at root. Used as item_table[1] when drilled in. +local function back_entry_for(current_path) + local parent + if current_path then + for i = #current_path, 1, -1 do + if current_path:sub(i, i) == "/" then + parent = current_path:sub(1, i - 1) + break + end + end + end + local label = "↩ " .. (parent or _("Library")) + return libraryitem.entry_back(parent, label) +end + +-- --------------------------------------------------------------------------- +-- get_view_mode(settings) — "mosaic" or "list"; defaults to mosaic. +-- --------------------------------------------------------------------------- +local function get_view_mode(settings) + local mode = settings.library_view_mode + if mode == "list" then return "list" end + return "mosaic" +end + +-- --------------------------------------------------------------------------- +-- build_item_table(store, settings, search) — query store + map rows to +-- Menu entries via libraryitem.entry_from_row. +-- --------------------------------------------------------------------------- +-- Sort-value extraction for the merged shelf list. Each entry — whether +-- a folder/group or a book row — gets one comparable value per sort_by; +-- groups use their "most recent child" aggregate so they interleave +-- correctly with siblings under date-based sorts. Mirrors Readest's +-- getGroupSortValue + getBookSortValue at +-- apps/readest-app/src/app/library/utils/libraryUtils.ts:313-403. +local SORT_VALUE_FOR_GROUP = { + last_read_at = function(g) return g.latest_last_read_at or 0 end, + updated_at = function(g) return g.latest_updated_at or 0 end, + created_at = function(g) return g.latest_created_at or 0 end, + title = function(g) return g.display_name or "" end, + author = function(g) return g.display_name or "" end, + series = function(g) return g.display_name or "" end, + format = function(g) return g.display_name or "" end, +} + +local SORT_VALUE_FOR_BOOK = { + -- last_read_at: prefer updated_at when present (matches the SQL + -- COALESCE in librarystore.listBooks). Without this the Lua-side + -- merged-shelf sort overrides the SQL sort with the old + -- "last_read_at first" behaviour, hiding any updated_at bump (e.g. + -- the dedupe path of "Add to Readest"). + last_read_at = function(r) return r.updated_at or r.last_read_at or 0 end, + updated_at = function(r) return r.updated_at or 0 end, + created_at = function(r) return r.created_at or 0 end, + title = function(r) return r.title or "" end, + author = function(r) return r.author or "" end, + series = function(r) return r.series or "" end, + format = function(r) return r.format or "" end, +} + +local function build_item_table(store, settings, search) + local group_by = active_group_by(settings) + + if not group_by then + local rows = store:listBooks(get_filters(settings, search)) + local items = {} + for i, row in ipairs(rows) do + items[i] = libraryitem.entry_from_row(row) + end + return items + end + + local parent_path = M._group_path + local groups = store:listBookshelfGroups(group_by, parent_path) + local books = store:listBookshelfBooks(get_filters(settings, search), group_by, parent_path) + + local sort_by = settings.library_sort_by or "last_read_at" + local sort_asc = settings.library_sort_ascending == true + local g_value = SORT_VALUE_FOR_GROUP[sort_by] or SORT_VALUE_FOR_GROUP.last_read_at + local b_value = SORT_VALUE_FOR_BOOK[sort_by] or SORT_VALUE_FOR_BOOK.last_read_at + + logger.dbg("ReadestLibrary build_item_table: group_by=" .. tostring(group_by) + .. " parent_path=" .. tostring(parent_path) + .. " sort_by=" .. tostring(sort_by) + .. " sort_asc=" .. tostring(sort_asc) + .. " #groups=" .. #groups .. " #books=" .. #books) + + -- Build a single mixed list with each entry's sort_value pre-computed, + -- then sort once. Stable on already-sorted input either way. + -- Group cells get a mini-cover preview in both view modes — a 2x2 + -- mosaic for Grid (mosaic) and a 1x4 horizontal strip for List. + local view_mode = get_view_mode(settings) + local group_entry_opts = { + group_by = group_by, + with_cover = true, + shape = (view_mode == "list") and "list" or "grid", + } + local merged = {} + for _i, g in ipairs(groups) do + merged[#merged + 1] = { + entry = libraryitem.entry_from_group(g, group_entry_opts), + sort_value = g_value(g), + } + end + for _i, row in ipairs(books) do + merged[#merged + 1] = { + entry = libraryitem.entry_from_row(row), + sort_value = b_value(row), + } + end + table.sort(merged, function(a, b) + local av, bv = a.sort_value, b.sort_value + if type(av) ~= type(bv) then + -- Pathological: a string sort_value next to a numeric one + -- (shouldn't happen given the table-driven extractors above, + -- but stay deterministic if a future caller mixes them). + return tostring(av) < tostring(bv) + end + if sort_asc then return av < bv end + return av > bv + end) + + local items = {} + if parent_path then + items[#items + 1] = back_entry_for(parent_path) + end + for _i, m in ipairs(merged) do + items[#items + 1] = m.entry + end + return items +end + +-- --------------------------------------------------------------------------- +-- mix_renderer(menu, view_mode) — apply CoverMenu + (Mosaic|List)Menu +-- methods onto our Menu. Mirrors zen_ui group_view.lua:62-95 layout. +-- --------------------------------------------------------------------------- +local function mix_renderer(menu, view_mode) + local CoverMenu = require("covermenu") + local MosaicMenu = require("mosaicmenu") + local ListMenu = require("listmenu") + + menu.updateItems = CoverMenu.updateItems + menu.onCloseWidget = CoverMenu.onCloseWidget + + -- Per-mode mixins + if view_mode == "mosaic" then + menu._recalculateDimen = MosaicMenu._recalculateDimen + menu._updateItemsBuildUI = MosaicMenu._updateItemsBuildUI + menu._do_cover_images = true + menu._do_center_partial_rows = false + menu._do_hint_opened = false + else + menu._recalculateDimen = ListMenu._recalculateDimen + menu._updateItemsBuildUI = ListMenu._updateItemsBuildUI + menu._do_cover_images = true + menu._do_filename_only = false + end + + menu.display_mode_type = view_mode + + -- Codex round 2 finding 3: zen_ui supplies these methods because the + -- mixin's _updateItemsBuildUI calls them as if they were native to the + -- Menu. Provide real implementations (an empty stub causes + -- been_opened=nil → MosaicMenu paints the "New" ribbon on every + -- already-read book — which was every book in the user's bug report). + if not menu.getBookInfo then + menu.getBookInfo = function(file_path) + if not file_path then return {} end + -- Cloud-only entries use a synthetic readest-cloud:// path; + -- they're not on disk so DocSettings can't read them. Return + -- been_opened=false so the renderer doesn't show "New" but + -- doesn't try to read a percent_finished either. + if file_path:match("^readest%-cloud://") then + return { been_opened = false } + end + local ok_ds, DocSettings = pcall(require, "docsettings") + if not ok_ds then return {} end + if not DocSettings:hasSidecarFile(file_path) then return {} end + local ok_open, doc = pcall(DocSettings.open, DocSettings, file_path) + if not ok_open or not doc then return {} end + local summary = doc:readSetting("summary") + local stats = doc:readSetting("stats") + return { + been_opened = true, + percent_finished = doc:readSetting("percent_finished"), + status = summary and summary.status, + pages = stats and stats.pages, + } + end + end + if not menu.resetBookInfoCache then + menu.resetBookInfoCache = function() end + end +end + +-- --------------------------------------------------------------------------- +-- smoke_test_render(menu) — render one synthetic local + one cloud-only +-- entry off-screen via pcall. If the renderer throws on either, fall back. +-- Catches contract drift in entry shape that the method-existence check +-- alone would miss (codex round 2 finding 3). +-- --------------------------------------------------------------------------- +local function smoke_test_render(menu) + local probe = { is_file = true, file = "/tmp/readest-smoke.epub", text = "Smoke" } + local cloud = libraryitem.entry_from_row({ + hash = "00smoke", title = "Cloud Smoke", cloud_present = 1, local_present = 0, + }) + local saved_table = menu.item_table + menu.item_table = { probe, cloud } + local ok, err = pcall(function() + if menu._recalculateDimen then menu:_recalculateDimen() end + -- Don't call _updateItemsBuildUI — it appends real widgets to the + -- menu's content_group which we don't want side-effects from. + -- The dimension recalc is the failure-prone part anyway. + end) + menu.item_table = saved_table + return ok, err +end + +-- --------------------------------------------------------------------------- +-- title_for(search) — build the menu title bar text. When a search is +-- active, surface it so the user can see why their library "looks empty". +-- --------------------------------------------------------------------------- +local function title_for(search) + local title = _("Readest Library") + if M._group_path then + title = title .. ": " .. M._group_path + end + if search and search ~= "" then + title = title .. " (" .. search .. ")" + end + return title +end + +-- --------------------------------------------------------------------------- +-- handleSearch(menu, store, settings) — open InputDialog. Search is +-- session-transient (M._search), never persisted: a stuck filter from a +-- previous run was hiding all-but-matching books with no UI hint. +-- Includes a Clear button when a search is active so getting back to the +-- full library is one tap. +-- --------------------------------------------------------------------------- +local function handleSearch(menu, store, settings) + local has_active = M._search and M._search ~= "" + local dialog + local apply = function(q) + UIManager:close(dialog) + M._search = (q and q ~= "") and q or nil + -- switchItemTable accepts the new title as its first positional arg; + -- call it that way instead of menu:setTitle (which doesn't exist on + -- Menu — it's on TitleBar). One call updates both title + items. + menu:switchItemTable(title_for(M._search), + build_item_table(store, settings, M._search), 1) + end + local row = { + { + text = _("Cancel"), + id = "close", + callback = function() UIManager:close(dialog) end, + }, + } + if has_active then + row[#row + 1] = { + text = _("Clear"), + callback = function() apply(nil) end, + } + end + row[#row + 1] = { + text = _("Search"), + is_enter_default = true, + callback = function() apply(dialog:getInputText() or "") end, + } + dialog = InputDialog:new{ + title = _("Search library"), + input = M._search or "", + input_hint = _("Search title or author"), + buttons = { row }, + } + UIManager:show(dialog) + dialog:onShowKeyboard() +end + +-- --------------------------------------------------------------------------- +-- refresh(menu, store, settings) — re-query store and update visible page. +-- Called after any view-menu change, search change, sync pull, or scan. +-- --------------------------------------------------------------------------- +function M.refresh() + if not M._menu then return end + local items = build_item_table(M._store, M._opts.settings, M._search) + -- Preserve current page so cover-download completions (which call + -- refresh too) don't yank the user back to page 1 mid-browse. The + -- third arg to switchItemTable is "jump to this item number"; we + -- compute the first item of the current page so the same page + -- re-renders. If the new item count is shorter (e.g. after a sync + -- delete), clamp to the last available item. + local page = M._menu.page or 1 + local perpage = M._menu.perpage or 1 + local jump_to = math.max(1, math.min((page - 1) * perpage + 1, #items)) + M._menu:switchItemTable(title_for(M._search), items, jump_to) +end + +-- Close the current Menu and rebuild it from scratch using the latest +-- settings. Used after view-menu changes that affect layout dimensions +-- (view mode, column count, cover fit) — those are baked in at Menu +-- construction time via mix_renderer + nb_cols_portrait, so a soft +-- refresh wouldn't pick them up. Settings that only affect the SQL +-- query (sort, group, search) keep using M.refresh() since rebuilding +-- the whole Menu would be needless flicker. +function M.reopen() + if M._menu then + UIManager:close(M._menu) + M._menu = nil + -- Clear the visible-hash filter so any in-flight BIM lookups + -- after close don't kick off downloads we no longer want. + libraryitem.set_visible_hashes(nil) + end + -- A view-mode/columns/cover-fit change is layout-only — keep the + -- user's current drill-in. _keep_state tells M.open to skip its + -- per-session resets (group_path, search reset). + if M._opts then M.open(M._opts, { keep_state = true }) end +end + +-- --------------------------------------------------------------------------- +-- runOpenSync(opts, menu) — fired after the menu is shown; runs lightScan +-- + cloud sync in a background-friendly way (Trapper coroutine for the +-- progress dialog; subprocess for the heavy walk happens inside +-- localscanner.fullSidecarWalk on first run / 24h gate / explicit Rescan). +-- +-- Sync direction depends on settings.auto_sync (mirrors the auto-sync +-- toggle the user controls from the Readest plugin menu): +-- on → "both" (push local changes first, then pull remote changes) +-- off → "pull" only (still surface cloud-side updates so the Library +-- view stays meaningful, but never silently push local state). +-- --------------------------------------------------------------------------- +local function runCloudSync(opts, store) + local mode = opts.settings.auto_sync and "both" or "pull" + logger.info("ReadestLibrary runCloudSync: mode=" .. mode + .. " auto_sync=" .. tostring(opts.settings.auto_sync)) + syncbooks.syncBooks({ + sync_auth = opts.sync_auth, + sync_path = opts.sync_path, + settings = opts.settings, + store = store, + }, mode, function(success, msg, status) + logger.info("ReadestLibrary runCloudSync[" .. mode .. "] done: success=" + .. tostring(success) .. " msg=" .. tostring(msg) + .. " status=" .. tostring(status)) + -- Refresh either way: success picks up new cloud rows; failure + -- (auth, network, server) leaves local rows visible without + -- leaking a stale display state. + M.refresh() + end) +end + +-- Cloud sync HTTP is synchronous on platforms without the Turbo looper +-- (macOS desktop, most KOReader builds with the lightweight networking +-- layer). Calling it inline from M.open blocks the UI loop, so +-- UIManager:show(menu) doesn't actually repaint until the sync returns +-- — visible to the user as a frozen Library on open. Pushing it through +-- UIManager:scheduleIn yields back to the event loop first, lets the +-- menu paint with the pre-sync local snapshot, and only then issues the +-- HTTP. The user still sees a brief blocking window when the request +-- fires, but at least content is on screen instead of a black hole. +-- Plan B (true non-blocking via runInSubProcess) is a bigger refactor. +local SYNC_DEFER_SECONDS = 0.05 + +local function runOpenSync(opts, store, menu) + Trapper:wrap(function() + logger.info("ReadestLibrary runOpenSync: start user=" + .. tostring(opts.settings.user_id and opts.settings.user_id:sub(1, 8)) + .. " auto_sync=" .. tostring(opts.settings.auto_sync)) + + -- 1. Light local scan (cheap, synchronous). Always refresh after, + -- regardless of pull outcome below — otherwise local-only books + -- (the common case for a freshly-installed plugin where nothing + -- has been uploaded to Readest cloud yet) would never appear in + -- the menu, since the initial item_table was built from the + -- pre-scan store snapshot. + local ok, err = pcall(localscanner.lightScan, { store = store }) + if not ok then logger.warn("ReadestLibrary lightScan failed:", err) end + M.refresh() + + -- 2. Cloud sync — deferred so the menu paints first. + -- willRerunWhenOnline + the inline call moved into the + -- scheduled handler so the offline→online and already-online + -- branches share one code path. + local since = store:getLastPulledAt() or 0 + logger.info("ReadestLibrary runOpenSync: since=" .. tostring(since) + .. ", scheduling cloud sync in " .. SYNC_DEFER_SECONDS .. "s") + UIManager:scheduleIn(SYNC_DEFER_SECONDS, function() + if NetworkMgr:willRerunWhenOnline(function() runCloudSync(opts, store) end) then + logger.info("ReadestLibrary runOpenSync: sync deferred until network online") + return + end + logger.info("ReadestLibrary runOpenSync: network online, dispatching sync") + runCloudSync(opts, store) + end) + end) +end + +-- --------------------------------------------------------------------------- +-- open(opts) — main entry from the Readest plugin menu. +-- opts: { settings = G_reader_settings.readest_sync, sync_path, sync_auth } +-- --------------------------------------------------------------------------- +function M.open(opts, internal) + local can_open, reason = M.canOpen(opts.settings) + if not can_open then + UIManager:show(InfoMessage:new{ text = reason, timeout = 3 }) + return + end + + M._opts = opts + -- Each fresh open starts at the root shelf; group drill-in state is + -- per-session, never persisted to disk. M.reopen passes keep_state + -- so a layout-change rebuild stays in the user's current folder. + if not (internal and internal.keep_state) then + M._group_path = nil + end + -- Migrate any stuck pre-fix library_search from settings into the + -- transient session slot, then strip it from disk so future upgrades + -- don't have to repeat this. (Leaving it in settings would re-pollute + -- M._search next session.) + if opts.settings.library_search then + M._search = opts.settings.library_search + opts.settings.library_search = nil + G_reader_settings:saveSetting("readest_sync", opts.settings) + end + local store = ensure_store(opts.settings) + + -- Renderer compatibility check (codex round 2 finding 3) + local ok, why = M.check_renderer_compat() + if not ok then + logger.warn("ReadestLibrary renderer compat check failed:", why) + UIManager:show(InfoMessage:new{ + text = _("Cover Browser plugin required for full Library rendering. Falling back to plain list."), + timeout = 5, + }) + -- Plain Menu fallback: still usable, just no covers + else + -- Pass sync auth so the patched BIM can lazily download cloud + -- cover.png files for cloud-only entries (covers are then + -- shared between cloud + local presentations of the same hash). + libraryitem.install({ + sync_auth = opts.sync_auth, + sync_path = opts.sync_path, + settings = opts.settings, + }) + end + + local Screen = Device.screen + local view_mode = get_view_mode(opts.settings) + + local menu + -- Custom TitleBar: close X on the right (via close_callback), search on + -- the left, and the centered title acts as a tap target for the View + -- menu. Stock TitleBar has no title-tap callback, so the actual tap + -- handler is registered on the Menu's ges_events below — this title + -- bar just supplies the dimen for the gesture range. + local view_menu_callback = function() + local LibraryViewMenu = require("library.libraryviewmenu") + local prev_group_by = active_group_by(opts.settings) + LibraryViewMenu.show({ + settings = opts.settings, + on_change = function() + if active_group_by(opts.settings) ~= prev_group_by then + M._group_path = nil + end + M.refresh() + end, + on_layout_change = function() + if active_group_by(opts.settings) ~= prev_group_by then + M._group_path = nil + end + M.reopen() + end, + }) + end + local title_bar = TitleBar:new{ + width = Screen:getWidth(), + fullscreen = "true", + align = "center", + title = title_for(M._search), + left_icon = "appbar.search", + left_icon_tap_callback = function() handleSearch(menu, store, opts.settings) end, + close_callback = function() if menu then menu:onClose() end end, + } + -- Compute the orientation-appropriate per-page count up front and + -- pass it as items_per_page. Menu's native _recalculateDimen + -- (menu.lua:648) uses items_per_page; MosaicMenu's mixin uses + -- nb_rows*nb_cols. Without setting items_per_page they disagree — + -- Menu's heuristic computed ~14, MosaicMenu's mixin computed 9, the + -- footer page-nav lagged the cell layout, and the cell layout + -- attempted to fit 14 cells in 9 visible slots, leaking partial cells + -- under the page nav. Make both formulas land on the same number by + -- pre-setting items_per_page. + local nb_cols_p = opts.settings.library_columns or 3 + local nb_rows_p = opts.settings.library_rows or 3 + local nb_cols_l = opts.settings.library_columns_landscape or 4 + local nb_rows_l = opts.settings.library_rows_landscape or 2 + local portrait = Screen:getWidth() <= Screen:getHeight() + local items_per_page = portrait and (nb_cols_p * nb_rows_p) or (nb_cols_l * nb_rows_l) + + menu = Menu:new{ + name = "readest_library", + is_borderless = true, + is_popout = false, + covers_fullscreen = true, + custom_title_bar = title_bar, + item_table = build_item_table(store, opts.settings, M._search), + width = Screen:getWidth(), + height = Screen:getHeight(), + items_per_page = items_per_page, + nb_cols_portrait = nb_cols_p, + nb_rows_portrait = nb_rows_p, + nb_cols_landscape = nb_cols_l, + nb_rows_landscape = nb_rows_l, + onMenuSelect = function(_self, item) + M.handleTap(item, opts) + end, + onMenuHold = function(_self, item) + M.handleHold(item, opts) + end, + } + title_bar.show_parent = menu + + if ok then + mix_renderer(menu, view_mode) + local smoke_ok, smoke_err = smoke_test_render(menu) + if not smoke_ok then + logger.warn("ReadestLibrary smoke test failed:", smoke_err) + -- Already constructed; just leave the mixin in place. The + -- smoke test is a tripwire, not a fatal check — we surface + -- via logger so a future user report includes the trace. + end + librarypaint.install(menu) + + -- Wrap updateItems to recompute the visible-page hash set before + -- any cell paints. Cell paints call BIM:getBookInfo, which calls + -- trigger_cover_download — and we want THAT to only fire for + -- on-screen items. set_visible_hashes runs FIRST so when the + -- subsequent paint hits BIM, the filter is already in place. + local orig_update = menu.updateItems + menu.updateItems = function(self, ...) + libraryitem.set_visible_hashes(self) + return orig_update(self, ...) + end + end + + -- Tap on the title bar (anywhere not on the left search icon or the + -- right close X) opens the View menu. Stock TitleBar has no + -- title_tap_callback hook, so we register a gesture range at the + -- Menu level. Children (IconButton, MenuItem cells, footer) get the + -- gesture first via WidgetContainer dispatch; only taps that no child + -- claims fall through to Menu's onGesture and reach this handler. + -- Use a range function so the dimen is read at match time — TitleBar + -- only sets dimen.x/y during paintTo, so the value at registration + -- isn't reliable on first tap. + menu.ges_events.TapTitle = { + GestureRange:new{ + ges = "tap", + range = function() return title_bar.dimen end, + }, + } + menu.onTapTitle = function() view_menu_callback() return true end + + M._menu = menu + UIManager:show(menu) + + runOpenSync(opts, store, menu) +end + +-- --------------------------------------------------------------------------- +-- handleTap(item, opts) — tap dispatch. Local books open immediately; +-- cloud-only books prompt for download. +-- --------------------------------------------------------------------------- +function M.handleTap(item, opts) + if not item then return end + + -- Group folder entry → drill in + if item._readest_group then + M._group_path = item._readest_group.name + M.refresh() + return + end + + -- "↩ Back" entry → pop one level (or up to root) + if item._readest_is_back then + M._group_path = item._readest_back_to + M.refresh() + return + end + + if not item._readest_row then return end + local row = item._readest_row + local lfs = require("libs/libkoreader-lfs") + + if row.local_present == 1 and row.file_path then + -- Tap-time recovery: maybe the file vanished since the last scan + if lfs.attributes(row.file_path, "mode") ~= "file" then + local ConfirmBox = require("ui/widget/confirmbox") + UIManager:show(ConfirmBox:new{ + text = _("File moved or deleted. Rescan library?"), + ok_callback = function() + Trapper:wrap(function() + localscanner.fullSidecarWalk({ + store = M._store, + home_dir = G_reader_settings:readSetting("home_dir"), + }) + M.refresh() + end) + end, + }) + return + end + local ReaderUI = require("apps/reader/readerui") + ReaderUI:showReader(row.file_path) + return + end + + -- Cloud-only path: confirm + download + open + if row.cloud_present == 1 then + local ConfirmBox = require("ui/widget/confirmbox") + UIManager:show(ConfirmBox:new{ + text = _("Download this book from Readest?") .. "\n\n" .. (row.title or ""), + ok_text = _("Download"), + ok_callback = function() + local download_dir = opts.settings.library_download_dir + or G_reader_settings:readSetting("home_dir") + if not download_dir or download_dir == "" then + UIManager:show(InfoMessage:new{ + text = _("Set Home folder in File Manager first to enable downloads."), + timeout = 3, + }) + return + end + local progress = InfoMessage:new{ + text = _("Downloading…") .. " " .. (row.title or ""), + } + UIManager:show(progress) + syncbooks.downloadBook(row, { + sync_auth = opts.sync_auth, + sync_path = opts.sync_path, + settings = opts.settings, + download_dir = download_dir, + }, function(success, dst_or_err, status) + UIManager:close(progress) + if not success then + local msg = (status == 404) + and _("Cloud copy unavailable.") + or _("Download failed.") + UIManager:show(InfoMessage:new{ text = msg, timeout = 3 }) + return + end + -- Update store: row now has a local file + M._store:upsertBook({ + hash = row.hash, title = row.title, + local_present = 1, file_path = dst_or_err, + }) + M.refresh() + local ReaderUI = require("apps/reader/readerui") + ReaderUI:showReader(dst_or_err) + end) + end, + }) + return + end +end + +-- --------------------------------------------------------------------------- +-- Action helpers (used by handleHold's button dialog) +-- --------------------------------------------------------------------------- + +-- Run a book download without auto-opening the reader on success. Used by +-- both "Download Book" and "Download All" from the long-press action +-- sheet — there the user wants the file on device, not to start reading. +local function downloadBookOnly(row, opts, after_cb) + local download_dir = opts.settings.library_download_dir + or G_reader_settings:readSetting("home_dir") + if not download_dir or download_dir == "" then + UIManager:show(InfoMessage:new{ + text = _("Set Home folder in File Manager first to enable downloads."), + timeout = 3, + }) + if after_cb then after_cb(false) end + return + end + local progress = InfoMessage:new{ + text = _("Downloading…") .. " " .. (row.title or ""), + } + UIManager:show(progress) + syncbooks.downloadBook(row, { + sync_auth = opts.sync_auth, + sync_path = opts.sync_path, + settings = opts.settings, + download_dir = download_dir, + }, function(success, dst_or_err, status) + UIManager:close(progress) + if not success then + local msg = (status == 404) + and _("Cloud copy unavailable.") + or _("Download failed.") + UIManager:show(InfoMessage:new{ text = msg, timeout = 3 }) + if after_cb then after_cb(false) end + return + end + M._store:upsertBook({ + hash = row.hash, title = row.title, + local_present = 1, file_path = dst_or_err, + }) + M.refresh() + if after_cb then after_cb(true) end + end) +end + +local function downloadCoverOnly(row, opts, after_cb) + local DataStorage = require("datastorage") + syncbooks.downloadCover(row, { + sync_auth = opts.sync_auth, + sync_path = opts.sync_path, + settings = opts.settings, + covers_dir = DataStorage:getSettingsDir() .. "/readest_covers", + }, function(success, _path_or_err, status) + if not success then + local msg = (status == 404) + and _("No cover available on Readest.") + or _("Cover download failed.") + UIManager:show(InfoMessage:new{ text = msg, timeout = 3 }) + if after_cb then after_cb(false) end + return + end + M.refresh() + if after_cb then after_cb(true) end + end) +end + +-- Remove the local file for a book and clear local_present in the store. +-- Returns true on success. Cloud-side state (cloud_present, deleted_at) +-- is untouched. +local function removeLocalFile(row) + local lfs = require("libs/libkoreader-lfs") + if not row.file_path or row.file_path == "" then return false end + if lfs.attributes(row.file_path, "mode") == "file" then + local ok = os.remove(row.file_path) + if not ok then + UIManager:show(InfoMessage:new{ + text = _("Could not delete the file."), timeout = 3, + }) + return false + end + end + -- Reset local presence in the store; cloud_present stays as-is so + -- the row remains visible (and re-downloadable) if it's in the cloud. + M._store:upsertBook({ + hash = row.hash, + title = row.title, + local_present = 0, + file_path = nil, + }) + return true +end + +-- --------------------------------------------------------------------------- +-- handleHold(item, opts) — long-press action sheet +-- --------------------------------------------------------------------------- +-- Mirrors Readest's BookDetailView action set (apps/readest-app/src/ +-- components/metadata/BookDetailView.tsx): Delete is a sub-menu with +-- Cloud & Device / Cloud Only / Device Only; Upload shows only when the +-- book is on device but not in the cloud; Download shows only when the +-- book is in the cloud. Cloud-side actions (Upload, Remove from Cloud) +-- need new /storage/upload + DELETE /sync calls that aren't ported yet, +-- so they're labeled but stubbed; local-side actions (Remove from +-- Device, Download Book/Cover/All) work today. +function M.handleHold(item, opts) + if not item or not item._readest_row then return end + local row = item._readest_row + local ButtonDialog = require("ui/widget/buttondialog") + local ConfirmBox = require("ui/widget/confirmbox") + + local on_cloud = row.cloud_present == 1 + local on_local = row.local_present == 1 + + local dialog + local function close() UIManager:close(dialog) end + + local rows = {} + local function add_row(text, cb) + rows[#rows + 1] = {{ text = text, callback = cb }} + end + + -- Cloud delete shared by "Cloud & Device" and "Cloud Only". Mirrors + -- Readest's cloudService.deleteBook 'cloud' branch: list /storage, + -- delete each file, clear cloud_present in the store. For Cloud & + -- Device the caller also pushes a tombstone via /sync so peers + -- consider the book gone (peers pulling /sync see deleted_at). + local function doCloudDelete(after_cb) + local progress = InfoMessage:new{ + text = _("Removing from cloud…") .. " " .. (row.title or ""), + } + UIManager:show(progress) + syncbooks.deleteCloudFiles(row, { + sync_auth = opts.sync_auth, + sync_path = opts.sync_path, + settings = opts.settings, + }, function(success, _msg, status) + UIManager:close(progress) + if not success then + UIManager:show(InfoMessage:new{ + text = _("Cloud removal failed.") + .. " (status=" .. tostring(status) .. ")", + timeout = 3, + }) + if after_cb then after_cb(false) end + return + end + if after_cb then after_cb(true) end + end) + end + + -- Delete sub-options (parity with BookDetailView's three-item dropdown). + add_row(_("Remove from Cloud & Device"), function() + close() + UIManager:show(ConfirmBox:new{ + text = _("Remove this book from cloud and device?") + .. "\n\n" .. (row.title or ""), + ok_text = _("Remove"), + ok_callback = function() + local function finish_local() + if on_local then removeLocalFile(row) end + -- Tombstone push: matches Readest's `book.deletedAt = + -- Date.now() + pushLibrary()` for the 'both' delete. + -- Peers pulling /sync see deleted_at and stop showing + -- the row, even though we already removed our local + -- copy + cloud objects. + local now = math.floor(os.time() * 1000) + M._store:upsertBook({ + hash = row.hash, + title = row.title, + cloud_present = 0, + local_present = 0, + deleted_at = now, + _force_cloud_present = true, + }) + -- Build a tombstone wire row from the in-memory entry + -- row (which already has format/meta_hash/etc) plus + -- the deleted_at + bumped updated_at. + local tombstone = {} + for k, v in pairs(row) do tombstone[k] = v end + tombstone.deleted_at = now + tombstone.updated_at = now + syncbooks.pushBook(tombstone, { + sync_auth = opts.sync_auth, + sync_path = opts.sync_path, + settings = opts.settings, + }, function() M.refresh() end) + M.refresh() + end + if on_cloud then + doCloudDelete(function() finish_local() end) + else + finish_local() + end + end, + }) + end) + if on_cloud then + add_row(_("Remove from Cloud Only"), function() + close() + UIManager:show(ConfirmBox:new{ + text = _("Remove this book from the cloud only?") + .. "\n\n" .. (row.title or ""), + ok_text = _("Remove"), + ok_callback = function() + doCloudDelete(function(success) + if success then + -- Cloud objects gone; clear cloud_present but + -- leave the row visible if local_present=1. + M._store:upsertBook({ + hash = row.hash, + title = row.title, + cloud_present = 0, + _force_cloud_present = true, + }) + M.refresh() + end + end) + end, + }) + end) + end + if on_local then + add_row(_("Remove from Device Only"), function() + close() + UIManager:show(ConfirmBox:new{ + text = _("Remove the local copy of this book?") + .. "\n\n" .. (row.title or ""), + ok_text = _("Remove"), + ok_callback = function() + if removeLocalFile(row) then M.refresh() end + end, + }) + end) + end + + -- Upload: parity with BookDetailView's `book.downloadedAt && onUpload`. + -- Shown whenever the book is on device, even if it's already in the + -- cloud — same gating as BookDetailView (which uses `downloadedAt` + -- alone, not `downloadedAt && !uploadedAt`). Re-uploading is the + -- web's path for replacing a cloud copy after local edits. + if on_local then + add_row(_("Upload to Cloud"), function() + close() + local progress = InfoMessage:new{ + text = _("Uploading…") .. " " .. (row.title or ""), + } + UIManager:show(progress) + local DataStorage = require("datastorage") + syncbooks.uploadBook(row, { + sync_auth = opts.sync_auth, + sync_path = opts.sync_path, + settings = opts.settings, + covers_dir = DataStorage:getSettingsDir() .. "/readest_covers", + }, function(success, msg, status) + UIManager:close(progress) + if not success then + local text + if status == 403 and msg and msg:find("quota", 1, true) then + text = _("Storage quota exceeded.") + else + text = _("Upload failed.") + .. " (" .. tostring(msg or status) .. ")" + end + UIManager:show(InfoMessage:new{ text = text, timeout = 4 }) + return + end + -- Mirror cloudService.uploadBook's bookkeeping: clear + -- deleted_at, bump uploaded_at + updated_at, mark cloud + -- present, then push the row so peers see the metadata. + -- _clear_fields un-tombstones since Lua nil in the row + -- table would otherwise be preserved by upsertBook. + local now = math.floor(os.time() * 1000) + M._store:upsertBook({ + hash = row.hash, + title = row.title, + cloud_present = 1, + uploaded_at = now, + updated_at = now, + _clear_fields = { "deleted_at" }, + }) + local pushed = {} + for k, v in pairs(row) do pushed[k] = v end + pushed.cloud_present = 1 + pushed.uploaded_at = now + pushed.updated_at = now + pushed.deleted_at = nil + syncbooks.pushBook(pushed, { + sync_auth = opts.sync_auth, + sync_path = opts.sync_path, + settings = opts.settings, + }, function() M.refresh() end) + M.refresh() + end) + end) + end + + -- Download: parity with BookDetailView's `book.uploadedAt && onDownload`. + -- BookDetailView has a single Download from Cloud button; we expose + -- Cover / Book / All from the user's request since the koplugin can + -- usefully download just the cover (e.g. to refresh the preview) + -- without also fetching the full file. + if on_cloud then + if not on_local then + add_row(_("Download Book"), function() + close() + downloadBookOnly(row, opts) + end) + end + add_row(_("Download Cover"), function() + close() + downloadCoverOnly(row, opts) + end) + if not on_local then + add_row(_("Download All"), function() + close() + downloadCoverOnly(row, opts, function() + downloadBookOnly(row, opts) + end) + end) + end + end + + if #rows == 0 then return end + + dialog = ButtonDialog:new{ + title = row.title or "", + title_align = "center", + buttons = rows, + } + UIManager:show(dialog) +end + +return M diff --git a/apps/readest.koplugin/library/list_strip.lua b/apps/readest.koplugin/library/list_strip.lua new file mode 100644 index 00000000..3d09e1d9 --- /dev/null +++ b/apps/readest.koplugin/library/list_strip.lua @@ -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 diff --git a/apps/readest.koplugin/library/localscanner.lua b/apps/readest.koplugin/library/localscanner.lua new file mode 100644 index 00000000..528e7333 --- /dev/null +++ b/apps/readest.koplugin/library/localscanner.lua @@ -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 diff --git a/apps/readest.koplugin/library/syncbooks.lua b/apps/readest.koplugin/library/syncbooks.lua new file mode 100644 index 00000000..b0d144c3 --- /dev/null +++ b/apps/readest.koplugin/library/syncbooks.lua @@ -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 diff --git a/apps/readest.koplugin/locales/ar/translation.po b/apps/readest.koplugin/locales/ar/translation.po index 0b5ff1b2..8dd5ae0b 100644 --- a/apps/readest.koplugin/locales/ar/translation.po +++ b/apps/readest.koplugin/locales/ar/translation.po @@ -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 "فشل جلب تقدم القراءة" diff --git a/apps/readest.koplugin/locales/bn/translation.po b/apps/readest.koplugin/locales/bn/translation.po index 2e1c0ec9..cb6b0832 100644 --- a/apps/readest.koplugin/locales/bn/translation.po +++ b/apps/readest.koplugin/locales/bn/translation.po @@ -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 "পড়ার অগ্রগতি আনা ব্যর্থ" diff --git a/apps/readest.koplugin/locales/bo/translation.po b/apps/readest.koplugin/locales/bo/translation.po index 027895d3..65d148da 100644 --- a/apps/readest.koplugin/locales/bo/translation.po +++ b/apps/readest.koplugin/locales/bo/translation.po @@ -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 "ཀློག་པའི་འཕེལ་རིམ་ལེན་མ་ཐུབ།" diff --git a/apps/readest.koplugin/locales/de/translation.po b/apps/readest.koplugin/locales/de/translation.po index e2fe49d4..5a65c514 100644 --- a/apps/readest.koplugin/locales/de/translation.po +++ b/apps/readest.koplugin/locales/de/translation.po @@ -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" diff --git a/apps/readest.koplugin/locales/el/translation.po b/apps/readest.koplugin/locales/el/translation.po index f199c9f8..1f001b5e 100644 --- a/apps/readest.koplugin/locales/el/translation.po +++ b/apps/readest.koplugin/locales/el/translation.po @@ -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 "Η λήψη της προόδου ανάγνωσης απέτυχε" diff --git a/apps/readest.koplugin/locales/es/translation.po b/apps/readest.koplugin/locales/es/translation.po index 5d1685d8..24fd7c73 100644 --- a/apps/readest.koplugin/locales/es/translation.po +++ b/apps/readest.koplugin/locales/es/translation.po @@ -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" diff --git a/apps/readest.koplugin/locales/fa/translation.po b/apps/readest.koplugin/locales/fa/translation.po index ee1796d2..03d4f758 100644 --- a/apps/readest.koplugin/locales/fa/translation.po +++ b/apps/readest.koplugin/locales/fa/translation.po @@ -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 "دریافت پیشرفت مطالعه ناموفق بود" diff --git a/apps/readest.koplugin/locales/fr/translation.po b/apps/readest.koplugin/locales/fr/translation.po index 3fdd5766..9cd74ee1 100644 --- a/apps/readest.koplugin/locales/fr/translation.po +++ b/apps/readest.koplugin/locales/fr/translation.po @@ -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" diff --git a/apps/readest.koplugin/locales/he/translation.po b/apps/readest.koplugin/locales/he/translation.po index d7253730..385dfb42 100644 --- a/apps/readest.koplugin/locales/he/translation.po +++ b/apps/readest.koplugin/locales/he/translation.po @@ -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 "משיכת התקדמות הקריאה נכשלה" diff --git a/apps/readest.koplugin/locales/hi/translation.po b/apps/readest.koplugin/locales/hi/translation.po index ba533c4f..1ae868ca 100644 --- a/apps/readest.koplugin/locales/hi/translation.po +++ b/apps/readest.koplugin/locales/hi/translation.po @@ -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 "पठन प्रगति प्राप्त करने में विफल" diff --git a/apps/readest.koplugin/locales/hu/translation.po b/apps/readest.koplugin/locales/hu/translation.po index 53a08e9a..68294767 100644 --- a/apps/readest.koplugin/locales/hu/translation.po +++ b/apps/readest.koplugin/locales/hu/translation.po @@ -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" diff --git a/apps/readest.koplugin/locales/id/translation.po b/apps/readest.koplugin/locales/id/translation.po index 527bdf21..f6fed41a 100644 --- a/apps/readest.koplugin/locales/id/translation.po +++ b/apps/readest.koplugin/locales/id/translation.po @@ -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" diff --git a/apps/readest.koplugin/locales/it/translation.po b/apps/readest.koplugin/locales/it/translation.po index 2e931b53..313627e2 100644 --- a/apps/readest.koplugin/locales/it/translation.po +++ b/apps/readest.koplugin/locales/it/translation.po @@ -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" diff --git a/apps/readest.koplugin/locales/ja/translation.po b/apps/readest.koplugin/locales/ja/translation.po index 5fe98490..8a68781b 100644 --- a/apps/readest.koplugin/locales/ja/translation.po +++ b/apps/readest.koplugin/locales/ja/translation.po @@ -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 "読書の進捗の取得に失敗しました" diff --git a/apps/readest.koplugin/locales/ko/translation.po b/apps/readest.koplugin/locales/ko/translation.po index f02181e2..f82a7c27 100644 --- a/apps/readest.koplugin/locales/ko/translation.po +++ b/apps/readest.koplugin/locales/ko/translation.po @@ -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 "읽기 진행 상황 가져오기에 실패했습니다" diff --git a/apps/readest.koplugin/locales/ms/translation.po b/apps/readest.koplugin/locales/ms/translation.po index 4b069702..4f82c654 100644 --- a/apps/readest.koplugin/locales/ms/translation.po +++ b/apps/readest.koplugin/locales/ms/translation.po @@ -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" diff --git a/apps/readest.koplugin/locales/nl/translation.po b/apps/readest.koplugin/locales/nl/translation.po index db650d6b..dee47bc5 100644 --- a/apps/readest.koplugin/locales/nl/translation.po +++ b/apps/readest.koplugin/locales/nl/translation.po @@ -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" diff --git a/apps/readest.koplugin/locales/pl/translation.po b/apps/readest.koplugin/locales/pl/translation.po index b7a9da88..a9a3976a 100644 --- a/apps/readest.koplugin/locales/pl/translation.po +++ b/apps/readest.koplugin/locales/pl/translation.po @@ -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" diff --git a/apps/readest.koplugin/locales/pt/translation.po b/apps/readest.koplugin/locales/pt/translation.po index 1e83c7aa..023a8209 100644 --- a/apps/readest.koplugin/locales/pt/translation.po +++ b/apps/readest.koplugin/locales/pt/translation.po @@ -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" diff --git a/apps/readest.koplugin/locales/ro/translation.po b/apps/readest.koplugin/locales/ro/translation.po index 438175d1..0ceb0d36 100644 --- a/apps/readest.koplugin/locales/ro/translation.po +++ b/apps/readest.koplugin/locales/ro/translation.po @@ -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" diff --git a/apps/readest.koplugin/locales/ru/translation.po b/apps/readest.koplugin/locales/ru/translation.po index dd800d96..f4c4b4c2 100644 --- a/apps/readest.koplugin/locales/ru/translation.po +++ b/apps/readest.koplugin/locales/ru/translation.po @@ -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 "Не удалось получить прогресс чтения" diff --git a/apps/readest.koplugin/locales/si/translation.po b/apps/readest.koplugin/locales/si/translation.po index c9911bda..8f9c70c8 100644 --- a/apps/readest.koplugin/locales/si/translation.po +++ b/apps/readest.koplugin/locales/si/translation.po @@ -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 "කියවීමේ ප්‍රගතිය ලබාගැනීම අසාර්ථකයි" diff --git a/apps/readest.koplugin/locales/sl/translation.po b/apps/readest.koplugin/locales/sl/translation.po index f936d2d4..a9757ed1 100644 --- a/apps/readest.koplugin/locales/sl/translation.po +++ b/apps/readest.koplugin/locales/sl/translation.po @@ -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" diff --git a/apps/readest.koplugin/locales/sv/translation.po b/apps/readest.koplugin/locales/sv/translation.po index 6a81fa06..e2a14437 100644 --- a/apps/readest.koplugin/locales/sv/translation.po +++ b/apps/readest.koplugin/locales/sv/translation.po @@ -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" diff --git a/apps/readest.koplugin/locales/ta/translation.po b/apps/readest.koplugin/locales/ta/translation.po index 0deb56f2..c3d59fd8 100644 --- a/apps/readest.koplugin/locales/ta/translation.po +++ b/apps/readest.koplugin/locales/ta/translation.po @@ -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 "வாசிப்பு முன்னேற்றத்தைப் பெறுவதில் தோல்வி" diff --git a/apps/readest.koplugin/locales/th/translation.po b/apps/readest.koplugin/locales/th/translation.po index 772b4752..76b4a396 100644 --- a/apps/readest.koplugin/locales/th/translation.po +++ b/apps/readest.koplugin/locales/th/translation.po @@ -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 "ดึงความคืบหน้าการอ่านไม่สำเร็จ" diff --git a/apps/readest.koplugin/locales/tr/translation.po b/apps/readest.koplugin/locales/tr/translation.po index 26d6aab3..bc2fcd98 100644 --- a/apps/readest.koplugin/locales/tr/translation.po +++ b/apps/readest.koplugin/locales/tr/translation.po @@ -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ı" diff --git a/apps/readest.koplugin/locales/uk/translation.po b/apps/readest.koplugin/locales/uk/translation.po index 9c934443..cde7eac8 100644 --- a/apps/readest.koplugin/locales/uk/translation.po +++ b/apps/readest.koplugin/locales/uk/translation.po @@ -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 "Не вдалося отримати прогрес читання" diff --git a/apps/readest.koplugin/locales/vi/translation.po b/apps/readest.koplugin/locales/vi/translation.po index 097c61c9..0c8ea6af 100644 --- a/apps/readest.koplugin/locales/vi/translation.po +++ b/apps/readest.koplugin/locales/vi/translation.po @@ -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" diff --git a/apps/readest.koplugin/locales/zh-CN/translation.po b/apps/readest.koplugin/locales/zh-CN/translation.po index cd20b114..8d80e9bb 100644 --- a/apps/readest.koplugin/locales/zh-CN/translation.po +++ b/apps/readest.koplugin/locales/zh-CN/translation.po @@ -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 "拉取阅读进度失败" diff --git a/apps/readest.koplugin/locales/zh-TW/translation.po b/apps/readest.koplugin/locales/zh-TW/translation.po index 35cc986f..6272f46d 100644 --- a/apps/readest.koplugin/locales/zh-TW/translation.po +++ b/apps/readest.koplugin/locales/zh-TW/translation.po @@ -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 "拉取閱讀進度失敗" diff --git a/apps/readest.koplugin/main.lua b/apps/readest.koplugin/main.lua index 361239e7..b33b653b 100644 --- a/apps/readest.koplugin/main.lua +++ b/apps/readest.koplugin/main.lua @@ -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 diff --git a/apps/readest.koplugin/readest-sync-api.json b/apps/readest.koplugin/readest-sync-api.json index bfff8b05..683ad7b6 100644 --- a/apps/readest.koplugin/readest-sync-api.json +++ b/apps/readest.koplugin/readest-sync-api.json @@ -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] } } } diff --git a/apps/readest.koplugin/readestsync.lua b/apps/readest.koplugin/readestsync.lua index 742fb86d..f36eb3d8 100644 --- a/apps/readest.koplugin/readestsync.lua +++ b/apps/readest.koplugin/readestsync.lua @@ -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 \ No newline at end of file diff --git a/apps/readest.koplugin/scripts/build-koplugin.mjs b/apps/readest.koplugin/scripts/build-koplugin.mjs new file mode 100755 index 00000000..b8d8c02f --- /dev/null +++ b/apps/readest.koplugin/scripts/build-koplugin.mjs @@ -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(); diff --git a/apps/readest.koplugin/scripts/extract-i18n.js b/apps/readest.koplugin/scripts/extract-i18n.js index b774d19c8611f815caffe5ce850727be8225e697..f2c21d5d7c8d6b8aa42868a1df2f329ffa0a5f49 100644 GIT binary patch delta 678 zcmZva!D|#T6vi*2Fc(`Yf_QnS&Wza1-7<J7N~IKO6%QgyI(f4bolQdCi!yAN^>1+h zkwQ=YMdI0;CzDRMTcn49A<6fBzwaeK-~2hg_`S8PDmcL=c+y|7AE2>)j}3$Ddw3@L zAfeSg5(J~r!`q|xuMdwW2ZtvDPQPeJpe$b<sFe1UMY96MRDd`O#HdP;MgeyS<glrL zJ;q-PjKq7^#JuH<rdlN*lQX!O9vG7%c~PgGLj{dw+1she+F%;i)~qp)f_#<mikM!) zJcGDlh`|Pi8a{s7k1MV9P>joH*biV^Xi0dKDyo*JgK3idIn3dk<}UfY8ywP$NE;9q z<!B6`+gL6+2qR}Gh^1%x5G6$}@)FL^VY3T$T|;iH!pY345E1jc4v>3*P1Me_)y2a{ ztLn-9<w)9wIR6@)<W79H+Khx7|1veZjil*7`*?BI55)~Jq(KN5s4IjJ*H?Qpvy@R@ z8!Z5P!dUTa-BDATL{Y--?yX(|jaqk(k0ZY;>dJTITSUtAdF0?tqS?<Dt6y7tPydJ6 UysUCH+5Z0H@x9Bx+b?(i0br%!2><{9 delta 139 zcmezDx6^CGOM%H*!rI<x#as$NpjVWdn39rN6kM5?tP$W7>h9?m@8TJx36V_8%qdAN z($GlLRIs&ENYhKrODPV|EXmMN*UKqQRM&(ko4i4|L9HOMBttJNKQj->6deVi0jZig Y3hJdLX}T8bn%0vqiYRYR5)l*z040Gcx&QzG diff --git a/apps/readest-app/scripts/lint-koplugin.js b/apps/readest.koplugin/scripts/lint-koplugin.mjs similarity index 67% rename from apps/readest-app/scripts/lint-koplugin.js rename to apps/readest.koplugin/scripts/lint-koplugin.mjs index 2308bfa3..1ac57768 100644 --- a/apps/readest-app/scripts/lint-koplugin.js +++ b/apps/readest.koplugin/scripts/lint-koplugin.mjs @@ -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.'); diff --git a/apps/readest.koplugin/scripts/test-koplugin.mjs b/apps/readest.koplugin/scripts/test-koplugin.mjs new file mode 100644 index 00000000..5a753d34 --- /dev/null +++ b/apps/readest.koplugin/scripts/test-koplugin.mjs @@ -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); diff --git a/apps/readest.koplugin/spec/README.md b/apps/readest.koplugin/spec/README.md new file mode 100644 index 00000000..6e17f117 --- /dev/null +++ b/apps/readest.koplugin/spec/README.md @@ -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. diff --git a/apps/readest.koplugin/spec/library/coverprovider_spec.lua b/apps/readest.koplugin/spec/library/coverprovider_spec.lua new file mode 100644 index 00000000..44f4a67f --- /dev/null +++ b/apps/readest.koplugin/spec/library/coverprovider_spec.lua @@ -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) diff --git a/apps/readest.koplugin/spec/library/exts_spec.lua b/apps/readest.koplugin/spec/library/exts_spec.lua new file mode 100644 index 00000000..8c59739d --- /dev/null +++ b/apps/readest.koplugin/spec/library/exts_spec.lua @@ -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) diff --git a/apps/readest.koplugin/spec/library/librarystore_spec.lua b/apps/readest.koplugin/spec/library/librarystore_spec.lua new file mode 100644 index 00000000..04a5c7b9 --- /dev/null +++ b/apps/readest.koplugin/spec/library/librarystore_spec.lua @@ -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) diff --git a/apps/readest.koplugin/spec/library/localscanner_spec.lua b/apps/readest.koplugin/spec/library/localscanner_spec.lua new file mode 100644 index 00000000..555cbcaf --- /dev/null +++ b/apps/readest.koplugin/spec/library/localscanner_spec.lua @@ -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) diff --git a/apps/readest.koplugin/spec/library/smoke_spec.lua b/apps/readest.koplugin/spec/library/smoke_spec.lua new file mode 100644 index 00000000..a0712a1f --- /dev/null +++ b/apps/readest.koplugin/spec/library/smoke_spec.lua @@ -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) diff --git a/apps/readest.koplugin/spec/library/syncbooks_spec.lua b/apps/readest.koplugin/spec/library/syncbooks_spec.lua new file mode 100644 index 00000000..8c48048d --- /dev/null +++ b/apps/readest.koplugin/spec/library/syncbooks_spec.lua @@ -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) diff --git a/apps/readest.koplugin/spec/spec_helper.lua b/apps/readest.koplugin/spec/spec_helper.lua new file mode 100644 index 00000000..07cfe010 --- /dev/null +++ b/apps/readest.koplugin/spec/spec_helper.lua @@ -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 diff --git a/apps/readest.koplugin/syncannotations.lua b/apps/readest.koplugin/syncannotations.lua index f07d7ed8..50e1dad6 100644 --- a/apps/readest.koplugin/syncannotations.lua +++ b/apps/readest.koplugin/syncannotations.lua @@ -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 diff --git a/apps/readest.koplugin/syncauth.lua b/apps/readest.koplugin/syncauth.lua index 072cddfc..a73126be 100644 --- a/apps/readest.koplugin/syncauth.lua +++ b/apps/readest.koplugin/syncauth.lua @@ -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 diff --git a/apps/readest.koplugin/syncconfig.lua b/apps/readest.koplugin/syncconfig.lua index dc75e583..4d02bfa8 100644 --- a/apps/readest.koplugin/syncconfig.lua +++ b/apps/readest.koplugin/syncconfig.lua @@ -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"), diff --git a/package.json b/package.json index c2e8380e..c6d2ae19 100644 --- a/package.json +++ b/package.json @@ -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",