forked from akai/readest
feat(koplugin): Readest Library view in KOReader (#4056)
* feat(koplugin/library): data layer + busted harness + design doc - LibraryStore: per-user SQLite index merging cloud + local books by partial-md5 hash. listBooks/listBookshelfBooks/listBookshelfGroups, upsertBook with cloud_present/local_present OR-merge + _force/clear sentinels, parseSyncRow, getChangedBooks for tombstone push. - EXTS table mirroring web's document.ts. - busted harness with KOReader stubs (G_reader_settings, DataStorage, lua-ljsqlite3 against :memory:); spec_helper, exts_spec, smoke_spec, librarystore_spec covering schema, sort, group nesting, dedupe. - Library design doc + spec README. - pnpm test:lua wired through root + app package.json; lint-koplugin recurses into library/ + spec/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(koplugin/library): cloud sync — push, pull, upload, delete, downloads - Spore methods: pullBooks (incremental /sync), getDownloadUrl, getUploadUrl, listFiles, deleteFile. - syncbooks.lua: pushBook + pushChangedBooks (advances watermark to max(updated_at, deleted_at)), syncBooks(opts, mode) for push/pull/both, downloadBook + downloadCover (sync socket.http with file sink; cover download via fork+poll with single-slot queue + visible-page filter + coalesced refresh), uploadBook (presigned-PUT flow + best-effort cover), deleteCloudFiles (list-then-delete-each, mirrors cloudService.deleteBook). - SyncAuth.withFreshToken wrapper resolves the ensureClient race; 401/403 unified across syncconfig + syncannotations. - Cloud + local book covers shared by partial-md5 hash; cover.png cached at <settings>/readest_covers/<hash>.png with sentinel for known 404s. - syncbooks_spec covers row-to-wire conversion + file_key shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(koplugin/library): local discovery — sidecar scanner + cover provider - localscanner.lightScan iterates ReadHistory entries, reads partial_md5_checksum from .sdr sidecars, and upserts local rows. Slow filesystem walks deferred to fullSidecarWalk (24h-gated). - coverprovider wraps BookInfoManager:getBookInfo for local books with graceful FakeCover fallback when coverbrowser is absent. - localscanner_spec + coverprovider_spec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(koplugin/library): UI — widget, item, view menu, FileManager hooks - librarywidget: full-screen Menu mixed in with CoverMenu + Mosaic/List per zen_ui's group_view pattern. Title-bar tap → view menu, search via left icon, drill-in/back for grouping. Async cloud sync deferred via scheduleIn so the menu paints before HTTP fires. - libraryitem: BIM patch for cloud-only (readest-cloud://) and group (readest-group://) URIs; group-cover composer (2x2 mosaic for grid, same in list) with cache key derived from the actual first-N hashes for content-based invalidation; ListMenuItem update + paintTo patches for wider list-mode cover strip and cloud-up/down icon overlay. - libraryviewmenu: ButtonDialog with View/Group by/Sort by/Actions. Default Group by = Groups (parity with web), values authors/groups. - librarypaint: partial-page e-ink repaint shim adapted from zen_ui. - main.lua: Library menu entry, dispatcher actions (Open Library / Push / Pull as general; progress + annotations as reader-only), "Add to Readest" button in FileManager's long-press file dialog (dedupe by partial_md5; bumps updated_at when present, inserts a fresh local-only row otherwise; un-tombstones via _clear_fields). Push books on Library open when auto_sync is on, pull-only otherwise. - Long-press action sheet with Readest BookDetailView parity: Remove from Cloud & Device / Cloud Only / Device Only, Upload to Cloud, Download Book / Cover / All. - Cloud-down + cloud-up SVG icons (LiaCloudDownloadAltSolid / LiaCloudUploadAltSolid) painted in the right-side wpageinfo slot. - i18n catalog updated for new strings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(koplugin/library): split libraryitem into focused modules libraryitem.lua had grown to 1018 lines mixing five unrelated concerns. Split along the natural seams: cloud_covers — readest-cloud:// URI scheme, on-disk <hash>.png cache, single-slot async download queue, visible-page filter group_covers — readest-group:// URI scheme, 2x2 mosaic composer with content-derived cache key (first-N hashes), cell layout table cloud_icons — bundled cloud-up/cloud-down SVG loader, IconWidget cache, paint-overlay positioning list_strip — list-mode group row builder (4-cover wider strip replacing ListMenu's square cover slot) bim_patch — BookInfoManager:getBookInfo router (cloud / group / local) + ListMenuItem update + paintTo patches; owns the _library_local_paths set and orig BIM reference libraryitem.lua is now 141 lines: just the entry-table constructors (entry_from_row, entry_from_group, entry_back) plus thin install / set_visible_hashes delegates. Each new module is 88-216 lines. No behavior change — same 113 specs pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(koplugin): co-locate dev tooling, ship-zip exclusions, CI job split - apps/readest.koplugin/scripts/build-koplugin.mjs: local sideloading build with the same exclusions the release workflow uses. - Move lint-koplugin + test-koplugin from apps/readest-app/scripts/ to apps/readest.koplugin/scripts/. All koplugin dev tooling now lives with the koplugin and is excluded from the published release zip. - Rename to .mjs so Node treats them as ESM without the reparse warning (the i18n CommonJS scripts stay .js). - Release workflow: zip -r exclusions for scripts/, docs/, spec/, .busted so dev artifacts don't ship to end users. - PR workflow: split build_web_app into build_web_app + test_web_app for parallelism. The test job installs luarocks + busted + lsqlite3complete and runs pnpm test:lua. test-koplugin.mjs now hard-fails (instead of soft-skipping) when CI=true and a tool is missing — a broken CI toolchain previously exited 0 silently. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -98,8 +98,19 @@ jobs:
|
||||
meta_file="apps/readest.koplugin/_meta.lua"
|
||||
perl -i -pe "s/^}/ version = \"${version}\",\n}/" "${meta_file}"
|
||||
|
||||
# Exclude dev-only artifacts from the published plugin zip:
|
||||
# scripts/ — i18n + build helpers
|
||||
# docs/ — design notes
|
||||
# spec/ — busted test suite
|
||||
# .busted — busted runner config
|
||||
# Mirror these in apps/readest.koplugin/scripts/build-koplugin.mjs
|
||||
# for local builds.
|
||||
cd apps
|
||||
zip -r ../${plugin_zip} readest.koplugin
|
||||
zip -r ../${plugin_zip} readest.koplugin \
|
||||
-x 'readest.koplugin/scripts/*' \
|
||||
'readest.koplugin/docs/*' \
|
||||
'readest.koplugin/spec/*' \
|
||||
'readest.koplugin/.busted'
|
||||
cd ..
|
||||
|
||||
echo "Uploading ${plugin_zip} to GitHub release"
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Before marking work complete, all applicable checks must pass:
|
||||
|
||||
1. `pnpm test` — unit tests
|
||||
2. `pnpm lint` — ESLint
|
||||
3. `pnpm fmt:check` — Rust format check (only when `src-tauri/` files changed)
|
||||
4. `pnpm clippy:check` — Rust lint (only when `src-tauri/` files changed)
|
||||
1. `pnpm test` — unit tests (vitest)
|
||||
2. `pnpm lint` — Biome + tsgo (also runs `pnpm lint:lua` if luajit is installed)
|
||||
3. `pnpm test:lua` — busted unit tests for `apps/readest.koplugin/spec/` (only when koplugin Lua files changed; soft-skips when busted/luajit not installed)
|
||||
4. `pnpm fmt:check` — Rust format check (only when `src-tauri/` files changed)
|
||||
5. `pnpm clippy:check` — Rust lint (only when `src-tauri/` files changed)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
return {
|
||||
default = {
|
||||
ROOT = { "spec" },
|
||||
pattern = "_spec",
|
||||
helper = "spec/spec_helper",
|
||||
verbose = true,
|
||||
["recursive-tests"] = true,
|
||||
},
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<path d="M 16 6 C 13.351563 6 11.050781 7.238281 9.40625 9.0625 C 9.269531 9.046875 9.148438 9 9 9 C 6.800781 9 5 10.800781 5 13 C 3.269531 14.054688 2 15.835938 2 18 C 2 21.300781 4.699219 24 8 24 L 13 24 L 13 22 L 8 22 C 5.78125 22 4 20.21875 4 18 C 4 16.339844 5.007813 14.921875 6.4375 14.3125 L 7.125 14.03125 L 7.03125 13.28125 C 7.011719 13.117188 7 13.023438 7 13 C 7 11.882813 7.882813 11 9 11 C 9.140625 11 9.296875 11.019531 9.46875 11.0625 L 10.09375 11.21875 L 10.46875 10.71875 C 11.75 9.074219 13.75 8 16 8 C 19.277344 8 22.011719 10.253906 22.78125 13.28125 L 22.96875 14.0625 L 23.8125 14.03125 C 24.023438 14.019531 24.070313 14 24 14 C 26.21875 14 28 15.78125 28 18 C 28 20.21875 26.21875 22 24 22 L 19 22 L 19 24 L 24 24 C 27.300781 24 30 21.300781 30 18 C 30 14.84375 27.511719 12.316406 24.40625 12.09375 C 23.183594 8.574219 19.925781 6 16 6 Z M 15 18 L 15 26 L 12 26 L 16 30 L 20 26 L 17 26 L 17 18 Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<path d="M 16 7 C 13.351563 7 11.050781 8.238281 9.40625 10.0625 C 9.269531 10.046875 9.148438 10 9 10 C 6.800781 10 5 11.800781 5 14 C 3.269531 15.054688 2 16.835938 2 19 C 2 22.300781 4.699219 25 8 25 L 13 25 L 13 23 L 8 23 C 5.78125 23 4 21.21875 4 19 C 4 17.339844 5.007813 15.921875 6.4375 15.3125 L 7.125 15.03125 L 7.03125 14.28125 C 7.011719 14.117188 7 14.023438 7 14 C 7 12.882813 7.882813 12 9 12 C 9.140625 12 9.296875 12.019531 9.46875 12.0625 L 10.09375 12.21875 L 10.46875 11.71875 C 11.75 10.074219 13.75 9 16 9 C 19.277344 9 22.011719 11.253906 22.78125 14.28125 L 22.96875 15.0625 L 23.8125 15.03125 C 24.023438 15.019531 24.070313 15 24 15 C 26.21875 15 28 16.78125 28 19 C 28 21.21875 26.21875 23 24 23 L 19 23 L 19 25 L 24 25 C 27.300781 25 30 22.300781 30 19 C 30 15.84375 27.511719 13.316406 24.40625 13.09375 C 23.183594 9.574219 19.925781 7 16 7 Z M 16 15 L 12 19 L 15 19 L 15 27 L 17 27 L 17 19 L 20 19 Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,216 @@
|
||||
-- bim_patch.lua
|
||||
-- Two global monkey-patches that make our cloud-only / group entries
|
||||
-- coexist with KOReader's coverbrowser pipeline:
|
||||
--
|
||||
-- 1. BookInfoManager:getBookInfo — intercepts readest-cloud:// and
|
||||
-- readest-group:// URIs. Without this, MosaicMenuItem's
|
||||
-- "info incomplete → schedule background extraction" path fires;
|
||||
-- BIM forks a subprocess that crashes at bookinfomanager.lua:492
|
||||
-- trying to lfs.attributes the synthetic URI.
|
||||
--
|
||||
-- 2. ListMenuItem:update + paintTo — list-mode group rows use a
|
||||
-- custom widget tree (4-cell cover strip), and book rows get a
|
||||
-- cloud-up/cloud-down icon overlay below the format text.
|
||||
--
|
||||
-- ListMenuItem is `local` to coverbrowser/listmenu.lua, so we reach
|
||||
-- it via debug.getupvalue on the exported _updateItemsBuildUI mixin.
|
||||
|
||||
local logger = require("logger")
|
||||
local cloud_covers = require("library.cloud_covers")
|
||||
local group_covers = require("library.group_covers")
|
||||
local cloud_icons = require("library.cloud_icons")
|
||||
local list_strip = require("library.list_strip")
|
||||
|
||||
local M = {}
|
||||
|
||||
local _bim_patched = false
|
||||
local _list_item_patched = false
|
||||
local _orig_get_book_info = nil -- captured pre-patch; needed by list_strip
|
||||
|
||||
-- Tracks file_paths that came from our LibraryStore (= entries we
|
||||
-- render in the Library widget). The BIM patch tags returned info
|
||||
-- with _no_provider so ListMenuItem.update renders mandatory verbatim
|
||||
-- (the format string), keeping right-side text right-aligned with
|
||||
-- cloud rows that already use _no_provider.
|
||||
local _library_local_paths = {}
|
||||
|
||||
-- Sentinel used by entry_from_row to flag cloud-only rows. Re-exported
|
||||
-- here so the patches can read it without a circular libraryitem import.
|
||||
M.CLOUD_ONLY_FLAG = "_readest_cloud_only"
|
||||
M.LOCAL_ONLY_FLAG = "_readest_local_only"
|
||||
|
||||
function M.register_local_path(path)
|
||||
_library_local_paths[path] = true
|
||||
end
|
||||
|
||||
function M.orig_get_book_info()
|
||||
return _orig_get_book_info
|
||||
end
|
||||
|
||||
-- Patch BIM:getBookInfo with a router that dispatches on URI prefix.
|
||||
-- Idempotent.
|
||||
local function patch_bim(opts)
|
||||
if _bim_patched then return end
|
||||
local ok, BIM = pcall(require, "bookinfomanager")
|
||||
if not ok or not BIM then
|
||||
logger.warn("ReadestLibrary bim_patch: bookinfomanager not available")
|
||||
return
|
||||
end
|
||||
_bim_patched = true
|
||||
_orig_get_book_info = BIM.getBookInfo
|
||||
|
||||
local function build_cloud_info(filepath, do_cover_image)
|
||||
local hash = cloud_covers.hash_from_uri(filepath)
|
||||
local meta = cloud_covers.get_meta(hash)
|
||||
local info = {
|
||||
has_meta = true,
|
||||
cover_fetched = true,
|
||||
ignore_cover = false,
|
||||
title = meta.title,
|
||||
authors = meta.author,
|
||||
has_cover = false,
|
||||
-- Render mandatory verbatim (no "<filetype> size" prefix).
|
||||
_no_provider = true,
|
||||
}
|
||||
if do_cover_image then
|
||||
local bb = cloud_covers.load_cover_bb(hash)
|
||||
if bb then
|
||||
local w, h = bb:getWidth(), bb:getHeight()
|
||||
info.cover_bb = bb
|
||||
info.cover_w = w
|
||||
info.cover_h = h
|
||||
-- BookInfoManager.isCachedCoverInvalid (bookinfomanager.lua:1017)
|
||||
-- crashes if cover_sizetag is nil. Format is "<w>x<h>".
|
||||
info.cover_sizetag = w .. "x" .. h
|
||||
info.has_cover = true
|
||||
else
|
||||
-- Lazy fetch: only currently-visible cells trigger.
|
||||
cloud_covers.trigger_download(hash)
|
||||
end
|
||||
end
|
||||
return info
|
||||
end
|
||||
|
||||
local function build_group_info(filepath, do_cover_image)
|
||||
local group_by, value, cache_key, shape = group_covers.parse_uri(filepath)
|
||||
local meta = cloud_covers.get_meta(filepath)
|
||||
local info = {
|
||||
has_meta = true,
|
||||
cover_fetched = true,
|
||||
ignore_cover = false,
|
||||
title = meta.title,
|
||||
authors = meta.author,
|
||||
has_cover = false,
|
||||
_no_provider = true,
|
||||
}
|
||||
if do_cover_image and group_by and value and cache_key then
|
||||
local LibraryWidget = package.loaded["library.librarywidget"]
|
||||
local store = LibraryWidget and LibraryWidget._store
|
||||
local settings = M._opts and M._opts.settings or {}
|
||||
local bb = group_covers.serve_or_compose(
|
||||
group_by, value, cache_key, shape,
|
||||
store, settings, _orig_get_book_info, BIM)
|
||||
if bb then
|
||||
local w, h = bb:getWidth(), bb:getHeight()
|
||||
info.cover_bb = bb
|
||||
info.cover_w = w
|
||||
info.cover_h = h
|
||||
info.cover_sizetag = w .. "x" .. h
|
||||
info.has_cover = true
|
||||
end
|
||||
end
|
||||
return info
|
||||
end
|
||||
|
||||
function BIM:getBookInfo(filepath, do_cover_image)
|
||||
if type(filepath) == "string" then
|
||||
if filepath:sub(1, #cloud_covers.URI_PREFIX) == cloud_covers.URI_PREFIX then
|
||||
return build_cloud_info(filepath, do_cover_image)
|
||||
end
|
||||
if filepath:sub(1, #group_covers.URI_PREFIX) == group_covers.URI_PREFIX then
|
||||
return build_group_info(filepath, do_cover_image)
|
||||
end
|
||||
end
|
||||
-- Real local file: forward to the original BIM, then add
|
||||
-- _no_provider for paths that came from our LibraryStore so
|
||||
-- the right-side text right-aligns with cloud rows. Shallow
|
||||
-- copy first so we don't mutate BIM's cached entry.
|
||||
local result = _orig_get_book_info(self, filepath, do_cover_image)
|
||||
if result and type(filepath) == "string" and _library_local_paths[filepath] then
|
||||
local copy = {}
|
||||
for k, v in pairs(result) do copy[k] = v end
|
||||
copy._no_provider = true
|
||||
return copy
|
||||
end
|
||||
return result
|
||||
end
|
||||
end
|
||||
|
||||
-- Locate listmenu's local ListMenuItem class via its captured upvalue
|
||||
-- on the exported _updateItemsBuildUI mixin. Cheapest path that
|
||||
-- doesn't require modifying coverbrowser.koplugin or copy-pasting the
|
||||
-- ~50-line build loop.
|
||||
local function patch_list_menu_item()
|
||||
if _list_item_patched then return end
|
||||
local debug = require("debug")
|
||||
local ok, ListMenu = pcall(require, "listmenu")
|
||||
if not ok or type(ListMenu._updateItemsBuildUI) ~= "function" then return end
|
||||
local ListMenuItem
|
||||
for i = 1, 50 do
|
||||
local name, val = debug.getupvalue(ListMenu._updateItemsBuildUI, i)
|
||||
if not name then break end
|
||||
if name == "ListMenuItem" and type(val) == "table" then
|
||||
ListMenuItem = val
|
||||
break
|
||||
end
|
||||
end
|
||||
if not ListMenuItem or type(ListMenuItem.update) ~= "function" then
|
||||
logger.warn("ReadestLibrary: couldn't locate ListMenuItem class for patching")
|
||||
return
|
||||
end
|
||||
|
||||
-- Custom group-row widget tree (wider cover strip).
|
||||
local orig_update = ListMenuItem.update
|
||||
function ListMenuItem:update()
|
||||
if self.entry and self.entry._readest_group then
|
||||
local LibraryWidget = package.loaded["library.librarywidget"]
|
||||
return list_strip.build(self, {
|
||||
store = LibraryWidget and LibraryWidget._store,
|
||||
settings = M._opts and M._opts.settings,
|
||||
orig_getBookInfo = _orig_get_book_info,
|
||||
})
|
||||
end
|
||||
return orig_update(self)
|
||||
end
|
||||
|
||||
-- Cloud icon overlay painted on top of the standard widget tree.
|
||||
-- cloud-only (cloud_present=1, local_present=0) → download icon
|
||||
-- local-only (cloud_present=0, local_present=1) → upload icon
|
||||
local orig_paint = ListMenuItem.paintTo
|
||||
function ListMenuItem:paintTo(bb, x, y)
|
||||
orig_paint(self, bb, x, y)
|
||||
if not self.entry then return end
|
||||
if self.entry[M.CLOUD_ONLY_FLAG] and cloud_icons.has_icon("dl") then
|
||||
cloud_icons.paint(self, bb, x, y, "dl")
|
||||
elseif self.entry[M.LOCAL_ONLY_FLAG] and cloud_icons.has_icon("up") then
|
||||
cloud_icons.paint(self, bb, x, y, "up")
|
||||
end
|
||||
end
|
||||
_list_item_patched = true
|
||||
logger.info("ReadestLibrary: patched ListMenuItem update + paintTo")
|
||||
end
|
||||
|
||||
function M.install(opts)
|
||||
M._opts = opts or {}
|
||||
cloud_covers.set_opts(M._opts)
|
||||
logger.info("ReadestLibrary bim_patch.install: opts="
|
||||
.. (opts and "set" or "nil")
|
||||
.. " sync_auth=" .. tostring(opts and opts.sync_auth ~= nil)
|
||||
.. " bim_patched_before=" .. tostring(_bim_patched))
|
||||
-- Both patches are idempotent so the order + repeated calls are
|
||||
-- safe. ListMenuItem first since it doesn't need BIM.
|
||||
patch_list_menu_item()
|
||||
patch_bim()
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,213 @@
|
||||
-- cloud_covers.lua
|
||||
-- Per-book cover lifecycle for cloud-only rows. Owns the on-disk
|
||||
-- <hash>.png cache, the synthetic readest-cloud:// URI scheme, and
|
||||
-- the single-slot async download queue that fetches missing covers
|
||||
-- from Readest storage when their cells become visible.
|
||||
--
|
||||
-- The disk cache is shared with hybrid (cloud+local) rows so a
|
||||
-- previously-downloaded cloud cover gets reused for the local
|
||||
-- presentation of the same hash without a re-extraction pass.
|
||||
|
||||
local logger = require("logger")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.URI_PREFIX = "readest-cloud://"
|
||||
|
||||
-- Synthetic-info metadata cache: keyed by either hash (cloud-only
|
||||
-- entries) or the full URI string (group entries). Values:
|
||||
-- { title, author }
|
||||
-- The patched BIM reads these to fill in title/authors so MosaicMenu's
|
||||
-- FakeCover renders meaningful text instead of "?".
|
||||
local _meta = {}
|
||||
|
||||
-- Download lifecycle state
|
||||
local _cover_pending = {} -- hash → true while a download is in flight
|
||||
local _missing_covers = {} -- hash → true after a 404 (don't keep retrying)
|
||||
local _visible_hashes = nil -- set of hashes on the current Menu page; nil = no filter
|
||||
local _refresh_pending = false -- coalesces multiple cover-completion refreshes
|
||||
local _download_queue = {} -- FIFO list of pending hashes
|
||||
local _downloading = false -- gate: only one socket.http active at a time
|
||||
|
||||
-- Sync-auth opts (set via M.set_opts at install time). Holds
|
||||
-- sync_auth, sync_path, settings — needed to drive syncbooks.downloadCover.
|
||||
local _opts = nil
|
||||
|
||||
function M.set_opts(opts)
|
||||
_opts = opts
|
||||
end
|
||||
|
||||
function M.set_meta(key, meta)
|
||||
_meta[key] = meta
|
||||
end
|
||||
|
||||
function M.get_meta(key)
|
||||
return _meta[key] or {}
|
||||
end
|
||||
|
||||
function M.covers_dir()
|
||||
local DataStorage = require("datastorage")
|
||||
return DataStorage:getSettingsDir() .. "/readest_covers"
|
||||
end
|
||||
|
||||
local function cover_path_for(hash)
|
||||
return M.covers_dir() .. "/" .. hash .. ".png"
|
||||
end
|
||||
|
||||
-- Strip the .ext suffix so callers get just the partial-md5. The .ext
|
||||
-- is encoded into the URI so listmenu's filemanagerutil.splitFileNameType
|
||||
-- returns a non-nil filetype (listmenu.lua:316); without it the
|
||||
-- right-column composition crashes on string concat.
|
||||
function M.hash_from_uri(filepath)
|
||||
local rest = filepath:sub(#M.URI_PREFIX + 1)
|
||||
return (rest:match("^([^.]+)") or rest)
|
||||
end
|
||||
|
||||
-- Load <hash>.png from disk into a fresh blitbuffer. Returns nil if the
|
||||
-- file doesn't exist or fails to decode. Caller owns the bb (ImageWidget
|
||||
-- will free it). No in-memory cache — ImageWidget treats the bb as
|
||||
-- disposable, so sharing one across paints leads to use-after-free.
|
||||
function M.load_cover_bb(hash)
|
||||
local lfs = require("libs/libkoreader-lfs")
|
||||
local path = cover_path_for(hash)
|
||||
if lfs.attributes(path, "mode") ~= "file" then return nil end
|
||||
local ok, RenderImage = pcall(require, "ui/renderimage")
|
||||
if not ok then return nil end
|
||||
local ok2, bb = pcall(RenderImage.renderImageFile, RenderImage, path, false)
|
||||
if not ok2 or not bb then return nil end
|
||||
return bb
|
||||
end
|
||||
|
||||
-- "<hash8> '<title>'" formatted log tag — searchable by either id.
|
||||
local function tag_for(hash)
|
||||
local meta = _meta[hash] or {}
|
||||
return hash:sub(1, 8) .. " '" .. tostring(meta.title or "?") .. "'"
|
||||
end
|
||||
|
||||
-- Pump the next entry off _download_queue. Re-entrant-safe via the
|
||||
-- _downloading gate. Filters known-404 hashes and hashes that have
|
||||
-- scrolled off-screen since they were enqueued.
|
||||
local function process_queue()
|
||||
if _downloading then return end
|
||||
local hash
|
||||
repeat
|
||||
hash = table.remove(_download_queue, 1)
|
||||
if not hash then return end
|
||||
if _missing_covers[hash] then
|
||||
_cover_pending[hash] = nil
|
||||
hash = nil
|
||||
elseif _visible_hashes and not _visible_hashes[hash] then
|
||||
logger.dbg("ReadestLibrary cover dequeue skip: " .. tag_for(hash)
|
||||
.. " no longer on visible page")
|
||||
_cover_pending[hash] = nil
|
||||
hash = nil
|
||||
end
|
||||
until hash
|
||||
|
||||
_downloading = true
|
||||
logger.info("ReadestLibrary cover download: starting " .. tag_for(hash))
|
||||
local syncbooks = require("library.syncbooks")
|
||||
syncbooks.downloadCover(
|
||||
{ hash = hash },
|
||||
{
|
||||
sync_auth = _opts and _opts.sync_auth,
|
||||
sync_path = _opts and _opts.sync_path,
|
||||
settings = _opts and _opts.settings,
|
||||
covers_dir = M.covers_dir(),
|
||||
},
|
||||
function(success, path_or_err, status)
|
||||
_cover_pending[hash] = nil
|
||||
_downloading = false
|
||||
if not success then
|
||||
if status == 404 then
|
||||
_missing_covers[hash] = true
|
||||
logger.info("ReadestLibrary cover " .. tag_for(hash)
|
||||
.. " — no cover on server (404), won't retry")
|
||||
else
|
||||
logger.warn("ReadestLibrary cover " .. tag_for(hash)
|
||||
.. " download failed: " .. tostring(path_or_err)
|
||||
.. " status=" .. tostring(status))
|
||||
end
|
||||
else
|
||||
logger.info("ReadestLibrary cover " .. tag_for(hash)
|
||||
.. " saved → " .. tostring(path_or_err))
|
||||
-- Coalesce refresh: multiple covers landing in the same
|
||||
-- tick still get one repaint, not N flickering redraws.
|
||||
if not _refresh_pending then
|
||||
_refresh_pending = true
|
||||
local UIManager = require("ui/uimanager")
|
||||
UIManager:nextTick(function()
|
||||
_refresh_pending = false
|
||||
local ok, LibraryWidget = pcall(require, "library.librarywidget")
|
||||
if ok and LibraryWidget._menu then LibraryWidget.refresh() end
|
||||
end)
|
||||
end
|
||||
end
|
||||
-- Yield to the UI loop before pumping the next one.
|
||||
local UIManager = require("ui/uimanager")
|
||||
UIManager:nextTick(process_queue)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Idempotent against in-flight requests and known-404 hashes. Only
|
||||
-- fires for hashes the caller has marked as visible via M.set_visible_hashes
|
||||
-- — otherwise paint stragglers / poll loops for off-screen items would
|
||||
-- queue downloads the user can't see.
|
||||
function M.trigger_download(hash)
|
||||
if _cover_pending[hash] then
|
||||
logger.dbg("ReadestLibrary cover skip: " .. tag_for(hash) .. " already in flight")
|
||||
return
|
||||
end
|
||||
if _missing_covers[hash] then
|
||||
logger.dbg("ReadestLibrary cover skip: " .. tag_for(hash) .. " known 404")
|
||||
return
|
||||
end
|
||||
if not _opts or not _opts.sync_auth then
|
||||
logger.warn("ReadestLibrary cover skip: " .. tag_for(hash)
|
||||
.. " — set_opts not called yet")
|
||||
return
|
||||
end
|
||||
if _visible_hashes and not _visible_hashes[hash] then
|
||||
logger.dbg("ReadestLibrary cover skip: " .. tag_for(hash) .. " not on visible page")
|
||||
return
|
||||
end
|
||||
|
||||
_cover_pending[hash] = true
|
||||
table.insert(_download_queue, hash)
|
||||
logger.dbg("ReadestLibrary cover queued: " .. tag_for(hash)
|
||||
.. " (queue len=" .. #_download_queue .. ")")
|
||||
process_queue()
|
||||
end
|
||||
|
||||
-- Set of hashes on the current Menu page; only these may trigger
|
||||
-- downloads. Pass nil to disable the filter (e.g. when the Library
|
||||
-- closes and the patched BIM might still be invoked from elsewhere).
|
||||
function M.set_visible_hashes(menu, cloud_only_flag)
|
||||
if not menu then
|
||||
logger.dbg("ReadestLibrary set_visible_hashes: cleared (menu nil)")
|
||||
_visible_hashes = nil
|
||||
return
|
||||
end
|
||||
local set = {}
|
||||
local count = 0
|
||||
local page = menu.page or 1
|
||||
local perpage = menu.perpage or 1
|
||||
local items = menu.item_table or {}
|
||||
local first = (page - 1) * perpage + 1
|
||||
local last = math.min(first + perpage - 1, #items)
|
||||
for i = first, last do
|
||||
local entry = items[i]
|
||||
if entry and entry[cloud_only_flag] and type(entry.file) == "string" then
|
||||
local hash = M.hash_from_uri(entry.file)
|
||||
set[hash] = true
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
_visible_hashes = set
|
||||
logger.info("ReadestLibrary set_visible_hashes: page=" .. page
|
||||
.. " range=" .. first .. ".." .. last
|
||||
.. " cloud_only_visible=" .. count
|
||||
.. " (item_table size=" .. #items .. ")")
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,88 @@
|
||||
-- cloud_icons.lua
|
||||
-- Per-row cloud-up/cloud-down overlay icons painted on top of the
|
||||
-- standard ListMenuItem widget tree. Loaded once from bundled SVGs
|
||||
-- and cached as IconWidget instances for reuse across all rows.
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Resolve apps/readest.koplugin root via debug.getinfo on this file's
|
||||
-- own source path (same trick as zen_ui's plugin_root.lua). Needed
|
||||
-- because our bundled icons aren't in any of KOReader's ICONS_DIRS,
|
||||
-- so we load them by absolute path through ImageWidget instead of
|
||||
-- IconWidget's name-based lookup.
|
||||
local _plugin_root = (function()
|
||||
local src = debug.getinfo(1, "S").source or ""
|
||||
local path = (src:sub(1, 1) == "@")
|
||||
and src:sub(2):match("^(.*)/library/[^/]+$") or nil
|
||||
if path and path:sub(1, 1) ~= "/" then
|
||||
local ok, lfs = pcall(require, "libs/libkoreader-lfs")
|
||||
local cwd = ok and lfs and lfs.currentdir()
|
||||
if cwd then path = cwd .. "/" .. path end
|
||||
end
|
||||
return path
|
||||
end)()
|
||||
|
||||
local ICON_FILES = {
|
||||
dl = _plugin_root and (_plugin_root .. "/icons/cloud_download.svg"),
|
||||
up = _plugin_root and (_plugin_root .. "/icons/cloud_upload.svg"),
|
||||
}
|
||||
|
||||
-- Per-icon cache: {key → {widget, size_loaded}}. IconWidget loads +
|
||||
-- caches its bb on first render so we only pay the SVG decode once
|
||||
-- per icon size.
|
||||
local _cache = {}
|
||||
|
||||
function M.has_icon(kind)
|
||||
return ICON_FILES[kind] ~= nil
|
||||
end
|
||||
|
||||
local function get_widget(kind, target_size)
|
||||
local entry = _cache[kind]
|
||||
if entry and entry.size_loaded == target_size then
|
||||
return entry.widget
|
||||
end
|
||||
if entry and entry.widget then
|
||||
local prev = entry.widget
|
||||
entry.widget = nil
|
||||
pcall(function() prev:free() end)
|
||||
end
|
||||
local file = ICON_FILES[kind]
|
||||
if not file then return nil end
|
||||
local ok, ImageWidget = pcall(require, "ui/widget/imagewidget")
|
||||
if not ok then return nil end
|
||||
local widget = ImageWidget:new{
|
||||
file = file,
|
||||
width = target_size,
|
||||
height = target_size,
|
||||
scale_factor = 0, -- aspect-preserving fit
|
||||
alpha = true, -- preserve SVG transparency
|
||||
is_icon = true,
|
||||
}
|
||||
_cache[kind] = { widget = widget, size_loaded = target_size }
|
||||
return widget
|
||||
end
|
||||
|
||||
-- Paint the cloud icon at the right edge of the row, in the slot
|
||||
-- where ListMenuItem normally draws its second line of right-side
|
||||
-- text (wpageinfo, e.g. "1% of 1424 pages"). For row height dimen.h,
|
||||
-- the standard wright VerticalGroup is roughly:
|
||||
-- VerticalSpan(2) + fileinfo(~h*0.28) + pageinfo(~h*0.28)
|
||||
-- center-aligned, which lands pageinfo at ~y + 0.5*h. Mirroring that
|
||||
-- keeps the format label and the cloud icon visually stacked at the
|
||||
-- right edge with consistent padding.
|
||||
function M.paint(item, bb, x, y, kind)
|
||||
local Screen = require("device").screen
|
||||
local icon_size = math.floor(item.height * 0.28)
|
||||
local icon = get_widget(kind, icon_size)
|
||||
if not icon then return end
|
||||
-- _render so getSize returns the actual scaled dims, not the
|
||||
-- requested width/height.
|
||||
icon:_render()
|
||||
local s = icon:getSize()
|
||||
local pad_right = Screen:scaleBySize(10)
|
||||
local icon_x = x + item.width - pad_right - s.w
|
||||
local icon_y = y + math.floor(item.height * 0.5)
|
||||
icon:paintTo(bb, icon_x, icon_y)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,116 @@
|
||||
-- coverprovider.lua
|
||||
-- Resolves a Library row to a renderable cover. For local books we lean on
|
||||
-- the bundled coverbrowser.koplugin's BookInfoManager (it already has a
|
||||
-- battle-tested extraction subprocess + zstd-compressed BLOB cache); for
|
||||
-- cloud-only books we download `cover.png` from Readest storage and render
|
||||
-- it via plain ImageWidget{file=path}.
|
||||
--
|
||||
-- Hard dependency on coverbrowser, mirroring zen_ui's pattern: at plugin
|
||||
-- init we check `coverbrowser_loaded()`. If the user has it disabled, the
|
||||
-- caller offers to enable it; until then, every cell renders FakeCover
|
||||
-- (no degraded grid mode).
|
||||
--
|
||||
-- The pure helpers (coverbrowser_loaded, cached_cover_path, MISSING) are
|
||||
-- exported and unit-tested. The actual blitbuffer-returning calls
|
||||
-- (get_local_cover, get_cloud_cover) require live KOReader and are
|
||||
-- exercised manually.
|
||||
|
||||
local logger = require("logger")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Constants
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Sentinel written into books.cover_path when a cloud cover download
|
||||
-- returned 404. Distinguishable from any real path so callers can short-
|
||||
-- circuit FakeCover rendering without re-attempting the download every
|
||||
-- frame.
|
||||
M.MISSING = "_missing"
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- coverbrowser_loaded
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- True if `require("covermenu")` will resolve. coverbrowser.koplugin is the
|
||||
-- only thing that ships the covermenu module, so this doubles as a "is the
|
||||
-- plugin enabled?" check.
|
||||
function M.coverbrowser_loaded()
|
||||
if package.loaded["covermenu"] then return true end
|
||||
if package.preload["covermenu"] then return true end
|
||||
-- pcall(require) costs <1ms but populates package.loaded on success;
|
||||
-- callers shouldn't pay that cost twice.
|
||||
local ok = pcall(require, "covermenu")
|
||||
return ok == true
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- cached_cover_path
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Where we store a downloaded cloud cover. Flat layout under the plugin's
|
||||
-- cache dir, keyed by hash so collisions can't happen.
|
||||
function M.cached_cover_path(covers_dir, hash)
|
||||
if not covers_dir or covers_dir == "" then return nil end
|
||||
if not hash or hash == "" then return nil end
|
||||
return covers_dir .. "/" .. hash .. ".png"
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- get_local_cover(file_path) → blitbuffer or nil
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Live-KOReader-only. Looks up the book in BookInfoManager's cache; if it's
|
||||
-- not there, kicks off background extraction (which writes the bb back into
|
||||
-- BIM next time the user paints). Returns nil immediately on cache miss so
|
||||
-- the caller can render FakeCover meanwhile.
|
||||
function M.get_local_cover(file_path)
|
||||
if not file_path then return nil end
|
||||
local ok, BIM = pcall(require, "bookinfomanager")
|
||||
if not ok or not BIM then return nil end
|
||||
|
||||
local info = BIM:getBookInfo(file_path, true)
|
||||
if info and info.cover_bb then return info.cover_bb end
|
||||
|
||||
-- Cache miss: ask BIM to extract in the background.
|
||||
BIM:extractInBackground({ { file_path } })
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- get_cloud_cover(book, opts, on_ready) — async; opts must include sync auth
|
||||
-- + a covers_dir + a settings table.
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- If the cover is already on disk, returns its path synchronously.
|
||||
-- Otherwise schedules a download via syncbooks.downloadCover and invokes
|
||||
-- on_ready(path_or_missing) when it completes. on_ready may be nil when the
|
||||
-- caller just wants to kick off the prefetch.
|
||||
function M.get_cloud_cover(book, opts, on_ready)
|
||||
if not book or not book.hash then
|
||||
if on_ready then on_ready(nil) end
|
||||
return nil
|
||||
end
|
||||
|
||||
local lfs = require("libs/libkoreader-lfs")
|
||||
local cached = M.cached_cover_path(opts.covers_dir, book.hash)
|
||||
if cached and lfs.attributes(cached, "mode") == "file" then
|
||||
return cached
|
||||
end
|
||||
|
||||
-- Don't retry a known-missing cover (avoids a 404 storm on every paint).
|
||||
if book.cover_path == M.MISSING then return nil end
|
||||
|
||||
local syncbooks = require("library.syncbooks")
|
||||
syncbooks.downloadCover(book, opts, function(success, path_or_err, status)
|
||||
if success then
|
||||
if on_ready then on_ready(path_or_err) end
|
||||
else
|
||||
if status == 404 then
|
||||
if on_ready then on_ready(M.MISSING) end
|
||||
else
|
||||
logger.dbg("ReadestLibrary cover download failed:", path_or_err, status)
|
||||
if on_ready then on_ready(nil) end
|
||||
end
|
||||
end
|
||||
end)
|
||||
return nil -- caller renders FakeCover until on_ready fires
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,21 @@
|
||||
-- exts.lua
|
||||
-- Book format → file extension mapping.
|
||||
--
|
||||
-- Verbatim port of `apps/readest-app/src/libs/document.ts` `EXTS`. The cloud
|
||||
-- `fileKey` builder in syncbooks.lua uses this to derive the `.{ext}` suffix
|
||||
-- from a `/sync` row's `format` column. Keep in sync with the web side.
|
||||
|
||||
local EXTS = {
|
||||
EPUB = "epub",
|
||||
PDF = "pdf",
|
||||
MOBI = "mobi",
|
||||
AZW = "azw",
|
||||
AZW3 = "azw3",
|
||||
CBZ = "cbz",
|
||||
FB2 = "fb2",
|
||||
FBZ = "fbz",
|
||||
TXT = "txt",
|
||||
MD = "md",
|
||||
}
|
||||
|
||||
return EXTS
|
||||
@@ -0,0 +1,203 @@
|
||||
-- group_covers.lua
|
||||
-- macOS-style folder previews: a 2x2 mosaic of the first N child book
|
||||
-- covers, served as a synthetic readest-group:// URI through the
|
||||
-- patched BookInfoManager. Composites are cached on disk under
|
||||
-- <settings>/readest_group_covers/<key>.png
|
||||
-- with the cache key derived from the actual first-N hashes, so any
|
||||
-- change to the group's content (sort flip, add/remove, reordering
|
||||
-- bump) auto-invalidates the cached PNG.
|
||||
|
||||
local logger = require("logger")
|
||||
local cloud_covers = require("library.cloud_covers")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.URI_PREFIX = "readest-group://"
|
||||
|
||||
-- Bump when the composite layout/dimensions change so existing on-disk
|
||||
-- composites get regenerated on next paint instead of serving the old
|
||||
-- aspect ratio forever.
|
||||
local CACHE_VERSION = 3
|
||||
|
||||
-- Layouts:
|
||||
-- "grid" — 2x2, 360x480 (3:4 — typical book-cover aspect).
|
||||
-- "list" — 2x2, 480x480 (square — matches ListMenu's rigid square
|
||||
-- cover slot, so the composite fills it vertically and each
|
||||
-- mini-cover stays book-shaped instead of getting squished).
|
||||
M.LAYOUTS = {
|
||||
grid = { target_w = 360, target_h = 480, cols = 2, rows = 2 },
|
||||
list = { target_w = 480, target_h = 480, cols = 2, rows = 2 },
|
||||
}
|
||||
|
||||
local function group_covers_dir()
|
||||
local DataStorage = require("datastorage")
|
||||
return DataStorage:getSettingsDir() .. "/readest_group_covers"
|
||||
end
|
||||
|
||||
local function group_cover_path(cache_key)
|
||||
return group_covers_dir() .. "/" .. cache_key .. "_v" .. CACHE_VERSION .. ".png"
|
||||
end
|
||||
|
||||
-- "Asimov" → "417369..." — filesystem-safe regardless of slashes,
|
||||
-- colons, etc. in the original group value.
|
||||
local function hex_encode(s)
|
||||
return (s:gsub(".", function(c) return string.format("%02x", string.byte(c)) end))
|
||||
end
|
||||
|
||||
local function hex_decode(hex)
|
||||
return (hex:gsub("..", function(h) return string.char(tonumber(h, 16)) end))
|
||||
end
|
||||
|
||||
-- shape ∈ {"grid", "list"} — controls the composite layout. Defaults
|
||||
-- to "grid" for backward compat with older callers.
|
||||
function M.build_uri(group_by, value, shape)
|
||||
return M.URI_PREFIX .. group_by .. ":" .. hex_encode(value)
|
||||
.. ":" .. (shape or "grid") .. ".png"
|
||||
end
|
||||
|
||||
-- Returns group_by, value, cache_key, shape; nil if not a group URI.
|
||||
-- cache_key here is the static "identity" portion; the BIM patch
|
||||
-- appends the actual first-N hashes for content-based invalidation.
|
||||
function M.parse_uri(uri)
|
||||
if uri:sub(1, #M.URI_PREFIX) ~= M.URI_PREFIX then return nil end
|
||||
local body = uri:sub(#M.URI_PREFIX + 1)
|
||||
if body:sub(-4) == ".png" then body = body:sub(1, -5) end
|
||||
local parts = {}
|
||||
for p in body:gmatch("[^:]+") do parts[#parts + 1] = p end
|
||||
if #parts < 2 then return nil end
|
||||
local group_by = parts[1]
|
||||
local hex = parts[2]
|
||||
local shape = parts[3] or "grid"
|
||||
local value = hex_decode(hex)
|
||||
return group_by, value, group_by .. "_" .. hex .. "_" .. shape, shape
|
||||
end
|
||||
|
||||
-- Pull a usable cover bb for a single child book during composition.
|
||||
-- Tries (in order):
|
||||
-- 1. local file via the original BIM cache (already-cached only;
|
||||
-- no extraction triggered)
|
||||
-- 2. cloud cover .png we previously downloaded
|
||||
-- Returns nil if neither path produces one. Caller owns the bb.
|
||||
function M.child_cover_bb(book, orig_getBookInfo, BIM)
|
||||
if book.local_present == 1 and book.file_path and orig_getBookInfo then
|
||||
local ok, info = pcall(orig_getBookInfo, BIM, book.file_path, true)
|
||||
if ok and info and info.has_cover and info.cover_bb then
|
||||
-- BIM hands us a cached bb whose ownership it keeps; we copy
|
||||
-- before scaling so the BIM cache stays intact when our
|
||||
-- composition pipeline frees what it received.
|
||||
local Blitbuffer = require("ffi/blitbuffer")
|
||||
local copy = Blitbuffer.new(info.cover_bb:getWidth(),
|
||||
info.cover_bb:getHeight(),
|
||||
info.cover_bb:getType())
|
||||
copy:blitFrom(info.cover_bb, 0, 0, 0, 0,
|
||||
info.cover_bb:getWidth(), info.cover_bb:getHeight())
|
||||
return copy
|
||||
end
|
||||
end
|
||||
return cloud_covers.load_cover_bb(book.hash)
|
||||
end
|
||||
|
||||
-- Compose up to N child covers into a mosaic and write the PNG to
|
||||
-- disk. Returns the loaded composite as a fresh bb, or nil on total
|
||||
-- failure (no usable child covers, or PNG write failure).
|
||||
local function compose(books, dest_path, shape, orig_getBookInfo, BIM)
|
||||
if #books == 0 then return nil end
|
||||
local layout = M.LAYOUTS[shape] or M.LAYOUTS.grid
|
||||
local target_w, target_h = layout.target_w, layout.target_h
|
||||
local cols, rows = layout.cols, layout.rows
|
||||
local max_cells = cols * rows
|
||||
|
||||
local Blitbuffer = require("ffi/blitbuffer")
|
||||
local target = Blitbuffer.new(target_w, target_h, Blitbuffer.TYPE_BBRGB32)
|
||||
target:fill(Blitbuffer.COLOR_WHITE)
|
||||
|
||||
local gap = 8
|
||||
local cell_w = math.floor((target_w - (cols - 1) * gap) / cols)
|
||||
local cell_h = math.floor((target_h - (rows - 1) * gap) / rows)
|
||||
local placed = 0
|
||||
|
||||
for i = 1, math.min(max_cells, #books) do
|
||||
local book = books[i]
|
||||
local cover = M.child_cover_bb(book, orig_getBookInfo, BIM)
|
||||
if cover then
|
||||
local row = math.floor((i - 1) / cols)
|
||||
local col = (i - 1) % cols
|
||||
local dx = col * (cell_w + gap)
|
||||
local dy = row * (cell_h + gap)
|
||||
local ok_scale, scaled = pcall(cover.scale, cover, cell_w, cell_h)
|
||||
if ok_scale and scaled then
|
||||
target:blitFrom(scaled, dx, dy, 0, 0, cell_w, cell_h)
|
||||
scaled:free()
|
||||
placed = placed + 1
|
||||
end
|
||||
cover:free()
|
||||
end
|
||||
end
|
||||
|
||||
if placed == 0 then
|
||||
target:free()
|
||||
return nil
|
||||
end
|
||||
|
||||
local lfs = require("libs/libkoreader-lfs")
|
||||
local dir = group_covers_dir()
|
||||
if lfs.attributes(dir, "mode") ~= "directory" then lfs.mkdir(dir) end
|
||||
|
||||
local ok_write = pcall(target.writeToFile, target, dest_path, "PNG")
|
||||
target:free()
|
||||
if not ok_write then
|
||||
logger.warn("ReadestLibrary group cover write failed: " .. tostring(dest_path))
|
||||
return nil
|
||||
end
|
||||
-- Re-load the file so the bb we hand back is one ImageWidget can
|
||||
-- safely take ownership of.
|
||||
local ok_render, RenderImage = pcall(require, "ui/renderimage")
|
||||
if not ok_render then return nil end
|
||||
local ok_load, bb = pcall(RenderImage.renderImageFile, RenderImage, dest_path, false)
|
||||
if not ok_load then return nil end
|
||||
return bb
|
||||
end
|
||||
|
||||
-- Cells-per-mosaic for a given shape. Used by callers to know how many
|
||||
-- books to fetch from the store.
|
||||
function M.cells_for(shape)
|
||||
local layout = M.LAYOUTS[shape] or M.LAYOUTS.grid
|
||||
return layout.cols * layout.rows
|
||||
end
|
||||
|
||||
-- High-level: query the store, derive a content-based cache key from
|
||||
-- the actual first-N hashes, load from disk if present, else compose
|
||||
-- fresh. Returns (bb, books) — books is the resolved list (so callers
|
||||
-- can reuse it without a second query).
|
||||
function M.serve_or_compose(group_by, value, cache_key, shape,
|
||||
store, settings, orig_getBookInfo, BIM)
|
||||
if not store then return nil, {} end
|
||||
local n = M.cells_for(shape)
|
||||
local books = store:listBooksInGroup(group_by, value, n, {
|
||||
sort_by = settings and settings.library_sort_by,
|
||||
sort_asc = settings and settings.library_sort_ascending == true,
|
||||
})
|
||||
-- Fingerprint: short prefix of each hash, joined. Stable for the
|
||||
-- same set in the same order; changes the moment any of those
|
||||
-- shifts.
|
||||
local parts = {}
|
||||
for i = 1, #books do
|
||||
parts[i] = (books[i].hash or ""):sub(1, 12)
|
||||
end
|
||||
local fingerprint = (#parts > 0) and table.concat(parts, "-") or "empty"
|
||||
local content_key = cache_key .. "_" .. fingerprint
|
||||
local cache_path = group_cover_path(content_key)
|
||||
|
||||
local lfs = require("libs/libkoreader-lfs")
|
||||
if lfs.attributes(cache_path, "mode") == "file" then
|
||||
local ok, RenderImage = pcall(require, "ui/renderimage")
|
||||
if ok then
|
||||
local ok2, loaded = pcall(RenderImage.renderImageFile,
|
||||
RenderImage, cache_path, false)
|
||||
if ok2 then return loaded, books end
|
||||
end
|
||||
end
|
||||
return compose(books, cache_path, shape, orig_getBookInfo, BIM), books
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,141 @@
|
||||
-- libraryitem.lua
|
||||
-- Maps LibraryStore rows (and group descriptors) into Menu entry
|
||||
-- tables that MosaicMenuItem / ListMenuItem can render. The heavy
|
||||
-- lifting is in:
|
||||
--
|
||||
-- library.cloud_covers — single-book cloud cover lifecycle
|
||||
-- library.group_covers — folder-preview composite mosaics
|
||||
-- library.cloud_icons — cloud-up/down overlay icons
|
||||
-- library.list_strip — list-mode group row widget
|
||||
-- library.bim_patch — BookInfoManager + ListMenuItem patches
|
||||
--
|
||||
-- This module is a thin glue layer that produces entries shaped the
|
||||
-- way the rendering pipeline expects, then delegates lifecycle hooks
|
||||
-- (install, set_visible_hashes) to the right submodule.
|
||||
|
||||
local cloud_covers = require("library.cloud_covers")
|
||||
local group_covers = require("library.group_covers")
|
||||
local bim_patch = require("library.bim_patch")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Sentinel fields on entry tables. Tap dispatch in librarywidget reads
|
||||
-- them; the bim_patch list-row patches read them via M.<flag>.
|
||||
M.CLOUD_ONLY_FLAG = bim_patch.CLOUD_ONLY_FLAG
|
||||
M.LOCAL_ONLY_FLAG = bim_patch.LOCAL_ONLY_FLAG
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Lifecycle delegates
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Called once when the Library widget opens. Wires the patched BIM
|
||||
-- with the user's current sync_auth/path/settings.
|
||||
function M.install(opts)
|
||||
bim_patch.install(opts)
|
||||
end
|
||||
|
||||
-- Limit cloud-cover downloads to entries on the current Menu page.
|
||||
function M.set_visible_hashes(menu)
|
||||
cloud_covers.set_visible_hashes(menu, M.CLOUD_ONLY_FLAG)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Entry constructors
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Group folder. Two render modes:
|
||||
-- * opts.with_cover ~= false → entry.file = readest-group:// URI so
|
||||
-- MosaicMenuItem treats it as a file and routes the paint through
|
||||
-- the patched BIM, which serves a 2x2 cover mosaic.
|
||||
-- * with_cover = false → entry has no `file` field, so the menu
|
||||
-- widget renders the default folder treatment (rounded frame +
|
||||
-- count badge).
|
||||
function M.entry_from_group(group, opts)
|
||||
opts = opts or {}
|
||||
local entry = {
|
||||
text = group.display_name or group.name,
|
||||
mandatory = tostring(group.count or 0),
|
||||
_readest_group = group,
|
||||
}
|
||||
-- Stash group_by on the descriptor so list_strip can resolve
|
||||
-- children without re-parsing the URI.
|
||||
if opts.group_by then group._group_by = opts.group_by end
|
||||
if opts.group_by and opts.with_cover ~= false then
|
||||
local shape = opts.shape or "grid"
|
||||
local uri = group_covers.build_uri(opts.group_by, group.name, shape)
|
||||
entry.file = uri
|
||||
entry.is_file = true
|
||||
-- Stash by URI so the BIM patch can return a proper title for
|
||||
-- FakeCover when the composite isn't cached yet on first paint.
|
||||
cloud_covers.set_meta(uri, {
|
||||
title = group.display_name or group.name,
|
||||
})
|
||||
end
|
||||
return entry
|
||||
end
|
||||
|
||||
-- "Up one level" entry. _readest_back_to is the parent path to
|
||||
-- navigate to (nil = back to root); the boolean flag distinguishes a
|
||||
-- root-back entry from a regular row that just happens to lack a
|
||||
-- back path.
|
||||
function M.entry_back(parent_path, label)
|
||||
return {
|
||||
text = label,
|
||||
mandatory = "",
|
||||
_readest_is_back = true,
|
||||
_readest_back_to = parent_path,
|
||||
}
|
||||
end
|
||||
|
||||
-- Convert a LibraryStore row into a Menu item_table entry. The Menu
|
||||
-- item layer expects entry.file, entry.text, entry.is_file etc.
|
||||
-- _readest_row is preserved so the tap handler in librarywidget can
|
||||
-- dispatch on cloud_present / local_present without re-querying.
|
||||
function M.entry_from_row(row, _opts)
|
||||
if not row then return nil end
|
||||
local entry = {
|
||||
text = row.title,
|
||||
author = row.author,
|
||||
series = row.series,
|
||||
series_index = row.series_index,
|
||||
cover_path = row.cover_path,
|
||||
is_file = true,
|
||||
mandatory = "",
|
||||
}
|
||||
local EXTS = require("library.exts")
|
||||
local ext = (EXTS[row.format] or "epub")
|
||||
if row.local_present == 1 and row.file_path then
|
||||
entry.file = row.file_path
|
||||
-- BIM patch tags this path with _no_provider so ListMenuItem
|
||||
-- renders mandatory verbatim (= the format) without the
|
||||
-- trailing "<filetype> " padding the standard format-string
|
||||
-- adds when mandatory is short. Keeps right-side text
|
||||
-- right-aligned with cloud rows that already use _no_provider.
|
||||
entry.mandatory = ext
|
||||
bim_patch.register_local_path(row.file_path)
|
||||
-- Mark "local but not in cloud" so the paintTo overlay paints
|
||||
-- the cloud-upload icon (mirroring Readest's BookItem rule:
|
||||
-- !uploadedAt → cloud-up).
|
||||
if (row.cloud_present or 0) == 0 then
|
||||
entry[M.LOCAL_ONLY_FLAG] = true
|
||||
end
|
||||
else
|
||||
-- Encode the real extension into the URI so listmenu's
|
||||
-- splitFileNameType returns a non-nil filetype for the right
|
||||
-- column. The patched BIM strips it back off.
|
||||
entry.file = cloud_covers.URI_PREFIX .. row.hash .. "." .. ext
|
||||
entry[M.CLOUD_ONLY_FLAG] = true
|
||||
-- Same _no_provider treatment as the local branch.
|
||||
entry.mandatory = ext
|
||||
-- Stash title/author by hash so the patched BIM (keyed by URI
|
||||
-- /path, not by row) can return them for FakeCover.
|
||||
cloud_covers.set_meta(row.hash, {
|
||||
title = row.title,
|
||||
author = row.author,
|
||||
})
|
||||
end
|
||||
entry._readest_row = row
|
||||
return entry
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,47 @@
|
||||
-- librarypaint.lua
|
||||
-- Partial-page repaint shim for the Library Menu, adapted from
|
||||
-- /Users/chrox/dev/koreader-plugins/zen_ui.koplugin/modules/filebrowser/patches/partial_page_repaint.lua
|
||||
--
|
||||
-- Problem on e-ink: when the visible page has fewer items than `perpage`
|
||||
-- (typical on the last page of any list), KOReader's normal partial-refresh
|
||||
-- leaves ghost pixels in the now-empty cells from the previously-shown
|
||||
-- items. A `setDirty(nil, "full")` waveform refresh clears the entire
|
||||
-- screen and removes the ghosts.
|
||||
--
|
||||
-- We hook our menu's updateItems to schedule a full refresh on the next
|
||||
-- UI tick when items_on_page < perpage. A `pending` guard prevents a
|
||||
-- double refresh if updateItems fires twice in the same tick (search
|
||||
-- debounce + sort change can do this).
|
||||
--
|
||||
-- Live-KOReader-only; no unit tests.
|
||||
|
||||
local M = {}
|
||||
|
||||
function M.install(menu)
|
||||
if menu._readest_paint_installed then return end
|
||||
menu._readest_paint_installed = true
|
||||
|
||||
local UIManager = require("ui/uimanager")
|
||||
local pending = false
|
||||
|
||||
local orig_updateItems = menu.updateItems
|
||||
menu.updateItems = function(self, ...)
|
||||
local r = orig_updateItems(self, ...)
|
||||
local total = #(self.item_table or {})
|
||||
local perpage = self.perpage
|
||||
if not perpage or perpage <= 0 or total == 0 then return r end
|
||||
local page = self.page or 1
|
||||
local items_on_page = math.max(0, math.min(perpage, total - (page - 1) * perpage))
|
||||
if items_on_page > 0 and items_on_page < perpage and not pending then
|
||||
pending = true
|
||||
UIManager:nextTick(function()
|
||||
pending = false
|
||||
UIManager:setDirty(nil, "full")
|
||||
UIManager:forceRePaint()
|
||||
end)
|
||||
end
|
||||
return r
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,751 @@
|
||||
-- librarystore.lua
|
||||
-- SQLite-backed book index for the Library view. Merges Readest cloud books
|
||||
-- (from /sync) with KOReader local books (from sidecar walks + ReadHistory)
|
||||
-- via the partial-md5 hash that both sides already use.
|
||||
--
|
||||
-- All queries are scoped by user_id (composite PK with hash) so signing into
|
||||
-- a different Readest account doesn't surface the previous user's books.
|
||||
--
|
||||
-- See apps/readest.koplugin/docs/library-design.md for the full schema and
|
||||
-- contract notes; spec/library/librarystore_spec.lua is the canonical
|
||||
-- behavioral spec.
|
||||
|
||||
local SQ3 = require("lua-ljsqlite3/init")
|
||||
local json = require("json")
|
||||
|
||||
local SCHEMA_VERSION = 1
|
||||
|
||||
local SCHEMA_SQL = [[
|
||||
CREATE TABLE IF NOT EXISTS books (
|
||||
user_id TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
meta_hash TEXT,
|
||||
title TEXT NOT NULL,
|
||||
source_title TEXT,
|
||||
author TEXT,
|
||||
format TEXT,
|
||||
metadata_json TEXT,
|
||||
series TEXT,
|
||||
series_index REAL,
|
||||
group_id TEXT,
|
||||
group_name TEXT,
|
||||
cover_path TEXT,
|
||||
file_path TEXT,
|
||||
cloud_present INTEGER NOT NULL DEFAULT 0,
|
||||
local_present INTEGER NOT NULL DEFAULT 0,
|
||||
uploaded_at INTEGER,
|
||||
progress_lib TEXT,
|
||||
reading_status TEXT,
|
||||
last_read_at INTEGER,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
deleted_at INTEGER,
|
||||
PRIMARY KEY (user_id, hash)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS books_user_updated ON books(user_id, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS books_user_lastread ON books(user_id, last_read_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS books_user_meta ON books(user_id, meta_hash);
|
||||
CREATE INDEX IF NOT EXISTS books_user_group ON books(user_id, group_name);
|
||||
CREATE INDEX IF NOT EXISTS books_user_author ON books(user_id, author);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_state (
|
||||
user_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
PRIMARY KEY (user_id, key)
|
||||
);
|
||||
]]
|
||||
|
||||
-- All columns we round-trip in the books row, in insert order.
|
||||
local BOOK_COLS = {
|
||||
"user_id", "hash", "meta_hash", "title", "source_title", "author",
|
||||
"format", "metadata_json", "series", "series_index", "group_id",
|
||||
"group_name", "cover_path", "file_path", "cloud_present",
|
||||
"local_present", "uploaded_at", "progress_lib", "reading_status",
|
||||
"last_read_at", "created_at", "updated_at", "deleted_at",
|
||||
}
|
||||
local BOOK_COL_INDEX = {}
|
||||
for i, c in ipairs(BOOK_COLS) do BOOK_COL_INDEX[c] = i end
|
||||
|
||||
-- Integer/real columns that lua-ljsqlite3 returns as int64_t / double cdata.
|
||||
-- We tonumber() these on row read so consumers can do arithmetic and
|
||||
-- string concat without worrying about cdata. Unix-ms timestamps fit
|
||||
-- well within Lua's 53-bit double mantissa.
|
||||
local NUMERIC_COLS = {
|
||||
series_index = true, cloud_present = true, local_present = true,
|
||||
uploaded_at = true, last_read_at = true, created_at = true,
|
||||
updated_at = true, deleted_at = true,
|
||||
}
|
||||
|
||||
local function row_to_table(raw)
|
||||
local out = {}
|
||||
for i, col in ipairs(BOOK_COLS) do
|
||||
local v = raw[i]
|
||||
if v ~= nil and NUMERIC_COLS[col] then
|
||||
v = tonumber(v)
|
||||
end
|
||||
out[col] = v
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Allowed sort columns. listBooks accepts only these to keep SQL safe from
|
||||
-- injection via filters.sort_by.
|
||||
local SORT_WHITELIST = {
|
||||
updated_at = true,
|
||||
last_read_at = true,
|
||||
title = true,
|
||||
author = true,
|
||||
created_at = true,
|
||||
series = true,
|
||||
format = true,
|
||||
}
|
||||
|
||||
-- Allowed group_by columns. Must match a real column name.
|
||||
local GROUP_WHITELIST = {
|
||||
author = true,
|
||||
series = true,
|
||||
group_name = true,
|
||||
}
|
||||
|
||||
local M = {}
|
||||
M.__index = M
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Construction
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- opts:
|
||||
-- user_id (required, string) — currently-authenticated Readest user.id
|
||||
-- db_path (optional, string) — defaults to ":memory:" for tests
|
||||
function M.new(opts)
|
||||
assert(opts and type(opts.user_id) == "string" and opts.user_id ~= "",
|
||||
"LibraryStore.new requires opts.user_id")
|
||||
local self = setmetatable({}, M)
|
||||
self.user_id = opts.user_id
|
||||
self.db_path = opts.db_path or ":memory:"
|
||||
self.db = SQ3.open(self.db_path)
|
||||
self.db:exec(SCHEMA_SQL)
|
||||
self.db:exec(string.format("PRAGMA user_version = %d;", SCHEMA_VERSION))
|
||||
self._groups_cache = {}
|
||||
return self
|
||||
end
|
||||
|
||||
function M:close()
|
||||
if self.db then self.db:close(); self.db = nil end
|
||||
end
|
||||
|
||||
function M:getUserVersion()
|
||||
local v = self.db:rowexec("PRAGMA user_version;")
|
||||
return tonumber(v)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Sync state per (user_id, key)
|
||||
-- ---------------------------------------------------------------------------
|
||||
function M:getLastPulledAt()
|
||||
local stmt = self.db:prepare(
|
||||
"SELECT value FROM sync_state WHERE user_id = ? AND key = ?")
|
||||
local row = stmt:reset():bind(self.user_id, "last_books_pulled_at"):step()
|
||||
stmt:close()
|
||||
if not row then return nil end
|
||||
return tonumber(row[1])
|
||||
end
|
||||
|
||||
function M:setLastPulledAt(ts)
|
||||
local stmt = self.db:prepare([[
|
||||
INSERT INTO sync_state (user_id, key, value) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
|
||||
]])
|
||||
stmt:reset():bind(self.user_id, "last_books_pulled_at", tostring(ts)):step()
|
||||
stmt:close()
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- upsertBook
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Merges a row by (user_id, hash). Flags `cloud_present` and `local_present`
|
||||
-- are OR-merged with the existing row UNLESS the caller passes the
|
||||
-- `_force_cloud_present` sentinel, in which case the supplied value is
|
||||
-- written verbatim (used for cloud-tombstone updates that must clear the
|
||||
-- flag).
|
||||
--
|
||||
-- Sentinels:
|
||||
-- _force_cloud_present = true → caller's cloud_present overrides OR-merge.
|
||||
-- _clear_fields = { "deleted_at", ... } → after the preserve-existing pass,
|
||||
-- these columns are explicitly nulled. Lets a caller un-tombstone a row
|
||||
-- by passing nil (which would otherwise be indistinguishable from "not
|
||||
-- provided" since Lua tables drop nil values).
|
||||
function M:upsertBook(row)
|
||||
assert(row and type(row.hash) == "string" and row.hash ~= "",
|
||||
"upsertBook requires row.hash")
|
||||
assert(row.title, "upsertBook requires row.title")
|
||||
|
||||
local existing = self:_getRowRaw(row.hash)
|
||||
|
||||
local merged = {}
|
||||
for k in pairs(BOOK_COL_INDEX) do
|
||||
merged[k] = row[k]
|
||||
end
|
||||
merged.user_id = self.user_id
|
||||
merged.hash = row.hash
|
||||
|
||||
if existing then
|
||||
-- OR-merge cloud_present unless explicit override
|
||||
if not row._force_cloud_present then
|
||||
merged.cloud_present = math.max(
|
||||
tonumber(existing.cloud_present) or 0,
|
||||
tonumber(merged.cloud_present) or 0)
|
||||
else
|
||||
merged.cloud_present = tonumber(row.cloud_present) or 0
|
||||
end
|
||||
-- OR-merge local_present always (no use case for force-clearing yet)
|
||||
merged.local_present = math.max(
|
||||
tonumber(existing.local_present) or 0,
|
||||
tonumber(merged.local_present) or 0)
|
||||
-- Preserve fields the caller didn't provide
|
||||
for k in pairs(BOOK_COL_INDEX) do
|
||||
if merged[k] == nil and existing[k] ~= nil then
|
||||
merged[k] = existing[k]
|
||||
end
|
||||
end
|
||||
-- Explicit clears: applied after preserve so they win.
|
||||
if row._clear_fields then
|
||||
for _, col in ipairs(row._clear_fields) do
|
||||
merged[col] = nil
|
||||
end
|
||||
end
|
||||
else
|
||||
merged.cloud_present = tonumber(merged.cloud_present) or 0
|
||||
merged.local_present = tonumber(merged.local_present) or 0
|
||||
end
|
||||
|
||||
-- Build INSERT … ON CONFLICT … DO UPDATE … with positional params.
|
||||
local placeholders = {}
|
||||
local update_setters = {}
|
||||
for i, col in ipairs(BOOK_COLS) do
|
||||
placeholders[i] = "?"
|
||||
if col ~= "user_id" and col ~= "hash" then
|
||||
update_setters[#update_setters + 1] = col .. " = excluded." .. col
|
||||
end
|
||||
end
|
||||
local sql = string.format([[
|
||||
INSERT INTO books (%s) VALUES (%s)
|
||||
ON CONFLICT(user_id, hash) DO UPDATE SET %s
|
||||
]], table.concat(BOOK_COLS, ", "),
|
||||
table.concat(placeholders, ", "),
|
||||
table.concat(update_setters, ", "))
|
||||
|
||||
local stmt = self.db:prepare(sql)
|
||||
stmt:reset()
|
||||
for i, col in ipairs(BOOK_COLS) do
|
||||
stmt:bind1(i, merged[col])
|
||||
end
|
||||
stmt:step()
|
||||
stmt:close()
|
||||
|
||||
-- Cached groupings stale after any insert/update.
|
||||
self._groups_cache = {}
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- getChangedBooks(since) — returns every row whose updated_at OR deleted_at
|
||||
-- exceeds the watermark. Mirrors useBooksSync.getNewBooks at
|
||||
-- apps/readest-app/src/app/library/hooks/useBooksSync.ts:22-35.
|
||||
-- Used by the auto-sync push path on book close to send deltas.
|
||||
-- ---------------------------------------------------------------------------
|
||||
function M:getChangedBooks(since)
|
||||
since = tonumber(since) or 0
|
||||
local stmt = self.db:prepare(string.format([[
|
||||
SELECT %s FROM books
|
||||
WHERE user_id = ?
|
||||
AND (updated_at > ? OR (deleted_at IS NOT NULL AND deleted_at > ?))
|
||||
ORDER BY updated_at ASC
|
||||
]], table.concat(BOOK_COLS, ", ")))
|
||||
stmt:reset():bind(self.user_id, since, since)
|
||||
local rows = {}
|
||||
while true do
|
||||
local r = stmt:step()
|
||||
if not r then break end
|
||||
rows[#rows + 1] = row_to_table(r)
|
||||
end
|
||||
stmt:close()
|
||||
return rows
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- touchBook(hash, fields) — update updated_at + last_read_at to "now",
|
||||
-- merge in any other fields the caller passes (commonly progress_lib),
|
||||
-- return the resulting row (or nil if the book isn't in the index).
|
||||
--
|
||||
-- This is the local-write half of "after open/close, sync to server"; the
|
||||
-- caller composes touchBook + syncbooks.pushBook to mirror what
|
||||
-- Readest web does in updateBookProgress + the books-table sync push
|
||||
-- (apps/readest-app/src/store/libraryStore.ts:105-122).
|
||||
-- ---------------------------------------------------------------------------
|
||||
function M:touchBook(hash, fields)
|
||||
if not hash or hash == "" then return nil end
|
||||
local existing = self:_getRowRaw(hash)
|
||||
if not existing then return nil end
|
||||
local now = os.time() * 1000
|
||||
local merge = {
|
||||
hash = hash,
|
||||
title = existing.title,
|
||||
updated_at = now,
|
||||
last_read_at = now,
|
||||
}
|
||||
if fields then for k, v in pairs(fields) do merge[k] = v end end
|
||||
self:upsertBook(merge)
|
||||
return self:_getRowRaw(hash)
|
||||
end
|
||||
|
||||
-- Internal: fetch a row by hash for the current user, returned as a table
|
||||
-- keyed by column name. Exposed (with leading underscore) for spec checks.
|
||||
function M:_getRowRaw(hash)
|
||||
local stmt = self.db:prepare(string.format(
|
||||
"SELECT %s FROM books WHERE user_id = ? AND hash = ?",
|
||||
table.concat(BOOK_COLS, ", ")))
|
||||
local row = stmt:reset():bind(self.user_id, hash):step()
|
||||
stmt:close()
|
||||
if not row then return nil end
|
||||
return row_to_table(row)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- listBooks
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- filters: { search, sort_by, sort_asc, group_by, group_filter }
|
||||
function M:listBooks(filters)
|
||||
filters = filters or {}
|
||||
local where = {
|
||||
"user_id = ?",
|
||||
"deleted_at IS NULL",
|
||||
"(cloud_present = 1 OR local_present = 1)",
|
||||
}
|
||||
local args = { self.user_id }
|
||||
|
||||
if filters.search and filters.search ~= "" then
|
||||
where[#where + 1] = "(LOWER(COALESCE(title, '')) LIKE ? OR LOWER(COALESCE(author, '')) LIKE ?)"
|
||||
local needle = "%" .. string.lower(filters.search) .. "%"
|
||||
args[#args + 1] = needle
|
||||
args[#args + 1] = needle
|
||||
end
|
||||
|
||||
if filters.group_by and filters.group_filter then
|
||||
local col = GROUP_WHITELIST[filters.group_by] and filters.group_by
|
||||
if col then
|
||||
where[#where + 1] = col .. " = ?"
|
||||
args[#args + 1] = filters.group_filter
|
||||
end
|
||||
end
|
||||
|
||||
-- "Books at this shelf level with no group value" — used by the
|
||||
-- bookshelf composer for the root view of group_by=author/series/group_name.
|
||||
if filters.ungrouped_col and GROUP_WHITELIST[filters.ungrouped_col] then
|
||||
local col = filters.ungrouped_col
|
||||
where[#where + 1] = "(" .. col .. " IS NULL OR " .. col .. " = '')"
|
||||
end
|
||||
|
||||
local sort_by = SORT_WHITELIST[filters.sort_by] and filters.sort_by or "last_read_at"
|
||||
local sort_dir = filters.sort_asc and "ASC" or "DESC"
|
||||
-- "Date Read" semantics in this plugin = "any recent activity" (the
|
||||
-- web's Updated + Date Read concepts merged earlier). Prefer
|
||||
-- updated_at when present so a metadata bump (e.g. "Add to Readest"
|
||||
-- dedupe re-stamping updated_at) floats the row to the top even
|
||||
-- when last_read_at is older. Falls back to last_read_at for the
|
||||
-- rare row that has only the read timestamp (no updated_at).
|
||||
local sort_expr = (sort_by == "last_read_at")
|
||||
and "COALESCE(updated_at, last_read_at)"
|
||||
or sort_by
|
||||
|
||||
local sql = string.format(
|
||||
"SELECT %s FROM books WHERE %s ORDER BY %s %s, hash ASC",
|
||||
table.concat(BOOK_COLS, ", "),
|
||||
table.concat(where, " AND "),
|
||||
sort_expr, sort_dir)
|
||||
|
||||
local stmt = self.db:prepare(sql)
|
||||
stmt:reset()
|
||||
for i, v in ipairs(args) do stmt:bind1(i, v) end
|
||||
|
||||
local rows = {}
|
||||
while true do
|
||||
local r = stmt:step()
|
||||
if not r then break end
|
||||
rows[#rows + 1] = row_to_table(r)
|
||||
end
|
||||
stmt:close()
|
||||
return rows
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- getGroups
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Returns array of { name, count, latest_updated_at, latest_last_read_at,
|
||||
-- latest_created_at } sorted by name. The per-sort aggregates let the
|
||||
-- caller interleave groups and books in the merged shelf list using each
|
||||
-- group's "most recent child" value (parity with Readest's
|
||||
-- getGroupSortValue at apps/readest-app/src/app/library/utils/libraryUtils.ts:381-387).
|
||||
-- Memoized per (user_id, group_by); invalidated by upsertBook.
|
||||
function M:getGroups(group_by)
|
||||
if not GROUP_WHITELIST[group_by] then return {} end
|
||||
|
||||
local cached = self._groups_cache[group_by]
|
||||
if cached then return cached end
|
||||
|
||||
local sql = string.format([[
|
||||
SELECT %s AS name,
|
||||
COUNT(*) AS cnt,
|
||||
MAX(updated_at) AS latest_updated,
|
||||
MAX(COALESCE(updated_at, last_read_at)) AS latest_last_read,
|
||||
MAX(created_at) AS latest_created
|
||||
FROM books
|
||||
WHERE user_id = ? AND deleted_at IS NULL
|
||||
AND (cloud_present = 1 OR local_present = 1)
|
||||
AND %s IS NOT NULL AND %s != ''
|
||||
GROUP BY %s
|
||||
ORDER BY name ASC
|
||||
]], group_by, group_by, group_by, group_by)
|
||||
|
||||
local stmt = self.db:prepare(sql)
|
||||
stmt:reset():bind1(1, self.user_id)
|
||||
local out = {}
|
||||
while true do
|
||||
local r = stmt:step()
|
||||
if not r then break end
|
||||
out[#out + 1] = {
|
||||
name = r[1],
|
||||
count = tonumber(r[2]),
|
||||
latest_updated_at = tonumber(r[3]),
|
||||
latest_last_read_at = tonumber(r[4]),
|
||||
latest_created_at = tonumber(r[5]),
|
||||
}
|
||||
end
|
||||
stmt:close()
|
||||
|
||||
self._groups_cache[group_by] = out
|
||||
return out
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- listBookshelfGroups(group_by, parent_path)
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Returns the group entries shown at the current shelf level, mirroring
|
||||
-- Readest's library nav model:
|
||||
-- author/series — flat groups; parent_path is ignored (only meaningful
|
||||
-- at root). Each entry is the existing getGroups output with display_name
|
||||
-- set to the group name.
|
||||
--
|
||||
-- group_name — nested folders. parent_path is the folder we're inside
|
||||
-- (nil = root). We emit one entry per immediate-child segment, with the
|
||||
-- `name` field carrying the full slash-delimited path so the caller can
|
||||
-- pass it back as parent_path for drill-in.
|
||||
--
|
||||
-- Each returned entry is { _group=true, name, display_name, count,
|
||||
-- latest_updated_at }, sorted by display_name ASC.
|
||||
function M:listBookshelfGroups(group_by, parent_path)
|
||||
if not GROUP_WHITELIST[group_by] then return {} end
|
||||
|
||||
if group_by ~= "group_name" then
|
||||
if parent_path then return {} end
|
||||
local out = {}
|
||||
for _i, g in ipairs(self:getGroups(group_by)) do
|
||||
out[#out + 1] = {
|
||||
_group = true,
|
||||
name = g.name,
|
||||
display_name = g.name,
|
||||
count = g.count,
|
||||
latest_updated_at = g.latest_updated_at,
|
||||
latest_last_read_at = g.latest_last_read_at,
|
||||
latest_created_at = g.latest_created_at,
|
||||
}
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- group_name: walk distinct group_name values and bucket by immediate
|
||||
-- child segment relative to parent_path. SQLite doesn't have great
|
||||
-- string-slicing primitives, but the distinct-group_name set is small
|
||||
-- (one row per unique path), so a Lua-side bucket is cheap.
|
||||
-- Per-sort aggregates mirror getGroups so the merged-shelf sort can
|
||||
-- use a folder's "most recent child" timestamp under any sort_by.
|
||||
local stmt = self.db:prepare([[
|
||||
SELECT group_name,
|
||||
COUNT(*) AS cnt,
|
||||
MAX(updated_at) AS latest_updated,
|
||||
MAX(COALESCE(updated_at, last_read_at)) AS latest_last_read,
|
||||
MAX(created_at) AS latest_created
|
||||
FROM books
|
||||
WHERE user_id = ? AND deleted_at IS NULL
|
||||
AND (cloud_present = 1 OR local_present = 1)
|
||||
AND group_name IS NOT NULL AND group_name != ''
|
||||
GROUP BY group_name
|
||||
]])
|
||||
stmt:reset():bind1(1, self.user_id)
|
||||
|
||||
local prefix = parent_path and (parent_path .. "/") or nil
|
||||
local prefix_len = prefix and #prefix or 0
|
||||
local children = {} -- segment → aggregate accumulator
|
||||
|
||||
while true do
|
||||
local r = stmt:step()
|
||||
if not r then break end
|
||||
local group_name = r[1]
|
||||
local cnt = tonumber(r[2]) or 0
|
||||
local latest_updated = tonumber(r[3]) or 0
|
||||
local latest_lastread = tonumber(r[4]) or 0
|
||||
local latest_created = tonumber(r[5]) or 0
|
||||
local rest
|
||||
if parent_path then
|
||||
-- "Fantasy" with parent="Fantasy" is a direct-child book, not
|
||||
-- a folder; skip from the folder list (caller picks it up via
|
||||
-- listBookshelfBooks).
|
||||
if group_name ~= parent_path
|
||||
and group_name:sub(1, prefix_len) == prefix then
|
||||
rest = group_name:sub(prefix_len + 1)
|
||||
end
|
||||
else
|
||||
rest = group_name
|
||||
end
|
||||
if rest and rest ~= "" then
|
||||
-- Match Readest's slashIndex > 0 semantics
|
||||
-- (apps/readest-app/src/app/library/components/BookshelfItem.tsx:43-44):
|
||||
-- a leading slash keeps the whole rest as the immediate-child name
|
||||
-- instead of producing an empty segment.
|
||||
local slash_pos = rest:find("/", 1, true)
|
||||
local segment
|
||||
if slash_pos and slash_pos > 1 then
|
||||
segment = rest:sub(1, slash_pos - 1)
|
||||
else
|
||||
segment = rest
|
||||
end
|
||||
local entry = children[segment]
|
||||
if entry then
|
||||
entry.count = entry.count + cnt
|
||||
entry.latest_updated = math.max(entry.latest_updated, latest_updated)
|
||||
entry.latest_lastread = math.max(entry.latest_lastread, latest_lastread)
|
||||
entry.latest_created = math.max(entry.latest_created, latest_created)
|
||||
else
|
||||
children[segment] = {
|
||||
count = cnt,
|
||||
latest_updated = latest_updated,
|
||||
latest_lastread = latest_lastread,
|
||||
latest_created = latest_created,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
stmt:close()
|
||||
|
||||
local out = {}
|
||||
for segment, data in pairs(children) do
|
||||
out[#out + 1] = {
|
||||
_group = true,
|
||||
name = parent_path and (parent_path .. "/" .. segment) or segment,
|
||||
display_name = segment,
|
||||
count = data.count,
|
||||
latest_updated_at = data.latest_updated,
|
||||
latest_last_read_at = data.latest_lastread,
|
||||
latest_created_at = data.latest_created,
|
||||
}
|
||||
end
|
||||
table.sort(out, function(a, b) return a.display_name < b.display_name end)
|
||||
return out
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- listBooksInGroup(group_by, group_value, limit, opts)
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Returns up to `limit` books in the group. opts.sort_by + opts.sort_asc
|
||||
-- mirror M:listBooks, so the cover composer picks the same first-N
|
||||
-- books the user would see when drilling in. Default sort:
|
||||
-- COALESCE(updated_at, last_read_at) DESC.
|
||||
--
|
||||
-- For group_name, matches the path itself AND any descendant path
|
||||
-- (so a top-level "Fantasy" preview pulls in books from Fantasy/Tolkien
|
||||
-- /LOTR even when nothing lives at the root level).
|
||||
function M:listBooksInGroup(group_by, group_value, limit, opts)
|
||||
if not GROUP_WHITELIST[group_by] then return {} end
|
||||
opts = opts or {}
|
||||
local where_extra, args
|
||||
if group_by == "group_name" then
|
||||
where_extra = "(group_name = ? OR group_name LIKE ?)"
|
||||
args = { self.user_id, group_value, group_value .. "/%", limit }
|
||||
else
|
||||
where_extra = group_by .. " = ?"
|
||||
args = { self.user_id, group_value, limit }
|
||||
end
|
||||
-- Honor the caller's current sort so the cover-preview composite
|
||||
-- picks the same first-N books the user would see when drilling in.
|
||||
-- Mirrors the sort_expr logic in M:listBooks above.
|
||||
local sort_by = SORT_WHITELIST[opts.sort_by] and opts.sort_by or "last_read_at"
|
||||
local sort_dir = opts.sort_asc and "ASC" or "DESC"
|
||||
local sort_expr = (sort_by == "last_read_at")
|
||||
and "COALESCE(updated_at, last_read_at)"
|
||||
or sort_by
|
||||
local sql = string.format([[
|
||||
SELECT %s FROM books
|
||||
WHERE user_id = ? AND deleted_at IS NULL
|
||||
AND (cloud_present = 1 OR local_present = 1)
|
||||
AND %s
|
||||
ORDER BY %s %s, hash ASC
|
||||
LIMIT ?
|
||||
]], table.concat(BOOK_COLS, ", "), where_extra, sort_expr, sort_dir)
|
||||
local stmt = self.db:prepare(sql)
|
||||
stmt:reset()
|
||||
for i, v in ipairs(args) do stmt:bind1(i, v) end
|
||||
local rows = {}
|
||||
while true do
|
||||
local r = stmt:step()
|
||||
if not r then break end
|
||||
rows[#rows + 1] = row_to_table(r)
|
||||
end
|
||||
stmt:close()
|
||||
return rows
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- listBookshelfBooks(filters, group_by, parent_path)
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Returns the book rows that appear directly at the current shelf level
|
||||
-- (siblings of the listBookshelfGroups output, NOT recursively).
|
||||
-- group_by=nil/"none" — all books matching filters
|
||||
-- group_by=author/series, root — books whose author/series is null/empty
|
||||
-- group_by=author/series, drill — books with col = parent_path
|
||||
-- group_by=group_name, root — books with null/empty group_name
|
||||
-- group_by=group_name, drill — books with group_name = parent_path
|
||||
function M:listBookshelfBooks(filters, group_by, parent_path)
|
||||
local sub = {}
|
||||
for k, v in pairs(filters or {}) do sub[k] = v end
|
||||
if not GROUP_WHITELIST[group_by] then
|
||||
sub.group_by = nil
|
||||
sub.group_filter = nil
|
||||
return self:listBooks(sub)
|
||||
end
|
||||
if parent_path then
|
||||
sub.group_by = group_by
|
||||
sub.group_filter = parent_path
|
||||
return self:listBooks(sub)
|
||||
end
|
||||
sub.group_by = nil
|
||||
sub.group_filter = nil
|
||||
sub.ungrouped_col = group_by
|
||||
return self:listBooks(sub)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- parseSyncRow (pure helper, no DB access)
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Maps a raw /sync DB row (snake_case, ISO timestamps, JSON-string metadata)
|
||||
-- to our internal row shape (fields ready for upsertBook). Returns nil for
|
||||
-- the dummy initial-sync hash.
|
||||
local DUMMY_HASH = "00000000000000000000000000000000"
|
||||
|
||||
-- ISO-8601 → unix ms. Accepts:
|
||||
-- 2026-02-01T00:00:00Z
|
||||
-- 2026-02-01T00:00:00+00:00 (Supabase / Postgres native; the common case)
|
||||
-- 2026-02-01T00:00:00+0000
|
||||
-- 2026-02-01T00:00:00.123456+00:00 (with fractional seconds)
|
||||
-- 2026-02-01 00:00:00+00:00 (Postgres without the T separator)
|
||||
local function iso_to_ms(s)
|
||||
if not s then return nil end
|
||||
if type(s) == "number" then return s end
|
||||
if type(s) ~= "string" then return nil end
|
||||
|
||||
local y, mo, d, h, mi, sec, frac, tz = s:match(
|
||||
"^(%d%d%d%d)%-(%d%d)%-(%d%d)[T ](%d%d):(%d%d):(%d%d)([%.%d]*)(.*)$")
|
||||
if not y then return nil end
|
||||
|
||||
local t = os.time({
|
||||
year = tonumber(y), month = tonumber(mo), day = tonumber(d),
|
||||
hour = tonumber(h), min = tonumber(mi), sec = tonumber(sec),
|
||||
isdst = false,
|
||||
})
|
||||
-- os.time interprets the struct as LOCAL time; convert to UTC by
|
||||
-- subtracting the local TZ offset.
|
||||
local utc_offset = os.difftime(t, os.time(os.date("!*t", t)))
|
||||
t = t + utc_offset
|
||||
|
||||
-- Apply the input's own offset (Z = +00:00; "+05:30" subtracts 5.5h to
|
||||
-- get UTC). Default to UTC if no offset present (server contract).
|
||||
if tz and tz ~= "" and tz ~= "Z" then
|
||||
local sign, oh, om = tz:match("^([%+%-])(%d%d):?(%d%d)$")
|
||||
if sign then
|
||||
local off = (tonumber(oh) * 3600) + (tonumber(om or 0) * 60)
|
||||
if sign == "+" then t = t - off else t = t + off end
|
||||
end
|
||||
end
|
||||
|
||||
local ms = t * 1000
|
||||
if frac and frac:sub(1, 1) == "." then
|
||||
-- Fractional seconds: take only the first 3 digits (ms precision)
|
||||
local f = frac:sub(2, 4)
|
||||
if #f > 0 then
|
||||
ms = ms + tonumber(f .. string.rep("0", 3 - #f))
|
||||
end
|
||||
end
|
||||
return ms
|
||||
end
|
||||
|
||||
function M.parseSyncRow(dbRow)
|
||||
if not dbRow then return nil end
|
||||
local hash = dbRow.book_hash or dbRow.hash
|
||||
if not hash or hash == DUMMY_HASH then return nil end
|
||||
|
||||
local out = {
|
||||
hash = hash,
|
||||
meta_hash = dbRow.meta_hash,
|
||||
title = dbRow.title or "Untitled",
|
||||
source_title = dbRow.source_title,
|
||||
author = dbRow.author,
|
||||
format = dbRow.format,
|
||||
group_id = dbRow.group_id,
|
||||
group_name = dbRow.group_name,
|
||||
uploaded_at = iso_to_ms(dbRow.uploaded_at),
|
||||
updated_at = iso_to_ms(dbRow.updated_at),
|
||||
created_at = iso_to_ms(dbRow.created_at),
|
||||
deleted_at = iso_to_ms(dbRow.deleted_at),
|
||||
}
|
||||
|
||||
-- Metadata: parse JSON string OR accept an already-parsed table; extract
|
||||
-- series/series_index into denormalized columns; round-trip the raw JSON
|
||||
-- so callers can read other fields lazily later.
|
||||
if dbRow.metadata ~= nil then
|
||||
local meta
|
||||
if type(dbRow.metadata) == "string" then
|
||||
local ok, parsed = pcall(json.decode, dbRow.metadata)
|
||||
if ok and type(parsed) == "table" then meta = parsed end
|
||||
elseif type(dbRow.metadata) == "table" then
|
||||
meta = dbRow.metadata
|
||||
end
|
||||
if meta then
|
||||
out.series = meta.series
|
||||
out.series_index = meta.seriesIndex
|
||||
local ok, encoded = pcall(json.encode, meta)
|
||||
if ok then out.metadata_json = encoded end
|
||||
end
|
||||
end
|
||||
|
||||
-- Progress: snake-case web shape is `progress = [cur, total]`.
|
||||
if dbRow.progress and type(dbRow.progress) == "table" then
|
||||
local ok, encoded = pcall(json.encode, dbRow.progress)
|
||||
if ok then out.progress_lib = encoded end
|
||||
end
|
||||
|
||||
-- Reading status passthrough (web side has 'unread'/'reading'/'finished')
|
||||
out.reading_status = dbRow.readingStatus or dbRow.reading_status
|
||||
|
||||
-- Cloud-presence flag: tombstones from the cloud arrive with deleted_at
|
||||
-- set; the row is still useful for tracking that the cloud copy is gone,
|
||||
-- but it doesn't count as cloud-present anymore. Force the flag through
|
||||
-- upsertBook's OR-merge with the sentinel.
|
||||
if out.deleted_at then
|
||||
out.cloud_present = 0
|
||||
out._force_cloud_present = true
|
||||
else
|
||||
out.cloud_present = 1
|
||||
end
|
||||
|
||||
return out
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,171 @@
|
||||
-- libraryviewmenu.lua
|
||||
-- The Library view-menu — a ButtonDialog with sections for View Mode,
|
||||
-- Columns, Cover fit, Group by, Sort by, Rescan library, and Download
|
||||
-- folder. Persists every choice to G_reader_settings.readest_sync.library_*
|
||||
-- and notifies the caller (LibraryWidget) so it can re-query and re-render.
|
||||
--
|
||||
-- Live-KOReader-only; smoke-tested via the manual matrix.
|
||||
|
||||
local ButtonDialog = require("ui/widget/buttondialog")
|
||||
local PathChooser = require("ui/widget/pathchooser")
|
||||
local UIManager = require("ui/uimanager")
|
||||
local _ = require("i18n")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Settings that affect the Menu's dimensions / mixin selection. Changing
|
||||
-- one requires a full Menu rebuild; M.refresh() (which only re-queries
|
||||
-- listBooks) wouldn't pick them up because nb_cols_portrait and the
|
||||
-- _recalculateDimen / _updateItemsBuildUI pointers are baked in at
|
||||
-- Menu construction time.
|
||||
local LAYOUT_KEYS = {
|
||||
library_view_mode = true,
|
||||
library_columns = true,
|
||||
library_rows = true,
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Helper: persist + invoke the right kind of refresh
|
||||
-- ---------------------------------------------------------------------------
|
||||
local function set(opts, key, value)
|
||||
opts.settings[key] = value
|
||||
G_reader_settings:saveSetting("readest_sync", opts.settings)
|
||||
if LAYOUT_KEYS[key] and opts.on_layout_change then
|
||||
opts.on_layout_change()
|
||||
elseif opts.on_change then
|
||||
opts.on_change()
|
||||
end
|
||||
end
|
||||
|
||||
-- Per-key default values that match what librarywidget assumes when the
|
||||
-- user hasn't explicitly chosen anything. The view menu's ✓ marker needs
|
||||
-- to know these so the implicit default still shows as selected.
|
||||
local DEFAULTS = {
|
||||
library_view_mode = "mosaic",
|
||||
library_group_by = "groups",
|
||||
library_sort_by = "last_read_at",
|
||||
library_sort_ascending = false,
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Helper: render a single row of mutually-exclusive options. Each option's
|
||||
-- label gets a "✓ " prefix when its value matches the current setting (or
|
||||
-- the default, if the setting hasn't been explicitly set).
|
||||
-- The dialog-close call is added by the back-fill loop in show() so this
|
||||
-- helper doesn't need to capture the dialog ref.
|
||||
-- ---------------------------------------------------------------------------
|
||||
local function row(opts, key, choices)
|
||||
local current = opts.settings[key]
|
||||
if current == nil then current = DEFAULTS[key] end
|
||||
local buttons = {}
|
||||
for _i, choice in ipairs(choices) do
|
||||
local label = choice.label
|
||||
if choice.value == current then label = "✓ " .. label end
|
||||
buttons[#buttons + 1] = {
|
||||
text = label,
|
||||
callback = function() set(opts, key, choice.value) end,
|
||||
}
|
||||
end
|
||||
return buttons
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- show(opts) — opts: { settings, on_change }
|
||||
-- ---------------------------------------------------------------------------
|
||||
function M.show(opts)
|
||||
local dialog
|
||||
dialog = ButtonDialog:new{
|
||||
title = _("Library view"),
|
||||
title_align = "center",
|
||||
buttons = {
|
||||
-- View Mode
|
||||
{ { text = _("View"), enabled = false } },
|
||||
row(opts, "library_view_mode", {
|
||||
{ label = _("Grid"), value = "mosaic" },
|
||||
{ label = _("List"), value = "list" },
|
||||
}),
|
||||
|
||||
-- Group by — alphabetical order matching Readest's web UI.
|
||||
-- Values mirror Readest's LibraryGroupByType ("authors",
|
||||
-- "groups", "series"); active_group_by() in librarywidget
|
||||
-- maps these to the SQL column names ("author",
|
||||
-- "group_name", "series") at the store boundary.
|
||||
-- "Books" is the no-grouping mode (flat list).
|
||||
{ { text = _("Group by"), enabled = false } },
|
||||
row(opts, "library_group_by", {
|
||||
{ label = _("Authors"), value = "authors" },
|
||||
{ label = _("Books"), value = "none" },
|
||||
{ label = _("Groups"), value = "groups" },
|
||||
{ label = _("Series"), value = "series" },
|
||||
}),
|
||||
|
||||
-- Sort by
|
||||
{ { text = _("Sort by"), enabled = false } },
|
||||
row(opts, "library_sort_by", {
|
||||
{ label = _("Date Read"), value = "last_read_at" },
|
||||
{ label = _("Date Added"), value = "created_at" },
|
||||
{ label = _("Title"), value = "title" },
|
||||
{ label = _("Author"), value = "author" },
|
||||
}),
|
||||
row(opts, "library_sort_ascending", {
|
||||
{ label = _("Descending"), value = false },
|
||||
{ label = _("Ascending"), value = true },
|
||||
}),
|
||||
|
||||
-- Actions
|
||||
{ { text = _("Actions"), enabled = false } },
|
||||
{
|
||||
{
|
||||
text = _("Rescan library"),
|
||||
callback = function()
|
||||
local Trapper = require("ui/trapper")
|
||||
local LibraryWidget = require("library.librarywidget")
|
||||
local localscanner = require("library.localscanner")
|
||||
Trapper:wrap(function()
|
||||
localscanner.fullSidecarWalk({
|
||||
store = LibraryWidget._store,
|
||||
home_dir = G_reader_settings:readSetting("home_dir"),
|
||||
})
|
||||
LibraryWidget.refresh()
|
||||
end)
|
||||
end,
|
||||
},
|
||||
{
|
||||
text = _("Download folder…"),
|
||||
callback = function()
|
||||
local picker
|
||||
picker = PathChooser:new{
|
||||
title = _("Pick a folder for downloaded books"),
|
||||
path = opts.settings.library_download_dir
|
||||
or G_reader_settings:readSetting("home_dir")
|
||||
or require("datastorage"):getDataDir(),
|
||||
select_directory = true,
|
||||
select_file = false,
|
||||
onConfirm = function(path)
|
||||
set(opts, "library_download_dir", path)
|
||||
end,
|
||||
}
|
||||
UIManager:show(picker)
|
||||
end,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
-- Wrap every actionable callback with `UIManager:close(dialog)` so each
|
||||
-- selection dismisses the dialog before applying its effect. Disabled
|
||||
-- header rows have no callback and are skipped.
|
||||
for _i, line in ipairs(dialog.buttons) do
|
||||
for _j, btn in ipairs(line) do
|
||||
if btn.callback then
|
||||
local orig = btn.callback
|
||||
btn.callback = function()
|
||||
UIManager:close(dialog)
|
||||
return orig()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
UIManager:show(dialog)
|
||||
end
|
||||
|
||||
return M
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
-- list_strip.lua
|
||||
-- List-mode group row builder. ListMenu's cover slot is hard-coded to
|
||||
-- a square dimen.h × dimen.h. To show 4 mini-covers each at that same
|
||||
-- square size (so each cell matches a single book row's cover slot)
|
||||
-- we replace the row's widget tree wholesale.
|
||||
--
|
||||
-- Mirrors the standard ListMenuItem.update widget shape:
|
||||
-- UnderlineContainer → VerticalGroup{
|
||||
-- VerticalSpan,
|
||||
-- HorizontalGroup{ cover-area, title, count },
|
||||
-- }
|
||||
-- but with the cover-area widened from 1× row-height to 4× row-height.
|
||||
|
||||
local group_covers = require("library.group_covers")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Mutates self._underline_container[1] in place. Called from the
|
||||
-- patched ListMenuItem:update for entries with _readest_group set.
|
||||
-- opts: { store, settings, orig_getBookInfo }
|
||||
function M.build(self, opts)
|
||||
local Geom = require("ui/geometry")
|
||||
local Size = require("ui/size")
|
||||
local Font = require("ui/font")
|
||||
local HorizontalGroup = require("ui/widget/horizontalgroup")
|
||||
local HorizontalSpan = require("ui/widget/horizontalspan")
|
||||
local VerticalGroup = require("ui/widget/verticalgroup")
|
||||
local VerticalSpan = require("ui/widget/verticalspan")
|
||||
local CenterContainer = require("ui/widget/container/centercontainer")
|
||||
local LeftContainer = require("ui/widget/container/leftcontainer")
|
||||
local FrameContainer = require("ui/widget/container/framecontainer")
|
||||
local TextWidget = require("ui/widget/textwidget")
|
||||
local TextBoxWidget = require("ui/widget/textboxwidget")
|
||||
local ImageWidget = require("ui/widget/imagewidget")
|
||||
local BookInfoManager = require("bookinfomanager")
|
||||
|
||||
local entry = self.entry
|
||||
local underline_h = self.underline_h or 1
|
||||
local dimen_h = self.height - 2 * underline_h
|
||||
local dimen_w = self.width
|
||||
|
||||
-- Each mini-cover gets a thin border (no padding) matching the
|
||||
-- single-book treatment in ListMenuItem.update (coverbrowser/
|
||||
-- listmenu.lua:258-269). cell_h subtracts the border on both sides
|
||||
-- so the framed cell fits within the row height.
|
||||
local border_size = Size.border.thin
|
||||
local cell_h = dimen_h - 2 * border_size
|
||||
local cell_w = cell_h
|
||||
local n_cells = 4
|
||||
local gap = math.floor(Size.padding.small / 2)
|
||||
|
||||
-- Resolve children with the current sort so the strip matches
|
||||
-- what the user would see when drilling in.
|
||||
local store = opts.store
|
||||
local group = entry._readest_group
|
||||
local settings = opts.settings or {}
|
||||
local books = (store and group)
|
||||
and store:listBooksInGroup(group._group_by, group.name, n_cells, {
|
||||
sort_by = settings.library_sort_by,
|
||||
sort_asc = settings.library_sort_ascending == true,
|
||||
}) or {}
|
||||
|
||||
-- Slot width fixed per cell so all rows align horizontally; the
|
||||
-- framed cover inside is sized to its rendered dimensions
|
||||
-- (image_size + border) so the border hugs the cover with no
|
||||
-- internal padding.
|
||||
local slot_w = cell_w + 2 * border_size
|
||||
local slot_h = cell_h + 2 * border_size
|
||||
local strip_children = {}
|
||||
for i = 1, n_cells do
|
||||
if i > 1 then
|
||||
strip_children[#strip_children + 1] = HorizontalSpan:new{ width = gap }
|
||||
end
|
||||
local book = books[i]
|
||||
local cell_widget
|
||||
if book then
|
||||
local cover = group_covers.child_cover_bb(book, opts.orig_getBookInfo, BookInfoManager)
|
||||
if cover then
|
||||
-- Precompute scale_factor and pass it WITHOUT
|
||||
-- width/height so ImageWidget:getSize returns the
|
||||
-- actual scaled bb dims. With explicit width+height
|
||||
-- it returns those exact dims, which would
|
||||
-- re-introduce padding inside the frame.
|
||||
local cw, ch = cover:getWidth(), cover:getHeight()
|
||||
local _, _, scale_factor = BookInfoManager.getCachedCoverSize(
|
||||
cw, ch, cell_w, cell_h)
|
||||
local wimage = ImageWidget:new{
|
||||
image = cover,
|
||||
scale_factor = scale_factor,
|
||||
}
|
||||
wimage:_render()
|
||||
local image_size = wimage:getSize()
|
||||
cell_widget = CenterContainer:new{
|
||||
dimen = Geom:new{ w = slot_w, h = slot_h },
|
||||
FrameContainer:new{
|
||||
width = image_size.w + 2 * border_size,
|
||||
height = image_size.h + 2 * border_size,
|
||||
margin = 0, padding = 0,
|
||||
bordersize = border_size,
|
||||
wimage,
|
||||
},
|
||||
}
|
||||
end
|
||||
end
|
||||
if not cell_widget then
|
||||
-- Empty slot keeps strip width consistent; no border so
|
||||
-- it visually disappears (like a missing book).
|
||||
cell_widget = HorizontalSpan:new{ width = slot_w }
|
||||
end
|
||||
strip_children[#strip_children + 1] = cell_widget
|
||||
end
|
||||
|
||||
local strip_widget = HorizontalGroup:new(strip_children)
|
||||
local strip_w = slot_w * n_cells + gap * (n_cells - 1)
|
||||
|
||||
local count_widget = TextWidget:new{
|
||||
text = entry.mandatory or "",
|
||||
face = Font:getFace("infont", 16),
|
||||
}
|
||||
local count_w = count_widget:getSize().w
|
||||
local pad_after_strip = Size.padding.large
|
||||
local pad_right = Size.padding.large
|
||||
|
||||
local title_w = math.max(0, dimen_w - strip_w - pad_after_strip - count_w - pad_right)
|
||||
local title_widget = TextBoxWidget:new{
|
||||
text = entry.text or "",
|
||||
face = Font:getFace("smalltfont", 18),
|
||||
width = title_w,
|
||||
bold = true,
|
||||
}
|
||||
|
||||
-- Wrap in LeftContainer with explicit dimen — ListMenuItem:paintTo
|
||||
-- reads self[1][1][2].dimen for shortcut/dogear overlay positioning.
|
||||
-- A bare HorizontalGroup never sets `dimen` so the access crashes.
|
||||
local widget = LeftContainer:new{
|
||||
dimen = Geom:new{ w = dimen_w, h = dimen_h },
|
||||
HorizontalGroup:new{
|
||||
align = "center",
|
||||
strip_widget,
|
||||
HorizontalSpan:new{ width = pad_after_strip },
|
||||
CenterContainer:new{
|
||||
dimen = Geom:new{ w = title_w, h = dimen_h },
|
||||
title_widget,
|
||||
},
|
||||
CenterContainer:new{
|
||||
dimen = Geom:new{ w = count_w, h = dimen_h },
|
||||
count_widget,
|
||||
},
|
||||
HorizontalSpan:new{ width = pad_right },
|
||||
},
|
||||
}
|
||||
|
||||
if self._underline_container[1] then
|
||||
self._underline_container[1]:free()
|
||||
end
|
||||
self._underline_container[1] = VerticalGroup:new{
|
||||
VerticalSpan:new{ width = underline_h },
|
||||
widget,
|
||||
}
|
||||
-- Tell ListMenu's _updateItemsBuildUI not to queue this item for
|
||||
-- BIM background extraction (which would fork a subprocess to
|
||||
-- scrape metadata from a file that doesn't actually exist).
|
||||
self.bookinfo_found = true
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,265 @@
|
||||
-- localscanner.lua
|
||||
-- Discover books KOReader has already opened (and therefore has a hash for)
|
||||
-- and feed them into the LibraryStore.
|
||||
--
|
||||
-- We never compute partial-md5 on demand: that's KOReader's job, performed
|
||||
-- the first time it opens a book file. The scanner only enumerates books
|
||||
-- whose .sdr/ sidecar already contains `partial_md5_checksum`, which means
|
||||
-- the local source-of-truth is "anything KOReader has opened at least
|
||||
-- once." This matches the user's stated v1 constraint and makes the scan
|
||||
-- bounded and side-effect-free.
|
||||
--
|
||||
-- Pure helpers (sidecar_to_book_path, parse_sidecar, should_skip_dir) are
|
||||
-- exported and unit-tested. The two driver methods (lightScan,
|
||||
-- fullSidecarWalk) require live KOReader services (ReadHistory,
|
||||
-- DocSettings, FFIUtil.runInSubProcess) and are exercised manually via the
|
||||
-- test matrix in docs/library-design.md.
|
||||
|
||||
local logger = require("logger")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- sidecar_to_book_path
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Convert "/foo/bar.sdr/metadata.epub.lua" → "/foo/bar.epub".
|
||||
-- Returns nil for any input that doesn't match the sidecar shape.
|
||||
function M.sidecar_to_book_path(path)
|
||||
if type(path) ~= "string" or path == "" then return nil end
|
||||
local parent, ext = path:match("^(.+)%.sdr/metadata%.([^./]+)%.lua$")
|
||||
if not parent then return nil end
|
||||
return parent .. "." .. ext
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- parse_sidecar
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Open a sidecar file, evaluate it (it returns a Lua table), and pull out
|
||||
-- the fields we care about: hash + a few doc_props. Returns:
|
||||
--
|
||||
-- { hash = "...", title = "...", author = "...", file_path = "..." }
|
||||
--
|
||||
-- ...or nil for any failure mode (file missing, syntax error, runtime
|
||||
-- error, missing partial_md5_checksum). The Library can't use a row without
|
||||
-- a hash, so we treat hash-missing as nothing-to-see-here.
|
||||
function M.parse_sidecar(path)
|
||||
local book_path = M.sidecar_to_book_path(path)
|
||||
if not book_path then return nil end
|
||||
|
||||
local f = loadfile(path)
|
||||
if not f then return nil end
|
||||
|
||||
-- Sidecars are raw `return { ... }` files; loadfile gives us a chunk
|
||||
-- that, when called, returns the table. Wrap in pcall in case the
|
||||
-- table contains a function or self-referential table that errors.
|
||||
local ok, result = pcall(f)
|
||||
if not ok or type(result) ~= "table" then return nil end
|
||||
|
||||
local hash = result.partial_md5_checksum
|
||||
if type(hash) ~= "string" or hash == "" then return nil end
|
||||
|
||||
local doc_props = result.doc_props or {}
|
||||
return {
|
||||
hash = hash,
|
||||
title = doc_props.title,
|
||||
author = doc_props.authors,
|
||||
file_path = book_path,
|
||||
}
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- should_skip_dir
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Predicate for the recursive walk. `.` / `..` filtered by the caller (lfs
|
||||
-- emits them but they're trivial); we handle the noise files KOReader's
|
||||
-- own FileChooser also skips.
|
||||
local SKIP_DIRS = {
|
||||
[".git"] = true,
|
||||
[".svn"] = true,
|
||||
[".hg"] = true,
|
||||
["node_modules"] = true,
|
||||
[".Trash"] = true,
|
||||
[".Trashes"] = true,
|
||||
["$RECYCLE.BIN"] = true,
|
||||
[".adobe-digital-editions"] = true,
|
||||
[".Spotlight-V100"] = true,
|
||||
[".fseventsd"] = true,
|
||||
[".DocumentRevisions-V100"] = true,
|
||||
[".TemporaryItems"] = true,
|
||||
}
|
||||
|
||||
function M.should_skip_dir(name)
|
||||
if name == "." or name == ".." then return false end -- caller's job
|
||||
return SKIP_DIRS[name] == true
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- lightScan(opts) — fast path; runs on every Library open
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- opts: { store = LibraryStore, ui = top-level UI? }
|
||||
--
|
||||
-- 1. For every existing local row in the store, stat file_path; if missing,
|
||||
-- set local_present=0.
|
||||
-- 2. For every ReadHistory entry whose file still exists, look up the
|
||||
-- sidecar (it's always present after KOReader opened the book) and
|
||||
-- upsert {hash, file_path, local_present=1, last_read_at=hist.time*1000}.
|
||||
--
|
||||
-- Bounded by `O(rows in SQLite + ReadHistory.hist size)` — typically <200
|
||||
-- entries. Cheap enough for every Library open. Live-KOReader-only.
|
||||
function M.lightScan(opts)
|
||||
local lfs = require("libs/libkoreader-lfs")
|
||||
local DocSettings = require("docsettings")
|
||||
local ReadHistory = require("readhistory")
|
||||
|
||||
local store = opts.store
|
||||
if not store then return 0, 0 end
|
||||
|
||||
-- Step 1: sweep stale file_paths to local_present=0
|
||||
local stale = 0
|
||||
local rows = store:listBooks({})
|
||||
for _, row in ipairs(rows) do
|
||||
if row.local_present == 1 and row.file_path then
|
||||
if lfs.attributes(row.file_path, "mode") ~= "file" then
|
||||
store:upsertBook({
|
||||
hash = row.hash,
|
||||
title = row.title,
|
||||
local_present = 0,
|
||||
})
|
||||
stale = stale + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Step 2: opportunistic upsert from ReadHistory.
|
||||
-- Per-iteration pcall so a single bad sidecar (corrupt Lua, file
|
||||
-- vanished mid-scan, DocSettings throwing on open) doesn't kill the
|
||||
-- whole loop and leave us with a partially-indexed library.
|
||||
local added, skipped = 0, 0
|
||||
for _, item in ipairs(ReadHistory.hist or {}) do
|
||||
local file = item.file
|
||||
if file and lfs.attributes(file, "mode") == "file" then
|
||||
local ok, err = pcall(function()
|
||||
-- DocSettings reads the sidecar for us; we get the same
|
||||
-- hash the sidecar walk would produce.
|
||||
local doc_settings = DocSettings:open(file)
|
||||
local hash = doc_settings:readSetting("partial_md5_checksum")
|
||||
if not hash or hash == "" then
|
||||
skipped = skipped + 1
|
||||
return
|
||||
end
|
||||
local doc_props = doc_settings:readSetting("doc_props") or {}
|
||||
store:upsertBook({
|
||||
hash = hash,
|
||||
title = doc_props.title
|
||||
or file:match("([^/]+)%.[^.]+$")
|
||||
or file,
|
||||
author = doc_props.authors,
|
||||
format = (file:match("%.([^.]+)$") or ""):upper(),
|
||||
file_path = file,
|
||||
local_present = 1,
|
||||
last_read_at = item.time and (item.time * 1000) or nil,
|
||||
})
|
||||
added = added + 1
|
||||
end)
|
||||
if not ok then
|
||||
skipped = skipped + 1
|
||||
logger.warn("ReadestLibrary lightScan: skipped " .. tostring(file)
|
||||
.. " — " .. tostring(err))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
logger.info("ReadestLibrary lightScan: stale=" .. stale
|
||||
.. " added/refreshed=" .. added .. " skipped=" .. skipped)
|
||||
return stale, added
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- fullSidecarWalk(opts, on_progress) — slow path; gated to first-run /
|
||||
-- explicit Rescan / 24h interval
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- opts: { store, home_dir, on_cancel? }
|
||||
-- on_progress: optional function(scanned_dirs, found_books)
|
||||
--
|
||||
-- Runs the recursive walk inside a forked subprocess via
|
||||
-- FFIUtil.runInSubProcess (the same pattern KOReader's filemanagerfilesearcher.lua
|
||||
-- uses for its dismissable scan, see :130-210). The subprocess returns a
|
||||
-- list of {file_path, hash, title, author} structs; the parent upserts each
|
||||
-- one in chunks so the UI stays responsive.
|
||||
--
|
||||
-- If `home_dir` is empty/nil we skip with a warning — this is the
|
||||
-- "user hasn't picked a Books folder yet" path, handled at the UI level
|
||||
-- by showing a hint to set Home in FileManager.
|
||||
function M.fullSidecarWalk(opts, on_progress)
|
||||
if not opts.home_dir or opts.home_dir == "" then
|
||||
logger.info("ReadestLibrary fullSidecarWalk: home_dir unset, skipping")
|
||||
return 0
|
||||
end
|
||||
|
||||
local lfs = require("libs/libkoreader-lfs")
|
||||
local FFIUtil = require("ffi/util")
|
||||
|
||||
-- Fork: walk the tree in the child, return the slim summary table.
|
||||
local child_fn = function()
|
||||
local results = {}
|
||||
local stack = { opts.home_dir }
|
||||
while #stack > 0 do
|
||||
local dir = table.remove(stack)
|
||||
local ok, iter, dir_obj = pcall(lfs.dir, dir)
|
||||
if ok then
|
||||
for entry in iter, dir_obj do
|
||||
if entry ~= "." and entry ~= ".." and not M.should_skip_dir(entry) then
|
||||
local full = dir .. "/" .. entry
|
||||
local mode = lfs.attributes(full, "mode")
|
||||
if mode == "directory" then
|
||||
-- KOReader sidecars live INSIDE *.sdr/ directories.
|
||||
if entry:match("%.sdr$") then
|
||||
-- Find the metadata.<ext>.lua inside
|
||||
for child in lfs.dir(full) do
|
||||
if child:match("^metadata%..+%.lua$") then
|
||||
local parsed = M.parse_sidecar(full .. "/" .. child)
|
||||
if parsed then
|
||||
results[#results + 1] = parsed
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
stack[#stack + 1] = full
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return results
|
||||
end
|
||||
|
||||
local results = FFIUtil.runInSubProcess(child_fn)
|
||||
if type(results) ~= "table" then
|
||||
logger.warn("ReadestLibrary fullSidecarWalk: subprocess returned non-table")
|
||||
return 0
|
||||
end
|
||||
|
||||
local store = opts.store
|
||||
local count = 0
|
||||
for _, p in ipairs(results) do
|
||||
if p.hash then
|
||||
store:upsertBook({
|
||||
hash = p.hash,
|
||||
title = p.title or p.file_path:match("([^/]+)%.[^.]+$") or "Untitled",
|
||||
author = p.author,
|
||||
file_path = p.file_path,
|
||||
local_present = 1,
|
||||
})
|
||||
count = count + 1
|
||||
if on_progress and count % 50 == 0 then
|
||||
on_progress(count)
|
||||
end
|
||||
end
|
||||
end
|
||||
if on_progress then on_progress(count) end
|
||||
logger.dbg("ReadestLibrary fullSidecarWalk: indexed " .. count .. " books")
|
||||
return count
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -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
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "يحافظ على مزامنة أجهزة KOReader و Readest الخاصة بك."
|
||||
|
||||
msgid "Library view"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Crop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group by"
|
||||
msgstr ""
|
||||
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Series"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Read"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Added"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr "العنوان"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "المؤلف"
|
||||
|
||||
msgid "Descending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ascending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rescan library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download folder…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pick a folder for downloaded books"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in to Readest to open the Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "إلغاء"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search title or author"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
|
||||
msgstr ""
|
||||
|
||||
msgid "File moved or deleted. Rescan library?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download this book from Readest?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set Home folder in File Manager first to enable downloads."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud copy unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Set auto progress sync"
|
||||
msgstr "تعيين المزامنة التلقائية للتقدم"
|
||||
|
||||
@@ -41,12 +158,27 @@ msgstr "إرسال ملاحظات Readest من هذا الجهاز"
|
||||
msgid "Pull readest annotations from other devices"
|
||||
msgstr "جلب ملاحظات Readest من الأجهزة الأخرى"
|
||||
|
||||
msgid "Open Readest Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log in Readest Account"
|
||||
msgstr "تسجيل الدخول إلى حساب Readest"
|
||||
|
||||
msgid "Log out as %1"
|
||||
msgstr "تسجيل الخروج من %1"
|
||||
|
||||
msgid "Push books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto sync progress and annotations"
|
||||
msgstr "مزامنة التقدم والملاحظات تلقائياً"
|
||||
|
||||
@@ -92,12 +224,6 @@ msgstr "لم تتم المزامنة مطلقاً"
|
||||
msgid "Book Fingerprint"
|
||||
msgstr "بصمة الكتاب"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "العنوان"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "المؤلف"
|
||||
|
||||
msgid "Identifiers"
|
||||
msgstr "المعرفات"
|
||||
|
||||
@@ -107,6 +233,15 @@ msgstr "آخر مزامنة"
|
||||
msgid "Sync Info"
|
||||
msgstr "معلومات المزامنة"
|
||||
|
||||
msgid "Library not initialized"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books synced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books sync failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking for update…"
|
||||
msgstr "التحقق من التحديث…"
|
||||
|
||||
@@ -167,6 +302,9 @@ msgstr "مزامنة كاملة: جلب جميع الملاحظات…"
|
||||
msgid "Pulling annotations..."
|
||||
msgstr "جارٍ جلب الملاحظات…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "فشل المصادقة، يرجى تسجيل الدخول مرة أخرى"
|
||||
|
||||
msgid "Failed to pull annotations"
|
||||
msgstr "فشل جلب الملاحظات"
|
||||
|
||||
@@ -176,9 +314,6 @@ msgstr "لم يتم العثور على ملاحظات جديدة"
|
||||
msgid "%1 annotations pulled"
|
||||
msgstr "تم جلب %1 ملاحظات"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "إلغاء"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "تسجيل الدخول"
|
||||
|
||||
@@ -218,9 +353,6 @@ msgstr "فشل إرسال تقدم القراءة"
|
||||
msgid "Pulling reading progress..."
|
||||
msgstr "جارٍ جلب تقدم القراءة…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "فشل المصادقة، يرجى تسجيل الدخول مرة أخرى"
|
||||
|
||||
msgid "Failed to pull reading progress"
|
||||
msgstr "فشل جلب تقدم القراءة"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "আপনার KOReader ও Readest ডিভাইসগুলিকে সিঙ্কে রাখে।"
|
||||
|
||||
msgid "Library view"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Crop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group by"
|
||||
msgstr ""
|
||||
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Series"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Read"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Added"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr "শিরোনাম"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "লেখক"
|
||||
|
||||
msgid "Descending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ascending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rescan library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download folder…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pick a folder for downloaded books"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in to Readest to open the Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "বাতিল"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search title or author"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
|
||||
msgstr ""
|
||||
|
||||
msgid "File moved or deleted. Rescan library?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download this book from Readest?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set Home folder in File Manager first to enable downloads."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud copy unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Set auto progress sync"
|
||||
msgstr "স্বয়ংক্রিয় অগ্রগতি সিঙ্ক সেট করুন"
|
||||
|
||||
@@ -41,12 +158,27 @@ msgstr "এই ডিভাইস থেকে Readest টীকা পাঠা
|
||||
msgid "Pull readest annotations from other devices"
|
||||
msgstr "অন্যান্য ডিভাইস থেকে Readest টীকা আনুন"
|
||||
|
||||
msgid "Open Readest Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log in Readest Account"
|
||||
msgstr "Readest অ্যাকাউন্টে লগ ইন করুন"
|
||||
|
||||
msgid "Log out as %1"
|
||||
msgstr "%1 হিসেবে লগ আউট করুন"
|
||||
|
||||
msgid "Push books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto sync progress and annotations"
|
||||
msgstr "স্বয়ংক্রিয়ভাবে অগ্রগতি ও টীকা সিঙ্ক করুন"
|
||||
|
||||
@@ -92,12 +224,6 @@ msgstr "কখনও সিঙ্ক করা হয়নি"
|
||||
msgid "Book Fingerprint"
|
||||
msgstr "বইয়ের ফিঙ্গারপ্রিন্ট"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "শিরোনাম"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "লেখক"
|
||||
|
||||
msgid "Identifiers"
|
||||
msgstr "শনাক্তকারী"
|
||||
|
||||
@@ -107,6 +233,15 @@ msgstr "শেষ সিঙ্ক"
|
||||
msgid "Sync Info"
|
||||
msgstr "সিঙ্ক তথ্য"
|
||||
|
||||
msgid "Library not initialized"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books synced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books sync failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking for update…"
|
||||
msgstr "আপডেট পরীক্ষা করা হচ্ছে…"
|
||||
|
||||
@@ -167,6 +302,9 @@ msgstr "পূর্ণ সিঙ্ক: সকল টীকা আনা হচ
|
||||
msgid "Pulling annotations..."
|
||||
msgstr "টীকা আনা হচ্ছে…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "প্রমাণীকরণ ব্যর্থ, অনুগ্রহ করে আবার লগ ইন করুন"
|
||||
|
||||
msgid "Failed to pull annotations"
|
||||
msgstr "টীকা আনা ব্যর্থ"
|
||||
|
||||
@@ -176,9 +314,6 @@ msgstr "নতুন কোনো টীকা পাওয়া যায়
|
||||
msgid "%1 annotations pulled"
|
||||
msgstr "%1টি টীকা আনা হয়েছে"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "বাতিল"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "লগ ইন"
|
||||
|
||||
@@ -218,9 +353,6 @@ msgstr "পড়ার অগ্রগতি পাঠানো ব্যর্
|
||||
msgid "Pulling reading progress..."
|
||||
msgstr "পড়ার অগ্রগতি আনা হচ্ছে…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "প্রমাণীকরণ ব্যর্থ, অনুগ্রহ করে আবার লগ ইন করুন"
|
||||
|
||||
msgid "Failed to pull reading progress"
|
||||
msgstr "পড়ার অগ্রগতি আনা ব্যর্থ"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "ཁྱེད་ཀྱི་ KOReader དང་ Readest སྒྲིག་ཆས་མཉམ་འགྱུར་འཇོག"
|
||||
|
||||
msgid "Library view"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Crop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group by"
|
||||
msgstr ""
|
||||
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Series"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Read"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Added"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr "དཔེ་དེབ་ཀྱི་མིང་།"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "རྩོམ་པ་པོ།"
|
||||
|
||||
msgid "Descending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ascending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rescan library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download folder…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pick a folder for downloaded books"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in to Readest to open the Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "འདོར་བ།"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search title or author"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
|
||||
msgstr ""
|
||||
|
||||
msgid "File moved or deleted. Rescan library?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download this book from Readest?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set Home folder in File Manager first to enable downloads."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud copy unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Set auto progress sync"
|
||||
msgstr "རང་འགུལ་འཕེལ་རིམ་མཉམ་འགྱུར་སྒྲིག"
|
||||
|
||||
@@ -41,12 +158,27 @@ msgstr "སྒྲིག་ཆས་འདི་ནས་ Readest མཆན་འ
|
||||
msgid "Pull readest annotations from other devices"
|
||||
msgstr "སྒྲིག་ཆས་གཞན་ནས་ Readest མཆན་འགྲེལ་ལེན་པ།"
|
||||
|
||||
msgid "Open Readest Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log in Readest Account"
|
||||
msgstr "Readest རྩིས་ཐོར་འཛུལ་ཞུགས།"
|
||||
|
||||
msgid "Log out as %1"
|
||||
msgstr "%1 ནས་ཕྱིར་ཐོན།"
|
||||
|
||||
msgid "Push books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto sync progress and annotations"
|
||||
msgstr "འཕེལ་རིམ་དང་མཆན་འགྲེལ་རང་འགུལ་མཉམ་འགྱུར།"
|
||||
|
||||
@@ -92,12 +224,6 @@ msgstr "མཉམ་འགྱུར་མ་བྱས།"
|
||||
msgid "Book Fingerprint"
|
||||
msgstr "དཔེ་ཆའི་མཛུབ་རྗེས།"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "དཔེ་དེབ་ཀྱི་མིང་།"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "རྩོམ་པ་པོ།"
|
||||
|
||||
msgid "Identifiers"
|
||||
msgstr "ངོས་འཛིན་བྱེད་མཁན"
|
||||
|
||||
@@ -107,6 +233,15 @@ msgstr "མཐའ་མའི་མཉམ་འགྱུར།"
|
||||
msgid "Sync Info"
|
||||
msgstr "མཉམ་འགྱུར་གསལ་བཤད།"
|
||||
|
||||
msgid "Library not initialized"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books synced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books sync failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking for update…"
|
||||
msgstr "གསར་སྤེལ་ཞིབ་བཤེར་བཞིན་པ…"
|
||||
|
||||
@@ -167,6 +302,9 @@ msgstr "ཆ་ཚང་མཉམ་འགྱུར: མཆན་འགྲེལ
|
||||
msgid "Pulling annotations..."
|
||||
msgstr "མཆན་འགྲེལ་ལེན་བཞིན་པ…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "ཡིད་ཆེས་ཞིབ་བཤེར་མ་ཐུབ། ཡང་བསྐྱར་འཛུལ་ཞུགས་གནང་རོགས།"
|
||||
|
||||
msgid "Failed to pull annotations"
|
||||
msgstr "མཆན་འགྲེལ་ལེན་མ་ཐུབ།"
|
||||
|
||||
@@ -176,9 +314,6 @@ msgstr "མཆན་འགྲེལ་གསར་པ་མ་རྙེད།"
|
||||
msgid "%1 annotations pulled"
|
||||
msgstr "མཆན་འགྲེལ་ %1 ལེན་སོང་།"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "འདོར་བ།"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "འཛུལ་ཞུགས།"
|
||||
|
||||
@@ -218,9 +353,6 @@ msgstr "ཀློག་པའི་འཕེལ་རིམ་སྐུར་མ
|
||||
msgid "Pulling reading progress..."
|
||||
msgstr "ཀློག་པའི་འཕེལ་རིམ་ལེན་བཞིན་པ…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "ཡིད་ཆེས་ཞིབ་བཤེར་མ་ཐུབ། ཡང་བསྐྱར་འཛུལ་ཞུགས་གནང་རོགས།"
|
||||
|
||||
msgid "Failed to pull reading progress"
|
||||
msgstr "ཀློག་པའི་འཕེལ་རིམ་ལེན་མ་ཐུབ།"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "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"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "Διατηρεί τις συσκευές KOReader και Readest συγχρονισμένες."
|
||||
|
||||
msgid "Library view"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Crop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group by"
|
||||
msgstr ""
|
||||
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Series"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Read"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Added"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr "Τίτλος"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "Συγγραφέας"
|
||||
|
||||
msgid "Descending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ascending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rescan library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download folder…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pick a folder for downloaded books"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in to Readest to open the Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "Ακύρωση"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search title or author"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
|
||||
msgstr ""
|
||||
|
||||
msgid "File moved or deleted. Rescan library?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download this book from Readest?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set Home folder in File Manager first to enable downloads."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud copy unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Set auto progress sync"
|
||||
msgstr "Ορισμός αυτόματου συγχρονισμού προόδου"
|
||||
|
||||
@@ -41,12 +158,27 @@ msgstr "Αποστολή σχολίων Readest από αυτή τη συσκε
|
||||
msgid "Pull readest annotations from other devices"
|
||||
msgstr "Λήψη σχολίων Readest από άλλες συσκευές"
|
||||
|
||||
msgid "Open Readest Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log in Readest Account"
|
||||
msgstr "Σύνδεση στον λογαριασμό Readest"
|
||||
|
||||
msgid "Log out as %1"
|
||||
msgstr "Αποσύνδεση ως %1"
|
||||
|
||||
msgid "Push books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto sync progress and annotations"
|
||||
msgstr "Αυτόματος συγχρονισμός προόδου και σχολίων"
|
||||
|
||||
@@ -92,12 +224,6 @@ msgstr "Δεν έχει συγχρονιστεί"
|
||||
msgid "Book Fingerprint"
|
||||
msgstr "Δακτυλικό αποτύπωμα βιβλίου"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "Τίτλος"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "Συγγραφέας"
|
||||
|
||||
msgid "Identifiers"
|
||||
msgstr "Αναγνωριστικά"
|
||||
|
||||
@@ -107,6 +233,15 @@ msgstr "Τελευταίος συγχρονισμός"
|
||||
msgid "Sync Info"
|
||||
msgstr "Πληροφορίες συγχρονισμού"
|
||||
|
||||
msgid "Library not initialized"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books synced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books sync failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking for update…"
|
||||
msgstr "Έλεγχος για ενημέρωση…"
|
||||
|
||||
@@ -167,6 +302,9 @@ msgstr "Πλήρης συγχρονισμός: λήψη όλων των σχολ
|
||||
msgid "Pulling annotations..."
|
||||
msgstr "Λήψη σχολίων…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "Αποτυχία ταυτοποίησης, συνδεθείτε ξανά"
|
||||
|
||||
msgid "Failed to pull annotations"
|
||||
msgstr "Η λήψη των σχολίων απέτυχε"
|
||||
|
||||
@@ -176,9 +314,6 @@ msgstr "Δεν βρέθηκαν νέα σχόλια"
|
||||
msgid "%1 annotations pulled"
|
||||
msgstr "Ελήφθησαν %1 σχόλια"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "Ακύρωση"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "Σύνδεση"
|
||||
|
||||
@@ -218,9 +353,6 @@ msgstr "Η αποστολή της προόδου ανάγνωσης απέτυ
|
||||
msgid "Pulling reading progress..."
|
||||
msgstr "Λήψη προόδου ανάγνωσης…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "Αποτυχία ταυτοποίησης, συνδεθείτε ξανά"
|
||||
|
||||
msgid "Failed to pull reading progress"
|
||||
msgstr "Η λήψη της προόδου ανάγνωσης απέτυχε"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "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"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "دستگاههای KOReader و Readest شما را همگام نگه میدارد."
|
||||
|
||||
msgid "Library view"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Crop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group by"
|
||||
msgstr ""
|
||||
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Series"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Read"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Added"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr "عنوان"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "نویسنده"
|
||||
|
||||
msgid "Descending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ascending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rescan library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download folder…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pick a folder for downloaded books"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in to Readest to open the Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "لغو"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search title or author"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
|
||||
msgstr ""
|
||||
|
||||
msgid "File moved or deleted. Rescan library?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download this book from Readest?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set Home folder in File Manager first to enable downloads."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud copy unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Set auto progress sync"
|
||||
msgstr "تنظیم همگامسازی خودکار پیشرفت"
|
||||
|
||||
@@ -41,12 +158,27 @@ msgstr "ارسال یادداشتهای Readest از این دستگاه"
|
||||
msgid "Pull readest annotations from other devices"
|
||||
msgstr "دریافت یادداشتهای Readest از دستگاههای دیگر"
|
||||
|
||||
msgid "Open Readest Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log in Readest Account"
|
||||
msgstr "ورود به حساب Readest"
|
||||
|
||||
msgid "Log out as %1"
|
||||
msgstr "خروج از حساب %1"
|
||||
|
||||
msgid "Push books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto sync progress and annotations"
|
||||
msgstr "همگامسازی خودکار پیشرفت و یادداشتها"
|
||||
|
||||
@@ -92,12 +224,6 @@ msgstr "هرگز همگامسازی نشده"
|
||||
msgid "Book Fingerprint"
|
||||
msgstr "اثر انگشت کتاب"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "عنوان"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "نویسنده"
|
||||
|
||||
msgid "Identifiers"
|
||||
msgstr "شناسهها"
|
||||
|
||||
@@ -107,6 +233,15 @@ msgstr "آخرین همگامسازی"
|
||||
msgid "Sync Info"
|
||||
msgstr "اطلاعات همگامسازی"
|
||||
|
||||
msgid "Library not initialized"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books synced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books sync failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking for update…"
|
||||
msgstr "در حال بررسی بهروزرسانی…"
|
||||
|
||||
@@ -167,6 +302,9 @@ msgstr "همگامسازی کامل: در حال دریافت تمام یاد
|
||||
msgid "Pulling annotations..."
|
||||
msgstr "در حال دریافت یادداشتها…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "احراز هویت ناموفق بود، دوباره وارد شوید"
|
||||
|
||||
msgid "Failed to pull annotations"
|
||||
msgstr "دریافت یادداشتها ناموفق بود"
|
||||
|
||||
@@ -176,9 +314,6 @@ msgstr "یادداشت جدیدی یافت نشد"
|
||||
msgid "%1 annotations pulled"
|
||||
msgstr "%1 یادداشت دریافت شد"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "لغو"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "ورود"
|
||||
|
||||
@@ -218,9 +353,6 @@ msgstr "ارسال پیشرفت مطالعه ناموفق بود"
|
||||
msgid "Pulling reading progress..."
|
||||
msgstr "در حال دریافت پیشرفت مطالعه…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "احراز هویت ناموفق بود، دوباره وارد شوید"
|
||||
|
||||
msgid "Failed to pull reading progress"
|
||||
msgstr "دریافت پیشرفت مطالعه ناموفق بود"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "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"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "שומר על סנכרון בין מכשירי KOReader ו-Readest שלך."
|
||||
|
||||
msgid "Library view"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Crop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group by"
|
||||
msgstr ""
|
||||
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Series"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Read"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Added"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr "כותרת"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "מחבר"
|
||||
|
||||
msgid "Descending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ascending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rescan library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download folder…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pick a folder for downloaded books"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in to Readest to open the Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "ביטול"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search title or author"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
|
||||
msgstr ""
|
||||
|
||||
msgid "File moved or deleted. Rescan library?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download this book from Readest?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set Home folder in File Manager first to enable downloads."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud copy unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Set auto progress sync"
|
||||
msgstr "הגדר סנכרון התקדמות אוטומטי"
|
||||
|
||||
@@ -41,12 +158,27 @@ msgstr "שלח הערות Readest ממכשיר זה"
|
||||
msgid "Pull readest annotations from other devices"
|
||||
msgstr "משוך הערות Readest ממכשירים אחרים"
|
||||
|
||||
msgid "Open Readest Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log in Readest Account"
|
||||
msgstr "התחבר לחשבון Readest"
|
||||
|
||||
msgid "Log out as %1"
|
||||
msgstr "התנתק מ-%1"
|
||||
|
||||
msgid "Push books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto sync progress and annotations"
|
||||
msgstr "סנכרן התקדמות והערות אוטומטית"
|
||||
|
||||
@@ -92,12 +224,6 @@ msgstr "מעולם לא סונכרן"
|
||||
msgid "Book Fingerprint"
|
||||
msgstr "טביעת אצבע של הספר"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "כותרת"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "מחבר"
|
||||
|
||||
msgid "Identifiers"
|
||||
msgstr "מזהים"
|
||||
|
||||
@@ -107,6 +233,15 @@ msgstr "סנכרון אחרון"
|
||||
msgid "Sync Info"
|
||||
msgstr "מידע סנכרון"
|
||||
|
||||
msgid "Library not initialized"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books synced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books sync failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking for update…"
|
||||
msgstr "בודק עדכון…"
|
||||
|
||||
@@ -167,6 +302,9 @@ msgstr "סנכרון מלא: מושך את כל ההערות…"
|
||||
msgid "Pulling annotations..."
|
||||
msgstr "מושך הערות…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "האימות נכשל, התחבר שוב"
|
||||
|
||||
msgid "Failed to pull annotations"
|
||||
msgstr "משיכת ההערות נכשלה"
|
||||
|
||||
@@ -176,9 +314,6 @@ msgstr "לא נמצאו הערות חדשות"
|
||||
msgid "%1 annotations pulled"
|
||||
msgstr "נמשכו %1 הערות"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "ביטול"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "התחבר"
|
||||
|
||||
@@ -218,9 +353,6 @@ msgstr "שליחת התקדמות הקריאה נכשלה"
|
||||
msgid "Pulling reading progress..."
|
||||
msgstr "מושך התקדמות קריאה…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "האימות נכשל, התחבר שוב"
|
||||
|
||||
msgid "Failed to pull reading progress"
|
||||
msgstr "משיכת התקדמות הקריאה נכשלה"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "आपके KOReader और Readest डिवाइसों को सिंक रखता है।"
|
||||
|
||||
msgid "Library view"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Crop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group by"
|
||||
msgstr ""
|
||||
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Series"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Read"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Added"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr "शीर्षक"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "लेखक"
|
||||
|
||||
msgid "Descending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ascending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rescan library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download folder…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pick a folder for downloaded books"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in to Readest to open the Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "रद्द करें"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search title or author"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
|
||||
msgstr ""
|
||||
|
||||
msgid "File moved or deleted. Rescan library?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download this book from Readest?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set Home folder in File Manager first to enable downloads."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud copy unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Set auto progress sync"
|
||||
msgstr "स्वचालित प्रगति सिंक सेट करें"
|
||||
|
||||
@@ -41,12 +158,27 @@ msgstr "इस डिवाइस से Readest एनोटेशन भेज
|
||||
msgid "Pull readest annotations from other devices"
|
||||
msgstr "अन्य डिवाइसों से Readest एनोटेशन प्राप्त करें"
|
||||
|
||||
msgid "Open Readest Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log in Readest Account"
|
||||
msgstr "Readest खाते में लॉग इन करें"
|
||||
|
||||
msgid "Log out as %1"
|
||||
msgstr "%1 से लॉग आउट करें"
|
||||
|
||||
msgid "Push books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto sync progress and annotations"
|
||||
msgstr "प्रगति और एनोटेशन स्वचालित रूप से सिंक करें"
|
||||
|
||||
@@ -92,12 +224,6 @@ msgstr "कभी सिंक नहीं हुआ"
|
||||
msgid "Book Fingerprint"
|
||||
msgstr "पुस्तक फिंगरप्रिंट"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "शीर्षक"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "लेखक"
|
||||
|
||||
msgid "Identifiers"
|
||||
msgstr "पहचानकर्ता"
|
||||
|
||||
@@ -107,6 +233,15 @@ msgstr "अंतिम सिंक"
|
||||
msgid "Sync Info"
|
||||
msgstr "सिंक जानकारी"
|
||||
|
||||
msgid "Library not initialized"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books synced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books sync failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking for update…"
|
||||
msgstr "अपडेट जांच रहा है…"
|
||||
|
||||
@@ -167,6 +302,9 @@ msgstr "पूर्ण सिंक: सभी एनोटेशन प्र
|
||||
msgid "Pulling annotations..."
|
||||
msgstr "एनोटेशन प्राप्त कर रहा है…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "प्रमाणीकरण विफल, कृपया पुनः लॉग इन करें"
|
||||
|
||||
msgid "Failed to pull annotations"
|
||||
msgstr "एनोटेशन प्राप्त करने में विफल"
|
||||
|
||||
@@ -176,9 +314,6 @@ msgstr "कोई नया एनोटेशन नहीं मिला"
|
||||
msgid "%1 annotations pulled"
|
||||
msgstr "%1 एनोटेशन प्राप्त हुए"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "रद्द करें"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "लॉग इन"
|
||||
|
||||
@@ -218,9 +353,6 @@ msgstr "पठन प्रगति भेजने में विफल"
|
||||
msgid "Pulling reading progress..."
|
||||
msgstr "पठन प्रगति प्राप्त कर रहा है…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "प्रमाणीकरण विफल, कृपया पुनः लॉग इन करें"
|
||||
|
||||
msgid "Failed to pull reading progress"
|
||||
msgstr "पठन प्रगति प्राप्त करने में विफल"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "KOReader と Readest のデバイス間で同期を保ちます。"
|
||||
|
||||
msgid "Library view"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Crop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group by"
|
||||
msgstr ""
|
||||
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Series"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Read"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Added"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr "タイトル"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "著者"
|
||||
|
||||
msgid "Descending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ascending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rescan library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download folder…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pick a folder for downloaded books"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in to Readest to open the Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "キャンセル"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search title or author"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
|
||||
msgstr ""
|
||||
|
||||
msgid "File moved or deleted. Rescan library?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download this book from Readest?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set Home folder in File Manager first to enable downloads."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud copy unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Set auto progress sync"
|
||||
msgstr "進捗の自動同期を設定"
|
||||
|
||||
@@ -41,12 +158,27 @@ msgstr "このデバイスから Readest の注釈を送信"
|
||||
msgid "Pull readest annotations from other devices"
|
||||
msgstr "他のデバイスから Readest の注釈を取得"
|
||||
|
||||
msgid "Open Readest Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log in Readest Account"
|
||||
msgstr "Readest アカウントにログイン"
|
||||
|
||||
msgid "Log out as %1"
|
||||
msgstr "%1 をログアウト"
|
||||
|
||||
msgid "Push books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto sync progress and annotations"
|
||||
msgstr "進捗と注釈を自動同期"
|
||||
|
||||
@@ -92,12 +224,6 @@ msgstr "未同期"
|
||||
msgid "Book Fingerprint"
|
||||
msgstr "書籍の指紋"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "タイトル"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "著者"
|
||||
|
||||
msgid "Identifiers"
|
||||
msgstr "識別子"
|
||||
|
||||
@@ -107,6 +233,15 @@ msgstr "前回同期"
|
||||
msgid "Sync Info"
|
||||
msgstr "同期情報"
|
||||
|
||||
msgid "Library not initialized"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books synced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books sync failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking for update…"
|
||||
msgstr "更新を確認しています…"
|
||||
|
||||
@@ -167,6 +302,9 @@ msgstr "フル同期: すべての注釈を取得しています…"
|
||||
msgid "Pulling annotations..."
|
||||
msgstr "注釈を取得しています…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "認証に失敗しました。再度ログインしてください"
|
||||
|
||||
msgid "Failed to pull annotations"
|
||||
msgstr "注釈の取得に失敗しました"
|
||||
|
||||
@@ -176,9 +314,6 @@ msgstr "新しい注釈は見つかりませんでした"
|
||||
msgid "%1 annotations pulled"
|
||||
msgstr "%1 件の注釈を取得しました"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "キャンセル"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "ログイン"
|
||||
|
||||
@@ -218,9 +353,6 @@ msgstr "読書の進捗の送信に失敗しました"
|
||||
msgid "Pulling reading progress..."
|
||||
msgstr "読書の進捗を取得しています…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "認証に失敗しました。再度ログインしてください"
|
||||
|
||||
msgid "Failed to pull reading progress"
|
||||
msgstr "読書の進捗の取得に失敗しました"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "KOReader와 Readest 기기를 동기화 상태로 유지합니다."
|
||||
|
||||
msgid "Library view"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Crop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group by"
|
||||
msgstr ""
|
||||
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Series"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Read"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Added"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr "제목"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "저자"
|
||||
|
||||
msgid "Descending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ascending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rescan library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download folder…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pick a folder for downloaded books"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in to Readest to open the Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "취소"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search title or author"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
|
||||
msgstr ""
|
||||
|
||||
msgid "File moved or deleted. Rescan library?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download this book from Readest?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set Home folder in File Manager first to enable downloads."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud copy unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Set auto progress sync"
|
||||
msgstr "진행 상황 자동 동기화 설정"
|
||||
|
||||
@@ -41,12 +158,27 @@ msgstr "이 기기에서 Readest 주석 보내기"
|
||||
msgid "Pull readest annotations from other devices"
|
||||
msgstr "다른 기기에서 Readest 주석 가져오기"
|
||||
|
||||
msgid "Open Readest Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log in Readest Account"
|
||||
msgstr "Readest 계정에 로그인"
|
||||
|
||||
msgid "Log out as %1"
|
||||
msgstr "%1 로그아웃"
|
||||
|
||||
msgid "Push books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto sync progress and annotations"
|
||||
msgstr "진행 상황과 주석을 자동으로 동기화"
|
||||
|
||||
@@ -92,12 +224,6 @@ msgstr "동기화한 적 없음"
|
||||
msgid "Book Fingerprint"
|
||||
msgstr "책 지문"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "제목"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "저자"
|
||||
|
||||
msgid "Identifiers"
|
||||
msgstr "식별자"
|
||||
|
||||
@@ -107,6 +233,15 @@ msgstr "마지막 동기화"
|
||||
msgid "Sync Info"
|
||||
msgstr "동기화 정보"
|
||||
|
||||
msgid "Library not initialized"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books synced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books sync failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking for update…"
|
||||
msgstr "업데이트 확인 중…"
|
||||
|
||||
@@ -167,6 +302,9 @@ msgstr "전체 동기화: 모든 주석을 가져오는 중…"
|
||||
msgid "Pulling annotations..."
|
||||
msgstr "주석을 가져오는 중…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "인증에 실패했습니다. 다시 로그인하세요"
|
||||
|
||||
msgid "Failed to pull annotations"
|
||||
msgstr "주석 가져오기에 실패했습니다"
|
||||
|
||||
@@ -176,9 +314,6 @@ msgstr "새 주석을 찾을 수 없습니다"
|
||||
msgid "%1 annotations pulled"
|
||||
msgstr "%1개 주석을 가져왔습니다"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "취소"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "로그인"
|
||||
|
||||
@@ -218,9 +353,6 @@ msgstr "읽기 진행 상황 보내기에 실패했습니다"
|
||||
msgid "Pulling reading progress..."
|
||||
msgstr "읽기 진행 상황을 가져오는 중…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "인증에 실패했습니다. 다시 로그인하세요"
|
||||
|
||||
msgid "Failed to pull reading progress"
|
||||
msgstr "읽기 진행 상황 가져오기에 실패했습니다"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "Поддерживает синхронизацию устройств KOReader и Readest."
|
||||
|
||||
msgid "Library view"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Crop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group by"
|
||||
msgstr ""
|
||||
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Series"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Read"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Added"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr "Название"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "Автор"
|
||||
|
||||
msgid "Descending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ascending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rescan library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download folder…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pick a folder for downloaded books"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in to Readest to open the Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "Отмена"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search title or author"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
|
||||
msgstr ""
|
||||
|
||||
msgid "File moved or deleted. Rescan library?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download this book from Readest?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set Home folder in File Manager first to enable downloads."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud copy unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Set auto progress sync"
|
||||
msgstr "Настроить автосинхронизацию прогресса"
|
||||
|
||||
@@ -41,12 +158,27 @@ msgstr "Отправить заметки Readest с этого устройст
|
||||
msgid "Pull readest annotations from other devices"
|
||||
msgstr "Получить заметки Readest с других устройств"
|
||||
|
||||
msgid "Open Readest Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log in Readest Account"
|
||||
msgstr "Войти в аккаунт Readest"
|
||||
|
||||
msgid "Log out as %1"
|
||||
msgstr "Выйти из %1"
|
||||
|
||||
msgid "Push books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto sync progress and annotations"
|
||||
msgstr "Автоматически синхронизировать прогресс и заметки"
|
||||
|
||||
@@ -92,12 +224,6 @@ msgstr "Ещё не синхронизировано"
|
||||
msgid "Book Fingerprint"
|
||||
msgstr "Отпечаток книги"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "Название"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "Автор"
|
||||
|
||||
msgid "Identifiers"
|
||||
msgstr "Идентификаторы"
|
||||
|
||||
@@ -107,6 +233,15 @@ msgstr "Последняя синхронизация"
|
||||
msgid "Sync Info"
|
||||
msgstr "Сведения о синхронизации"
|
||||
|
||||
msgid "Library not initialized"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books synced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books sync failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking for update…"
|
||||
msgstr "Проверка обновлений…"
|
||||
|
||||
@@ -167,6 +302,9 @@ msgstr "Полная синхронизация: получение всех з
|
||||
msgid "Pulling annotations..."
|
||||
msgstr "Получение заметок…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "Ошибка аутентификации, войдите снова"
|
||||
|
||||
msgid "Failed to pull annotations"
|
||||
msgstr "Не удалось получить заметки"
|
||||
|
||||
@@ -176,9 +314,6 @@ msgstr "Новых заметок не найдено"
|
||||
msgid "%1 annotations pulled"
|
||||
msgstr "Получено заметок: %1"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "Отмена"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "Войти"
|
||||
|
||||
@@ -218,9 +353,6 @@ msgstr "Не удалось отправить прогресс чтения"
|
||||
msgid "Pulling reading progress..."
|
||||
msgstr "Получение прогресса чтения…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "Ошибка аутентификации, войдите снова"
|
||||
|
||||
msgid "Failed to pull reading progress"
|
||||
msgstr "Не удалось получить прогресс чтения"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "ඔබේ KOReader සහ Readest උපාංග සමමුහූර්තව තබා ගනී."
|
||||
|
||||
msgid "Library view"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Crop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group by"
|
||||
msgstr ""
|
||||
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Series"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Read"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Added"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr "නම"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "කතෘ"
|
||||
|
||||
msgid "Descending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ascending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rescan library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download folder…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pick a folder for downloaded books"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in to Readest to open the Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "අවලංගු කරන්න"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search title or author"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
|
||||
msgstr ""
|
||||
|
||||
msgid "File moved or deleted. Rescan library?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download this book from Readest?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set Home folder in File Manager first to enable downloads."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud copy unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Set auto progress sync"
|
||||
msgstr "ස්වයංක්රීය ප්රගති සමමුහූර්තකරණය සකසන්න"
|
||||
|
||||
@@ -41,12 +158,27 @@ msgstr "මෙම උපාංගයෙන් Readest සටහන් යවන
|
||||
msgid "Pull readest annotations from other devices"
|
||||
msgstr "වෙනත් උපාංගවලින් Readest සටහන් ලබාගන්න"
|
||||
|
||||
msgid "Open Readest Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log in Readest Account"
|
||||
msgstr "Readest ගිණුමට පිවිසෙන්න"
|
||||
|
||||
msgid "Log out as %1"
|
||||
msgstr "%1 ලෙස ඉවත් වන්න"
|
||||
|
||||
msgid "Push books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto sync progress and annotations"
|
||||
msgstr "ප්රගතිය සහ සටහන් ස්වයංක්රීයව සමමුහූර්ත කරන්න"
|
||||
|
||||
@@ -92,12 +224,6 @@ msgstr "කිසිදා සමමුහූර්ත වී නැත"
|
||||
msgid "Book Fingerprint"
|
||||
msgstr "පොතේ ඇඟිලි සලකුණ"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "නම"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "කතෘ"
|
||||
|
||||
msgid "Identifiers"
|
||||
msgstr "හඳුනාගැනීම්"
|
||||
|
||||
@@ -107,6 +233,15 @@ msgstr "අවසන් සමමුහූර්තකරණය"
|
||||
msgid "Sync Info"
|
||||
msgstr "සමමුහූර්ත තොරතුරු"
|
||||
|
||||
msgid "Library not initialized"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books synced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books sync failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking for update…"
|
||||
msgstr "යාවත්කාලීන පරීක්ෂා කරමින්…"
|
||||
|
||||
@@ -167,6 +302,9 @@ msgstr "සම්පූර්ණ සමමුහූර්තකරණය: සි
|
||||
msgid "Pulling annotations..."
|
||||
msgstr "සටහන් ලබාගනිමින්…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "සත්යාපනය අසාර්ථකයි, කරුණාකර නැවත පිවිසෙන්න"
|
||||
|
||||
msgid "Failed to pull annotations"
|
||||
msgstr "සටහන් ලබාගැනීම අසාර්ථකයි"
|
||||
|
||||
@@ -176,9 +314,6 @@ msgstr "නව සටහන් හමු නොවුණි"
|
||||
msgid "%1 annotations pulled"
|
||||
msgstr "%1 සටහන් ලබා ගන්නා ලදී"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "අවලංගු කරන්න"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "පිවිසෙන්න"
|
||||
|
||||
@@ -218,9 +353,6 @@ msgstr "කියවීමේ ප්රගතිය යැවීම අසා
|
||||
msgid "Pulling reading progress..."
|
||||
msgstr "කියවීමේ ප්රගතිය ලබාගනිමින්…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "සත්යාපනය අසාර්ථකයි, කරුණාකර නැවත පිවිසෙන්න"
|
||||
|
||||
msgid "Failed to pull reading progress"
|
||||
msgstr "කියවීමේ ප්රගතිය ලබාගැනීම අසාර්ථකයි"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 "வாசிப்பு முன்னேற்றத்தைப் பெறுவதில் தோல்வி"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "รักษาการซิงค์ระหว่างอุปกรณ์ KOReader และ Readest ของคุณ"
|
||||
|
||||
msgid "Library view"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Crop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group by"
|
||||
msgstr ""
|
||||
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Series"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Read"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Added"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr "ชื่อเรื่อง"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "ผู้แต่ง"
|
||||
|
||||
msgid "Descending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ascending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rescan library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download folder…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pick a folder for downloaded books"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in to Readest to open the Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "ยกเลิก"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search title or author"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
|
||||
msgstr ""
|
||||
|
||||
msgid "File moved or deleted. Rescan library?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download this book from Readest?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set Home folder in File Manager first to enable downloads."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud copy unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Set auto progress sync"
|
||||
msgstr "ตั้งค่าการซิงค์ความคืบหน้าอัตโนมัติ"
|
||||
|
||||
@@ -41,12 +158,27 @@ msgstr "ส่งคำอธิบาย Readest จากอุปกรณ์
|
||||
msgid "Pull readest annotations from other devices"
|
||||
msgstr "ดึงคำอธิบาย Readest จากอุปกรณ์อื่น"
|
||||
|
||||
msgid "Open Readest Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log in Readest Account"
|
||||
msgstr "เข้าสู่ระบบบัญชี Readest"
|
||||
|
||||
msgid "Log out as %1"
|
||||
msgstr "ออกจากระบบในชื่อ %1"
|
||||
|
||||
msgid "Push books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto sync progress and annotations"
|
||||
msgstr "ซิงค์ความคืบหน้าและคำอธิบายอัตโนมัติ"
|
||||
|
||||
@@ -92,12 +224,6 @@ msgstr "ไม่เคยซิงค์"
|
||||
msgid "Book Fingerprint"
|
||||
msgstr "ลายนิ้วมือหนังสือ"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "ชื่อเรื่อง"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "ผู้แต่ง"
|
||||
|
||||
msgid "Identifiers"
|
||||
msgstr "ตัวระบุ"
|
||||
|
||||
@@ -107,6 +233,15 @@ msgstr "ซิงค์ล่าสุด"
|
||||
msgid "Sync Info"
|
||||
msgstr "ข้อมูลการซิงค์"
|
||||
|
||||
msgid "Library not initialized"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books synced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books sync failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking for update…"
|
||||
msgstr "กำลังตรวจสอบการอัปเดต…"
|
||||
|
||||
@@ -167,6 +302,9 @@ msgstr "ซิงค์เต็มรูปแบบ: กำลังดึง
|
||||
msgid "Pulling annotations..."
|
||||
msgstr "กำลังดึงคำอธิบาย…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "การยืนยันตัวตนล้มเหลว โปรดเข้าสู่ระบบอีกครั้ง"
|
||||
|
||||
msgid "Failed to pull annotations"
|
||||
msgstr "ดึงคำอธิบายไม่สำเร็จ"
|
||||
|
||||
@@ -176,9 +314,6 @@ msgstr "ไม่พบคำอธิบายใหม่"
|
||||
msgid "%1 annotations pulled"
|
||||
msgstr "ดึงคำอธิบาย %1 รายการ"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "ยกเลิก"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "เข้าสู่ระบบ"
|
||||
|
||||
@@ -218,9 +353,6 @@ msgstr "ส่งความคืบหน้าการอ่านไม่
|
||||
msgid "Pulling reading progress..."
|
||||
msgstr "กำลังดึงความคืบหน้าการอ่าน…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "การยืนยันตัวตนล้มเหลว โปรดเข้าสู่ระบบอีกครั้ง"
|
||||
|
||||
msgid "Failed to pull reading progress"
|
||||
msgstr "ดึงความคืบหน้าการอ่านไม่สำเร็จ"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "KOReader 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ı"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "Підтримує синхронізацію пристроїв KOReader і Readest."
|
||||
|
||||
msgid "Library view"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Crop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group by"
|
||||
msgstr ""
|
||||
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Series"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Read"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Added"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr "Назва"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "Автор"
|
||||
|
||||
msgid "Descending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ascending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rescan library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download folder…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pick a folder for downloaded books"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in to Readest to open the Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "Скасувати"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search title or author"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
|
||||
msgstr ""
|
||||
|
||||
msgid "File moved or deleted. Rescan library?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download this book from Readest?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set Home folder in File Manager first to enable downloads."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud copy unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Set auto progress sync"
|
||||
msgstr "Налаштувати автосинхронізацію прогресу"
|
||||
|
||||
@@ -41,12 +158,27 @@ msgstr "Надіслати нотатки Readest з цього пристрою
|
||||
msgid "Pull readest annotations from other devices"
|
||||
msgstr "Отримати нотатки Readest з інших пристроїв"
|
||||
|
||||
msgid "Open Readest Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log in Readest Account"
|
||||
msgstr "Увійти до облікового запису Readest"
|
||||
|
||||
msgid "Log out as %1"
|
||||
msgstr "Вийти з %1"
|
||||
|
||||
msgid "Push books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto sync progress and annotations"
|
||||
msgstr "Автоматично синхронізувати прогрес і нотатки"
|
||||
|
||||
@@ -92,12 +224,6 @@ msgstr "Ще не синхронізовано"
|
||||
msgid "Book Fingerprint"
|
||||
msgstr "Відбиток книги"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "Назва"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "Автор"
|
||||
|
||||
msgid "Identifiers"
|
||||
msgstr "Ідентифікатори"
|
||||
|
||||
@@ -107,6 +233,15 @@ msgstr "Остання синхронізація"
|
||||
msgid "Sync Info"
|
||||
msgstr "Відомості про синхронізацію"
|
||||
|
||||
msgid "Library not initialized"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books synced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books sync failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking for update…"
|
||||
msgstr "Перевірка оновлень…"
|
||||
|
||||
@@ -167,6 +302,9 @@ msgstr "Повна синхронізація: отримання всіх но
|
||||
msgid "Pulling annotations..."
|
||||
msgstr "Отримання нотаток…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "Помилка автентифікації, увійдіть знову"
|
||||
|
||||
msgid "Failed to pull annotations"
|
||||
msgstr "Не вдалося отримати нотатки"
|
||||
|
||||
@@ -176,9 +314,6 @@ msgstr "Нових нотаток не знайдено"
|
||||
msgid "%1 annotations pulled"
|
||||
msgstr "Отримано нотаток: %1"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "Скасувати"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "Увійти"
|
||||
|
||||
@@ -218,9 +353,6 @@ msgstr "Не вдалося надіслати прогрес читання"
|
||||
msgid "Pulling reading progress..."
|
||||
msgstr "Отримання прогресу читання…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "Помилка автентифікації, увійдіть знову"
|
||||
|
||||
msgid "Failed to pull reading progress"
|
||||
msgstr "Не вдалося отримати прогрес читання"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "Giữ các thiết bị KOReader và Readest của bạn đồng bộ."
|
||||
|
||||
msgid "Library view"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Crop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group by"
|
||||
msgstr ""
|
||||
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Series"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Read"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Added"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr "Tiêu đề"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "Tác giả"
|
||||
|
||||
msgid "Descending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ascending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rescan library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download folder…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pick a folder for downloaded books"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in to Readest to open the Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "Hủy"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search title or author"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
|
||||
msgstr ""
|
||||
|
||||
msgid "File moved or deleted. Rescan library?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download this book from Readest?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set Home folder in File Manager first to enable downloads."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud copy unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Set auto progress sync"
|
||||
msgstr "Đặt đồng bộ tiến trình tự động"
|
||||
|
||||
@@ -41,12 +158,27 @@ msgstr "Đẩy ghi chú Readest từ thiết bị này"
|
||||
msgid "Pull readest annotations from other devices"
|
||||
msgstr "Lấy ghi chú Readest từ thiết bị khác"
|
||||
|
||||
msgid "Open Readest Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log in Readest Account"
|
||||
msgstr "Đăng nhập tài khoản Readest"
|
||||
|
||||
msgid "Log out as %1"
|
||||
msgstr "Đăng xuất khỏi %1"
|
||||
|
||||
msgid "Push books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto sync progress and annotations"
|
||||
msgstr "Tự động đồng bộ tiến trình và ghi chú"
|
||||
|
||||
@@ -92,12 +224,6 @@ msgstr "Chưa đồng bộ"
|
||||
msgid "Book Fingerprint"
|
||||
msgstr "Dấu vân tay sách"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "Tiêu đề"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "Tác giả"
|
||||
|
||||
msgid "Identifiers"
|
||||
msgstr "Định Danh"
|
||||
|
||||
@@ -107,6 +233,15 @@ msgstr "Lần đồng bộ cuối"
|
||||
msgid "Sync Info"
|
||||
msgstr "Thông tin đồng bộ"
|
||||
|
||||
msgid "Library not initialized"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books synced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books sync failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking for update…"
|
||||
msgstr "Đang kiểm tra cập nhật…"
|
||||
|
||||
@@ -167,6 +302,9 @@ msgstr "Đồng bộ đầy đủ: đang lấy tất cả ghi chú…"
|
||||
msgid "Pulling annotations..."
|
||||
msgstr "Đang lấy ghi chú…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "Xác thực thất bại, vui lòng đăng nhập lại"
|
||||
|
||||
msgid "Failed to pull annotations"
|
||||
msgstr "Không thể lấy ghi chú"
|
||||
|
||||
@@ -176,9 +314,6 @@ msgstr "Không tìm thấy ghi chú mới"
|
||||
msgid "%1 annotations pulled"
|
||||
msgstr "Đã lấy %1 ghi chú"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "Hủy"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "Đăng nhập"
|
||||
|
||||
@@ -218,9 +353,6 @@ msgstr "Không thể đẩy tiến trình đọc"
|
||||
msgid "Pulling reading progress..."
|
||||
msgstr "Đang lấy tiến trình đọc…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "Xác thực thất bại, vui lòng đăng nhập lại"
|
||||
|
||||
msgid "Failed to pull reading progress"
|
||||
msgstr "Không thể lấy tiến trình đọc"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "让你的 KOReader 与 Readest 设备保持同步。"
|
||||
|
||||
msgid "Library view"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Crop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group by"
|
||||
msgstr ""
|
||||
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Series"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Read"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Added"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr "标题"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "作者"
|
||||
|
||||
msgid "Descending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ascending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rescan library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download folder…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pick a folder for downloaded books"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in to Readest to open the Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "取消"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search title or author"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
|
||||
msgstr ""
|
||||
|
||||
msgid "File moved or deleted. Rescan library?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download this book from Readest?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set Home folder in File Manager first to enable downloads."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud copy unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Set auto progress sync"
|
||||
msgstr "设置自动同步阅读进度"
|
||||
|
||||
@@ -41,12 +158,27 @@ msgstr "从此设备推送 Readest 标注"
|
||||
msgid "Pull readest annotations from other devices"
|
||||
msgstr "从其他设备拉取 Readest 标注"
|
||||
|
||||
msgid "Open Readest Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log in Readest Account"
|
||||
msgstr "登录 Readest 账户"
|
||||
|
||||
msgid "Log out as %1"
|
||||
msgstr "退出登录(%1)"
|
||||
|
||||
msgid "Push books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto sync progress and annotations"
|
||||
msgstr "自动同步阅读进度和标注"
|
||||
|
||||
@@ -92,12 +224,6 @@ msgstr "尚未同步"
|
||||
msgid "Book Fingerprint"
|
||||
msgstr "书籍指纹"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "标题"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "作者"
|
||||
|
||||
msgid "Identifiers"
|
||||
msgstr "标识符"
|
||||
|
||||
@@ -107,6 +233,15 @@ msgstr "上次同步"
|
||||
msgid "Sync Info"
|
||||
msgstr "同步信息"
|
||||
|
||||
msgid "Library not initialized"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books synced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books sync failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking for update…"
|
||||
msgstr "正在检查更新…"
|
||||
|
||||
@@ -167,6 +302,9 @@ msgstr "完整同步:正在拉取所有标注..."
|
||||
msgid "Pulling annotations..."
|
||||
msgstr "正在拉取标注..."
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "认证失败,请重新登录"
|
||||
|
||||
msgid "Failed to pull annotations"
|
||||
msgstr "拉取标注失败"
|
||||
|
||||
@@ -176,9 +314,6 @@ msgstr "未发现新标注"
|
||||
msgid "%1 annotations pulled"
|
||||
msgstr "已拉取 %1 条标注"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "取消"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "登录"
|
||||
|
||||
@@ -218,9 +353,6 @@ msgstr "推送阅读进度失败"
|
||||
msgid "Pulling reading progress..."
|
||||
msgstr "正在拉取阅读进度..."
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "认证失败,请重新登录"
|
||||
|
||||
msgid "Failed to pull reading progress"
|
||||
msgstr "拉取阅读进度失败"
|
||||
|
||||
|
||||
@@ -17,6 +17,123 @@ msgstr "Readest"
|
||||
msgid "Keeps your KOReader and Readest devices in sync."
|
||||
msgstr "讓你的 KOReader 與 Readest 裝置保持同步。"
|
||||
|
||||
msgid "Library view"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Crop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group by"
|
||||
msgstr ""
|
||||
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Series"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort by"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Read"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date Added"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr "書名"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "作者"
|
||||
|
||||
msgid "Descending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ascending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rescan library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download folder…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pick a folder for downloaded books"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in to Readest to open the Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "取消"
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search title or author"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Browser plugin required for full Library rendering. Falling back to plain list."
|
||||
msgstr ""
|
||||
|
||||
msgid "File moved or deleted. Rescan library?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download this book from Readest?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set Home folder in File Manager first to enable downloads."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud copy unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Set auto progress sync"
|
||||
msgstr "設定自動同步閱讀進度"
|
||||
|
||||
@@ -41,12 +158,27 @@ msgstr "從此裝置推送 Readest 註釋"
|
||||
msgid "Pull readest annotations from other devices"
|
||||
msgstr "從其他裝置拉取 Readest 註釋"
|
||||
|
||||
msgid "Open Readest Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull Readest book library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log in Readest Account"
|
||||
msgstr "登入 Readest 帳戶"
|
||||
|
||||
msgid "Log out as %1"
|
||||
msgstr "登出(%1)"
|
||||
|
||||
msgid "Push books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pull books now"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto sync progress and annotations"
|
||||
msgstr "自動同步閱讀進度和註釋"
|
||||
|
||||
@@ -92,12 +224,6 @@ msgstr "尚未同步"
|
||||
msgid "Book Fingerprint"
|
||||
msgstr "書籍指紋"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "書名"
|
||||
|
||||
msgid "Author"
|
||||
msgstr "作者"
|
||||
|
||||
msgid "Identifiers"
|
||||
msgstr "識別碼"
|
||||
|
||||
@@ -107,6 +233,15 @@ msgstr "上次同步"
|
||||
msgid "Sync Info"
|
||||
msgstr "同步資訊"
|
||||
|
||||
msgid "Library not initialized"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books synced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Books sync failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking for update…"
|
||||
msgstr "正在檢查更新…"
|
||||
|
||||
@@ -167,6 +302,9 @@ msgstr "完整同步:正在拉取所有註釋…"
|
||||
msgid "Pulling annotations..."
|
||||
msgstr "正在拉取註釋…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "驗證失敗,請重新登入"
|
||||
|
||||
msgid "Failed to pull annotations"
|
||||
msgstr "拉取註釋失敗"
|
||||
|
||||
@@ -176,9 +314,6 @@ msgstr "未發現新註釋"
|
||||
msgid "%1 annotations pulled"
|
||||
msgstr "已拉取 %1 條註釋"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "取消"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "登入"
|
||||
|
||||
@@ -218,9 +353,6 @@ msgstr "推送閱讀進度失敗"
|
||||
msgid "Pulling reading progress..."
|
||||
msgstr "正在拉取閱讀進度…"
|
||||
|
||||
msgid "Authentication failed, please login again"
|
||||
msgstr "驗證失敗,請重新登入"
|
||||
|
||||
msgid "Failed to pull reading progress"
|
||||
msgstr "拉取閱讀進度失敗"
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ local KeyValuePage = require("ui/widget/keyvaluepage")
|
||||
local WidgetContainer = require("ui/widget/container/widgetcontainer")
|
||||
local NetworkMgr = require("ui/network/manager")
|
||||
local UIManager = require("ui/uimanager")
|
||||
local logger = require("logger")
|
||||
local sha2 = require("ffi/sha2")
|
||||
local T = require("ffi/util").template
|
||||
local _ = require("i18n")
|
||||
@@ -46,9 +47,35 @@ function ReadestSync:init()
|
||||
self.installed_version = meta and meta.version and tostring(meta.version)
|
||||
|
||||
self.ui.menu:registerToMainMenu(self)
|
||||
-- Dispatcher actions (gestures / quick menu entries). Registering
|
||||
-- here in init() rather than onReaderReady so the FileManager-only
|
||||
-- actions (Readest Library, Push books, Pull books — flagged with
|
||||
-- general=true) show up in FileManager → Settings → Taps and gestures
|
||||
-- when no document is open. Reader-only actions (auto sync etc.)
|
||||
-- still get registered on first plugin load too — they just don't
|
||||
-- fire until a document opens.
|
||||
self:onDispatcherRegisterActions()
|
||||
-- Long-press file menu in FileManager: add an "Add to Readest"
|
||||
-- entry that hashes the file, registers it in the LibraryStore, and
|
||||
-- uploads it to the user's Readest cloud. Skipped in reader context
|
||||
-- (FileManager.instance is nil there).
|
||||
self:registerFileDialogButton()
|
||||
end
|
||||
|
||||
-- Register Library actions (Open / Push / Pull) — available in both
|
||||
-- reader and FileManager via the `general=true` flag.
|
||||
function ReadestSync:onDispatcherRegisterActions()
|
||||
Dispatcher:registerAction("readest_open_library", { category="none", event="ReadestOpenLibrary", title=_("Open Readest Library"), general=true,})
|
||||
Dispatcher:registerAction("readest_push_books", { category="none", event="ReadestPushBooks", title=_("Push Readest book library"), general=true,})
|
||||
Dispatcher:registerAction("readest_pull_books", { category="none", event="ReadestPullBooks", title=_("Pull Readest book library"), general=true,})
|
||||
end
|
||||
|
||||
-- Reader-only actions (progress + annotations sync) — registered only
|
||||
-- after a document opens, so they don't appear in the gesture picker
|
||||
-- when FileManager is the foreground context. They'd never fire from
|
||||
-- FileManager anyway (the handlers operate on the active ReaderUI), so
|
||||
-- showing them there would just be noise.
|
||||
function ReadestSync:onDispatcherRegisterReaderActions()
|
||||
Dispatcher:registerAction("readest_sync_set_autosync",
|
||||
{ category="string", event="ReadestSyncToggleAutoSync", title=_("Set auto progress sync"), reader=true,
|
||||
args={true, false}, toggle={_("on"), _("off")},})
|
||||
@@ -66,7 +93,199 @@ function ReadestSync:onReaderReady()
|
||||
self:pullBookNotes(false)
|
||||
end)
|
||||
end
|
||||
self:onDispatcherRegisterActions()
|
||||
self:onDispatcherRegisterReaderActions()
|
||||
end
|
||||
|
||||
-- Reverse-lookup table: file extension (lowercase) → Readest format
|
||||
-- token (uppercase). Computed once at module load from EXTS so we don't
|
||||
-- pay the iteration cost per long-press.
|
||||
local _readest_format_for_ext = nil
|
||||
local function readest_format_for_ext(ext)
|
||||
if not _readest_format_for_ext then
|
||||
_readest_format_for_ext = {}
|
||||
local EXTS = require("library.exts")
|
||||
for fmt, e in pairs(EXTS) do _readest_format_for_ext[e] = fmt end
|
||||
end
|
||||
return ext and _readest_format_for_ext[ext:lower()]
|
||||
end
|
||||
|
||||
-- Register an "Add to Readest" button in FileManager's long-press file
|
||||
-- dialog (the one with Paste / Cut / Delete / Copy / Reading / On hold
|
||||
-- / etc.). The button only renders for supported book formats and
|
||||
-- when the user is signed in to Readest.
|
||||
--
|
||||
-- Why scheduleIn(0): plugin init() runs while KOReader is still
|
||||
-- constructing the FileManager — FileManager.instance is not yet
|
||||
-- assigned. Deferring to the next event-loop tick (same trick simpleui
|
||||
-- uses at plugins/simpleui.koplugin/main.lua:261) lets the FileManager
|
||||
-- finish its own init first so .instance is populated when we look it
|
||||
-- up. Safe in reader context too: .instance stays nil so we no-op.
|
||||
function ReadestSync:registerFileDialogButton()
|
||||
local plugin = self
|
||||
UIManager:scheduleIn(0, function()
|
||||
local ok_FM, FileManager = pcall(require, "apps/filemanager/filemanager")
|
||||
if not ok_FM or not FileManager.instance then return end
|
||||
FileManager.instance:addFileDialogButtons("readest_add_to_library",
|
||||
function(file, is_file, _book_props)
|
||||
if not is_file then return nil end
|
||||
local ext = file:match("%.([^./\\]+)$")
|
||||
if not readest_format_for_ext(ext) then return nil end
|
||||
return {
|
||||
{
|
||||
text = _("Add to Readest"),
|
||||
enabled = plugin.settings.access_token ~= nil,
|
||||
callback = function()
|
||||
local fc = FileManager.instance and FileManager.instance.file_chooser
|
||||
local dlg = fc and fc.file_dialog
|
||||
if dlg then UIManager:close(dlg) end
|
||||
plugin:addToReadest(file)
|
||||
end,
|
||||
},
|
||||
}
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Hash the file, upsert a Library row, then upload to cloud. Mirrors
|
||||
-- the upload flow we already use in librarywidget's "Upload to Cloud"
|
||||
-- action — the only new thing here is computing the partial_md5 from
|
||||
-- the file directly, since long-pressing in FileManager doesn't go
|
||||
-- through the Library row path.
|
||||
function ReadestSync:addToReadest(file)
|
||||
local lfs = require("libs/libkoreader-lfs")
|
||||
local util = require("util")
|
||||
|
||||
if not self.settings.access_token then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Sign in to Readest first."), timeout = 3,
|
||||
})
|
||||
return
|
||||
end
|
||||
local attr = lfs.attributes(file)
|
||||
if not attr or attr.mode ~= "file" then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("File not found."), timeout = 3,
|
||||
})
|
||||
return
|
||||
end
|
||||
local ext = file:match("%.([^./\\]+)$")
|
||||
local format = readest_format_for_ext(ext)
|
||||
if not format then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Unsupported book format."), timeout = 3,
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
-- Hash via util.partialMD5 — same algorithm Readest uses, fast
|
||||
-- (reads small chunks at fixed offsets, no full-file scan).
|
||||
local progress = InfoMessage:new{
|
||||
text = _("Hashing book…"),
|
||||
}
|
||||
UIManager:show(progress)
|
||||
UIManager:nextTick(function()
|
||||
local hash = util.partialMD5(file)
|
||||
UIManager:close(progress)
|
||||
if not hash then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Could not read file."), timeout = 3,
|
||||
})
|
||||
return
|
||||
end
|
||||
self:_addLocalRow(file, hash, format, attr.size)
|
||||
end)
|
||||
end
|
||||
|
||||
function ReadestSync:_addLocalRow(file, hash, format, _size)
|
||||
local store = self:getLibraryStore()
|
||||
if not store then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Sign in to Readest first."), timeout = 3,
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
-- Title: filename minus extension. BIM might have richer metadata
|
||||
-- if the user has opened the book before, but a filename-derived
|
||||
-- title is good enough for the row to round-trip — the user can
|
||||
-- later trigger an Upload to Cloud which carries it to Readest.
|
||||
local basename = file:match("([^/]+)$") or file
|
||||
local title = basename:gsub("%.[^.]+$", "")
|
||||
local now = math.floor(os.time() * 1000)
|
||||
logger.info("ReadestSync addToReadest: hash=" .. hash:sub(1, 8)
|
||||
.. " title=" .. tostring(title)
|
||||
.. " format=" .. tostring(format)
|
||||
.. " path=" .. tostring(file)
|
||||
.. " now=" .. tostring(now))
|
||||
|
||||
-- Dedupe by partial_md5: if a non-tombstoned row with this hash is
|
||||
-- already in the user's library, just bump its updated_at and skip.
|
||||
-- Tombstoned rows (deleted_at set) are treated as "not in library"
|
||||
-- — re-adding writes a fresh local-only row with cloud_present=0.
|
||||
local existing = store:_getRowRaw(hash)
|
||||
if existing then
|
||||
logger.info("ReadestSync addToReadest: existing row found"
|
||||
.. " cloud_present=" .. tostring(existing.cloud_present)
|
||||
.. " local_present=" .. tostring(existing.local_present)
|
||||
.. " deleted_at=" .. tostring(existing.deleted_at)
|
||||
.. " updated_at=" .. tostring(existing.updated_at))
|
||||
else
|
||||
logger.info("ReadestSync addToReadest: no existing row — inserting")
|
||||
end
|
||||
if existing and existing.deleted_at == nil then
|
||||
store:upsertBook({
|
||||
hash = hash,
|
||||
title = existing.title or title,
|
||||
file_path = file,
|
||||
local_present = 1,
|
||||
updated_at = now,
|
||||
})
|
||||
logger.info("ReadestSync addToReadest dedupe: bumped updated_at for "
|
||||
.. hash:sub(1, 8) .. " to " .. tostring(now))
|
||||
local LibraryWidget = require("library.librarywidget")
|
||||
if LibraryWidget._menu then LibraryWidget.refresh() end
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Already in your Readest library:") .. " "
|
||||
.. (existing.title or title),
|
||||
timeout = 2,
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
-- Add as a local-only row (cloud_present defaults to 0). Stamp
|
||||
-- created_at + updated_at explicitly so the row sorts under "Date
|
||||
-- Added" / "Last Updated" right away, and so the next pushChangedBooks
|
||||
-- pass picks it up (its query is `updated_at > since`).
|
||||
-- _clear_fields un-tombstones the row when re-adding a previously-
|
||||
-- deleted book — passing deleted_at = nil alone wouldn't clear it
|
||||
-- because Lua tables drop nil keys and upsertBook's preserve pass
|
||||
-- would copy the existing tombstone forward.
|
||||
store:upsertBook({
|
||||
hash = hash,
|
||||
title = title,
|
||||
format = format,
|
||||
file_path = file,
|
||||
local_present = 1,
|
||||
created_at = now,
|
||||
updated_at = now,
|
||||
_clear_fields = { "deleted_at" },
|
||||
})
|
||||
local row = store:_getRowRaw(hash)
|
||||
logger.info("ReadestSync addToReadest insert: stored row"
|
||||
.. " updated_at=" .. tostring(row and row.updated_at)
|
||||
.. " created_at=" .. tostring(row and row.created_at)
|
||||
.. " cloud_present=" .. tostring(row and row.cloud_present)
|
||||
.. " local_present=" .. tostring(row and row.local_present))
|
||||
local LibraryWidget = require("library.librarywidget")
|
||||
if LibraryWidget._menu then LibraryWidget.refresh() end
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Added to Readest:") .. " " .. title,
|
||||
timeout = 2,
|
||||
})
|
||||
end
|
||||
|
||||
function ReadestSync:onAddToReadest(file)
|
||||
self:addToReadest(file)
|
||||
end
|
||||
|
||||
-- ── Menu ───────────────────────────────────────────────────────────
|
||||
@@ -92,16 +311,43 @@ function ReadestSync:addToMainMenu(menu_items)
|
||||
end
|
||||
end
|
||||
end,
|
||||
separator = true,
|
||||
},
|
||||
{
|
||||
text = _("Auto sync progress and annotations"),
|
||||
text = _("Auto sync"),
|
||||
checked_func = function() return self.settings.auto_sync end,
|
||||
callback = function()
|
||||
self:onReadestSyncToggleAutoSync()
|
||||
end,
|
||||
separator = true,
|
||||
},
|
||||
{
|
||||
text = _("Readest library"),
|
||||
enabled_func = function()
|
||||
return self.settings.access_token ~= nil and self.settings.user_id ~= nil
|
||||
end,
|
||||
callback = function()
|
||||
self:openLibrary()
|
||||
end,
|
||||
},
|
||||
{
|
||||
text = _("Push books now"),
|
||||
enabled_func = function()
|
||||
return self.settings.access_token ~= nil and self.settings.user_id ~= nil
|
||||
end,
|
||||
callback = function()
|
||||
self:syncBooksLibrary("push", true)
|
||||
end,
|
||||
},
|
||||
{
|
||||
text = _("Pull books now"),
|
||||
enabled_func = function()
|
||||
return self.settings.access_token ~= nil and self.settings.user_id ~= nil
|
||||
end,
|
||||
callback = function()
|
||||
self:syncBooksLibrary("pull", true)
|
||||
end,
|
||||
separator = true,
|
||||
},
|
||||
{
|
||||
text = _("Push reading progress now"),
|
||||
enabled_func = function()
|
||||
@@ -351,15 +597,141 @@ function ReadestSync:onReadestSyncPullAnnotations()
|
||||
self:pullBookNotes(true)
|
||||
end
|
||||
|
||||
function ReadestSync:openLibrary()
|
||||
if not self.settings.access_token or not self.settings.user_id then
|
||||
UIManager:show(InfoMessage:new{ text = _("Please login first"), timeout = 2 })
|
||||
return
|
||||
end
|
||||
local LibraryWidget = require("library.librarywidget")
|
||||
LibraryWidget.open({
|
||||
settings = self.settings,
|
||||
sync_path = self.path,
|
||||
sync_auth = SyncAuth,
|
||||
})
|
||||
end
|
||||
|
||||
function ReadestSync:onReadestOpenLibrary()
|
||||
self:openLibrary()
|
||||
end
|
||||
|
||||
-- Lazy-open a LibraryStore for the current user. The Library widget may
|
||||
-- already have one open via librarywidget._store; we share it when present
|
||||
-- to avoid two SQLite handles to the same file. Returns nil if user_id
|
||||
-- isn't set (shouldn't happen — caller should gate on access_token).
|
||||
function ReadestSync:getLibraryStore()
|
||||
if not self.settings.user_id or self.settings.user_id == "" then return nil end
|
||||
local LibraryWidget = require("library.librarywidget")
|
||||
if LibraryWidget._store and LibraryWidget._current_user == self.settings.user_id then
|
||||
return LibraryWidget._store
|
||||
end
|
||||
if self.library_store and self.library_store.user_id == self.settings.user_id then
|
||||
return self.library_store
|
||||
end
|
||||
if self.library_store then self.library_store:close() end
|
||||
local LibraryStore = require("library.librarystore")
|
||||
local DataStorage = require("datastorage")
|
||||
self.library_store = LibraryStore.new({
|
||||
user_id = self.settings.user_id,
|
||||
db_path = DataStorage:getSettingsDir() .. "/readest_library.sqlite3",
|
||||
})
|
||||
return self.library_store
|
||||
end
|
||||
|
||||
-- Bump updated_at + last_read_at on the local row for the currently-open
|
||||
-- book. Called before a sync push so the row's timestamp is fresh. Returns
|
||||
-- the touched row, or nil if there's nothing to touch (no book open, no
|
||||
-- partial_md5, or hash not in the LibraryStore index).
|
||||
function ReadestSync:touchOpenBook()
|
||||
if not self.ui or not self.ui.doc_settings then return nil end
|
||||
local hash = self.ui.doc_settings:readSetting("partial_md5_checksum")
|
||||
if not hash or hash == "" then return nil end
|
||||
|
||||
local store = self:getLibraryStore()
|
||||
if not store then return nil end
|
||||
|
||||
local progress_lib
|
||||
if self.ui.document and self.ui.document.getPageCount and self.ui.getCurrentPage then
|
||||
local cur = self.ui:getCurrentPage()
|
||||
local total = self.ui.document:getPageCount()
|
||||
if cur and total then
|
||||
progress_lib = require("json").encode({ cur, total })
|
||||
end
|
||||
end
|
||||
|
||||
local touched = store:touchBook(hash, { progress_lib = progress_lib })
|
||||
if not touched then
|
||||
logger.dbg("ReadestSync touchOpenBook: no row for " .. hash
|
||||
.. " (book not in LibraryStore index)")
|
||||
end
|
||||
return touched
|
||||
end
|
||||
|
||||
-- syncBooksLibrary(mode, interactive) — bidirectional book-row sync,
|
||||
-- mirroring useBooksSync.handleAutoSync at apps/readest-app/src/app/
|
||||
-- library/hooks/useBooksSync.ts:66-78. mode: "push"|"pull"|"both".
|
||||
-- Touches the currently-open book first so its updated_at gets included
|
||||
-- in the push delta. Interactive=true shows toast feedback.
|
||||
function ReadestSync:syncBooksLibrary(mode, interactive)
|
||||
if not self.settings.access_token or not self.settings.user_id then
|
||||
if interactive then
|
||||
UIManager:show(InfoMessage:new{ text = _("Please login first"), timeout = 2 })
|
||||
end
|
||||
return
|
||||
end
|
||||
local store = self:getLibraryStore()
|
||||
if not store then
|
||||
if interactive then
|
||||
UIManager:show(InfoMessage:new{ text = _("Library not initialized"), timeout = 2 })
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if mode ~= "pull" then
|
||||
-- Touch only matters for push (and 'both' which includes push)
|
||||
self:touchOpenBook()
|
||||
end
|
||||
|
||||
local syncbooks = require("library.syncbooks")
|
||||
syncbooks.syncBooks({
|
||||
sync_auth = SyncAuth,
|
||||
sync_path = self.path,
|
||||
settings = self.settings,
|
||||
store = store,
|
||||
}, mode, function(success, msg, status)
|
||||
logger.info("ReadestSync syncBooksLibrary[" .. mode .. "] done: success="
|
||||
.. tostring(success) .. " msg=" .. tostring(msg))
|
||||
if interactive then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = success
|
||||
and _("Books synced")
|
||||
or _("Books sync failed"),
|
||||
timeout = 2,
|
||||
})
|
||||
end
|
||||
-- If the Library widget is open, refresh it so newly-pulled rows show
|
||||
local LibraryWidget = require("library.librarywidget")
|
||||
if LibraryWidget._menu then LibraryWidget.refresh() end
|
||||
end)
|
||||
end
|
||||
|
||||
function ReadestSync:onCloseDocument()
|
||||
if self.settings.auto_sync and self.settings.access_token then
|
||||
NetworkMgr:goOnlineToRun(function()
|
||||
self:pushBookConfig(false)
|
||||
self:pushBookNotes(false)
|
||||
self:syncBooksLibrary("both", false)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
function ReadestSync:onReadestPushBooks()
|
||||
self:syncBooksLibrary("push", true)
|
||||
end
|
||||
|
||||
function ReadestSync:onReadestPullBooks()
|
||||
self:syncBooksLibrary("pull", true)
|
||||
end
|
||||
|
||||
function ReadestSync:onPageUpdate(page)
|
||||
if self.settings.auto_sync and self.settings.access_token and page then
|
||||
if self.delayed_push_task then
|
||||
|
||||
@@ -14,6 +14,37 @@
|
||||
"required_params": ["books", "notes", "configs"],
|
||||
"payload": ["books", "notes", "configs"],
|
||||
"expected_status": [200, 201, 301, 400, 401, 403]
|
||||
},
|
||||
"pullBooks": {
|
||||
"path": "/sync?type=books",
|
||||
"method": "GET",
|
||||
"required_params": ["since"],
|
||||
"expected_status": [200, 400, 301, 401, 403]
|
||||
},
|
||||
"getDownloadUrl": {
|
||||
"path": "/storage/download",
|
||||
"method": "GET",
|
||||
"required_params": ["fileKey"],
|
||||
"expected_status": [200, 400, 301, 401, 403, 404]
|
||||
},
|
||||
"listFiles": {
|
||||
"path": "/storage/list",
|
||||
"method": "GET",
|
||||
"required_params": ["bookHash"],
|
||||
"expected_status": [200, 400, 301, 401, 403]
|
||||
},
|
||||
"deleteFile": {
|
||||
"path": "/storage/delete",
|
||||
"method": "DELETE",
|
||||
"required_params": ["fileKey"],
|
||||
"expected_status": [200, 400, 301, 401, 403, 404]
|
||||
},
|
||||
"getUploadUrl": {
|
||||
"path": "/storage/upload",
|
||||
"method": "POST",
|
||||
"required_params": ["fileName", "fileSize", "bookHash"],
|
||||
"payload": ["fileName", "fileSize", "bookHash"],
|
||||
"expected_status": [200, 400, 301, 401, 403]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,27 +68,30 @@ function ReadestSyncClient:init()
|
||||
end
|
||||
end
|
||||
|
||||
function ReadestSyncClient:pullChanges(params, callback)
|
||||
-- Internal: prepare the Spore client with our standard middleware stack.
|
||||
-- Call before each RPC.
|
||||
function ReadestSyncClient:_prepare()
|
||||
self.client:reset_middlewares()
|
||||
self.client:enable("Format.JSON")
|
||||
self.client:enable("ReadestHeaders", {})
|
||||
self.client:enable("ReadestAuth", {})
|
||||
|
||||
end
|
||||
|
||||
-- Internal: dispatch a Spore RPC and invoke `callback(success, body, status)`
|
||||
-- when the async response arrives. The status is forwarded so callers can
|
||||
-- distinguish 401/403/404 from generic failure (codex round 1 finding 15).
|
||||
function ReadestSyncClient:_dispatch(name, args, callback)
|
||||
self:_prepare()
|
||||
socketutil:set_timeout(SYNC_TIMEOUTS[1], SYNC_TIMEOUTS[2])
|
||||
local co = coroutine.create(function()
|
||||
local ok, res = pcall(function()
|
||||
return self.client:pullChanges({
|
||||
since = params.since,
|
||||
type = params.type,
|
||||
book = params.book,
|
||||
meta_hash = params.meta_hash,
|
||||
})
|
||||
return self.client[name](self.client, args)
|
||||
end)
|
||||
if ok then
|
||||
callback(res.status == 200, res.body)
|
||||
callback(res.status == 200, res.body, res.status)
|
||||
else
|
||||
logger.dbg("ReadestSyncClient:pullChanges failure:", res)
|
||||
callback(false, res.body)
|
||||
logger.dbg("ReadestSyncClient:" .. name .. " failure:", res)
|
||||
callback(false, res and res.body or nil, res and res.status or nil)
|
||||
end
|
||||
end)
|
||||
self.client:enable("AsyncHTTP", {thread = co})
|
||||
@@ -97,28 +100,64 @@ function ReadestSyncClient:pullChanges(params, callback)
|
||||
socketutil:reset_timeout()
|
||||
end
|
||||
|
||||
function ReadestSyncClient:pullChanges(params, callback)
|
||||
self:_dispatch("pullChanges", {
|
||||
since = params.since,
|
||||
type = params.type,
|
||||
book = params.book,
|
||||
meta_hash = params.meta_hash,
|
||||
}, callback)
|
||||
end
|
||||
|
||||
function ReadestSyncClient:pushChanges(changes, callback)
|
||||
self.client:reset_middlewares()
|
||||
self.client:enable("Format.JSON")
|
||||
self.client:enable("ReadestHeaders", {})
|
||||
self.client:enable("ReadestAuth", {})
|
||||
self:_dispatch("pushChanges", changes or {}, callback)
|
||||
end
|
||||
|
||||
socketutil:set_timeout(SYNC_TIMEOUTS[1], SYNC_TIMEOUTS[2])
|
||||
local co = coroutine.create(function()
|
||||
local ok, res = pcall(function()
|
||||
return self.client:pushChanges(changes or {})
|
||||
end)
|
||||
if ok then
|
||||
callback(res.status == 200, res.body)
|
||||
else
|
||||
logger.dbg("ReadestSyncClient:pushChanges failure:", res)
|
||||
callback(false, res.body)
|
||||
end
|
||||
end)
|
||||
self.client:enable("AsyncHTTP", {thread = co})
|
||||
coroutine.resume(co)
|
||||
if UIManager.looper then UIManager:setInputTimeout() end
|
||||
socketutil:reset_timeout()
|
||||
-- pullBooks: incremental fetch of the books table since the watermark.
|
||||
-- Returns body shape `{ books: [...] }`. Drives Library:open() refresh.
|
||||
function ReadestSyncClient:pullBooks(params, callback)
|
||||
self:_dispatch("pullBooks", { since = params.since }, callback)
|
||||
end
|
||||
|
||||
-- getDownloadUrl: resolve a storage fileKey to a signed URL. The server's
|
||||
-- processFileKeys fallback at apps/readest-app/src/pages/api/storage/
|
||||
-- download.ts:99-107 lets us send the simple {hash}/{hash}.{ext} variant
|
||||
-- and have R2 deployments resolve to the actual stored filename
|
||||
-- transparently. Body shape on success: { downloadUrl }.
|
||||
function ReadestSyncClient:getDownloadUrl(params, callback)
|
||||
self:_dispatch("getDownloadUrl", { fileKey = params.fileKey }, callback)
|
||||
end
|
||||
|
||||
-- listFiles: enumerate the rows of the `files` table for a given book
|
||||
-- hash. Used to discover the EXACT fileKeys (book object + cover.png)
|
||||
-- before deletion — the DELETE endpoint requires a literal match (no
|
||||
-- extension fallback like /storage/download has). Body shape on success:
|
||||
-- { files: [ { file_key, file_size, book_hash, ... } ] }.
|
||||
function ReadestSyncClient:listFiles(params, callback)
|
||||
self:_dispatch("listFiles", { bookHash = params.bookHash }, callback)
|
||||
end
|
||||
|
||||
-- deleteFile: remove one storage object plus its `files` row. Caller
|
||||
-- usually iterates over listFiles output. The `books` row stays put;
|
||||
-- cleanup of the books table is a separate /sync push (with deletedAt).
|
||||
function ReadestSyncClient:deleteFile(params, callback)
|
||||
self:_dispatch("deleteFile", { fileKey = params.fileKey }, callback)
|
||||
end
|
||||
|
||||
-- getUploadUrl: ask the server to issue a presigned PUT URL for a new
|
||||
-- storage object. Two-step flow (`apps/readest-app/src/pages/api/storage/
|
||||
-- upload.ts`): server inserts a row in the `files` table for
|
||||
-- (user, bookHash, fileKey) BEFORE the actual bytes move. Body shape on
|
||||
-- success: { uploadUrl, fileKey, usage, quota }. Quota-exceeded → 403.
|
||||
-- The fileName must be the cloud-relative path (eg
|
||||
-- "Readest/Books/<hash>/<hash>.epub"); the server prepends "<user.id>/"
|
||||
-- to form the final fileKey.
|
||||
function ReadestSyncClient:getUploadUrl(params, callback)
|
||||
self:_dispatch("getUploadUrl", {
|
||||
fileName = params.fileName,
|
||||
fileSize = params.fileSize,
|
||||
bookHash = params.bookHash,
|
||||
}, callback)
|
||||
end
|
||||
|
||||
return ReadestSyncClient
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build a Readest-${version}-1.koplugin.zip locally for sideloading
|
||||
* onto a real KOReader device. Mirrors the same exclusions the CI
|
||||
* release workflow uses (.github/workflows/release.yml: "create
|
||||
* KOReader plugin zip"):
|
||||
* scripts/ — i18n + build helpers
|
||||
* docs/ — design notes
|
||||
* spec/ — busted test suite
|
||||
* .busted — busted runner config
|
||||
*
|
||||
* Usage:
|
||||
* node apps/readest.koplugin/scripts/build-koplugin.js [--version X.Y.Z]
|
||||
* [--out PATH]
|
||||
* [--keep-meta]
|
||||
*
|
||||
* --version Stamp _meta.lua with this version before zipping. If
|
||||
* omitted, a "dev-<sha>" placeholder is used so the
|
||||
* installed plugin is identifiable. _meta.lua is restored
|
||||
* after zipping unless --keep-meta is passed.
|
||||
*
|
||||
* --out PATH Destination zip path. Default:
|
||||
* ./Readest-${version}-1.koplugin.zip
|
||||
*
|
||||
* --keep-meta Don't restore _meta.lua after the build. Useful when
|
||||
* chaining a release tag.
|
||||
*
|
||||
* Requirements: a working `zip` binary on PATH (macOS/Linux/WSL/git-bash).
|
||||
*/
|
||||
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PLUGIN_DIR = path.resolve(__dirname, '..');
|
||||
const PLUGIN_NAME = path.basename(PLUGIN_DIR); // "readest.koplugin"
|
||||
const APPS_DIR = path.resolve(PLUGIN_DIR, '..');
|
||||
const META_FILE = path.join(PLUGIN_DIR, '_meta.lua');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { keepMeta: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--version') out.version = argv[++i];
|
||||
else if (a === '--out') out.out = argv[++i];
|
||||
else if (a === '--keep-meta') out.keepMeta = true;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
console.log(`Usage:
|
||||
node build-koplugin.js [--version X.Y.Z] [--out PATH] [--keep-meta]
|
||||
|
||||
Default version: dev-<git-sha>
|
||||
Default out: ./Readest-<version>-1.koplugin.zip`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`Unknown argument: ${a}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function shortGitSha() {
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', '--short=8', 'HEAD'], {
|
||||
cwd: PLUGIN_DIR,
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function which(cmd) {
|
||||
const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd]);
|
||||
return r.status === 0;
|
||||
}
|
||||
|
||||
function stampMeta(version) {
|
||||
const original = fs.readFileSync(META_FILE, 'utf8');
|
||||
// Inject ` version = "X",` immediately before the closing `}`.
|
||||
// Same regex shape as the CI step's perl one-liner; matches a `}`
|
||||
// at the start of a line and prepends the version line.
|
||||
const stamped = original.replace(/^}/m, ` version = "${version}",\n}`);
|
||||
fs.writeFileSync(META_FILE, stamped);
|
||||
return original;
|
||||
}
|
||||
|
||||
function restoreMeta(original) {
|
||||
fs.writeFileSync(META_FILE, original);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const version = args.version || `dev-${shortGitSha()}`;
|
||||
const out = path.resolve(args.out || `Readest-${version}-1.koplugin.zip`);
|
||||
|
||||
if (!which('zip')) {
|
||||
console.error('error: `zip` not found on PATH. Install via your package manager.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Remove an existing zip so `zip -r` doesn't accidentally append.
|
||||
if (fs.existsSync(out)) fs.unlinkSync(out);
|
||||
|
||||
const originalMeta = stampMeta(version);
|
||||
let zipExit = 1;
|
||||
try {
|
||||
const exclusions = [
|
||||
`${PLUGIN_NAME}/scripts/*`,
|
||||
`${PLUGIN_NAME}/docs/*`,
|
||||
`${PLUGIN_NAME}/spec/*`,
|
||||
`${PLUGIN_NAME}/.busted`,
|
||||
];
|
||||
const zipArgs = ['-r', out, PLUGIN_NAME, '-x', ...exclusions];
|
||||
console.log(`Building ${path.basename(out)} from ${PLUGIN_DIR}`);
|
||||
console.log(` excluding: ${exclusions.join(', ')}`);
|
||||
const r = spawnSync('zip', zipArgs, {
|
||||
cwd: APPS_DIR,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
zipExit = r.status === null ? 1 : r.status;
|
||||
} finally {
|
||||
if (!args.keepMeta) restoreMeta(originalMeta);
|
||||
}
|
||||
|
||||
if (zipExit !== 0) {
|
||||
console.error(`zip exited with code ${zipExit}`);
|
||||
process.exit(zipExit);
|
||||
}
|
||||
const stat = fs.statSync(out);
|
||||
console.log(`✓ ${out} (${(stat.size / 1024).toFixed(1)} KiB)`);
|
||||
}
|
||||
|
||||
main();
|
||||
Binary file not shown.
+21
-6
@@ -17,18 +17,33 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const KOPLUGIN_DIR = path.resolve(__dirname, '..', '..', 'readest.koplugin');
|
||||
// Script lives at apps/readest.koplugin/scripts/; one parent up is the
|
||||
// koplugin root.
|
||||
const KOPLUGIN_DIR = path.resolve(__dirname, '..');
|
||||
|
||||
if (!fs.existsSync(KOPLUGIN_DIR)) {
|
||||
console.error(`koplugin directory not found at ${KOPLUGIN_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const luaFiles = fs
|
||||
.readdirSync(KOPLUGIN_DIR)
|
||||
.filter((f) => f.endsWith('.lua'))
|
||||
.map((f) => path.join(KOPLUGIN_DIR, f))
|
||||
.sort();
|
||||
// Recurse into subdirectories so files under `library/` and `spec/` get
|
||||
// syntax-checked too. Skip dotfiles/dirs (e.g. `.busted`) and `node_modules`
|
||||
// in case anyone vendors a JS dep here later.
|
||||
function collectLuaFiles(dir) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...collectLuaFiles(full));
|
||||
} else if (entry.isFile() && entry.name.endsWith('.lua')) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const luaFiles = collectLuaFiles(KOPLUGIN_DIR).sort();
|
||||
|
||||
if (luaFiles.length === 0) {
|
||||
console.log('No .lua files to check.');
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run busted unit tests against apps/readest.koplugin under LuaJIT (the
|
||||
* runtime KOReader uses). Spec files live in `apps/readest.koplugin/spec/`
|
||||
* and are auto-discovered via `.busted` config.
|
||||
*
|
||||
* Invoked from `pnpm test:lua`. Soft-skips with a notice when busted or
|
||||
* luajit is not installed; CI installs both and runs unconditionally.
|
||||
*
|
||||
* Required toolchain (one-time):
|
||||
* brew install luajit luarocks (or apt-get install …)
|
||||
* luarocks --lua-version=5.1 install busted
|
||||
* luarocks --lua-version=5.1 install lsqlite3complete
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// Script lives at apps/readest.koplugin/scripts/; one parent up is the
|
||||
// koplugin root.
|
||||
const KOPLUGIN_DIR = path.resolve(__dirname, '..');
|
||||
|
||||
if (!fs.existsSync(KOPLUGIN_DIR)) {
|
||||
console.error(`koplugin directory not found at ${KOPLUGIN_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.join(KOPLUGIN_DIR, 'spec'))) {
|
||||
console.log('No spec/ directory under apps/readest.koplugin — nothing to test.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const which = (cmd, env = process.env) =>
|
||||
spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { env });
|
||||
|
||||
// In CI a missing toolchain is a hard failure (exit 1); locally it's a
|
||||
// soft skip (exit 0) so devs without busted installed don't get blocked.
|
||||
// GitHub Actions sets CI=true; respect any other CI's convention too.
|
||||
const HARD_FAIL = !!process.env.CI;
|
||||
const onMissing = (tool, install_hint) => {
|
||||
const msg =
|
||||
`test:lua: ${tool} not found. ` +
|
||||
`Install via ${install_hint}.` +
|
||||
(HARD_FAIL ? '' : ' Skipping (set CI=1 to make this an error).');
|
||||
if (HARD_FAIL) {
|
||||
console.error(msg);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.warn(msg);
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
if (which('luajit').status !== 0) {
|
||||
onMissing(
|
||||
'luajit',
|
||||
'`brew install luajit` on macOS, `apt-get install luajit` on Linux',
|
||||
);
|
||||
}
|
||||
|
||||
if (which('luarocks').status !== 0) {
|
||||
onMissing('luarocks', '`brew install luarocks` (or `apt-get install luarocks`)');
|
||||
}
|
||||
|
||||
// `luarocks --lua-version=5.1 path` emits export lines for PATH, LUA_PATH,
|
||||
// LUA_CPATH that point at the user's 5.1 rocks tree. pnpm's spawned shells
|
||||
// don't source the user's rc files, so ~/.luarocks/bin is usually missing
|
||||
// from PATH and `which busted` fails even when busted is installed. We
|
||||
// re-inject these vars into the spawn env here so subsequent lookups +
|
||||
// busted's own `require('busted.runner')` both succeed.
|
||||
const lrPath = spawnSync('luarocks', ['--lua-version=5.1', 'path'], { encoding: 'utf8' });
|
||||
if (lrPath.status !== 0) {
|
||||
if (HARD_FAIL) {
|
||||
console.error('test:lua: `luarocks --lua-version=5.1 path` failed.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.warn('test:lua: `luarocks --lua-version=5.1 path` failed.');
|
||||
process.exit(0);
|
||||
}
|
||||
const env = { ...process.env };
|
||||
for (const line of lrPath.stdout.split('\n')) {
|
||||
const m = line.match(/^export (LUA_PATH|LUA_CPATH|PATH)='(.*)'$/);
|
||||
if (m) {
|
||||
if (m[1] === 'PATH') {
|
||||
// luarocks's PATH already includes the rest of the user's PATH (it
|
||||
// re-emits the value it inherited), so an outright assignment is
|
||||
// safe and avoids a double-prepend.
|
||||
env.PATH = m[2];
|
||||
} else {
|
||||
env[m[1]] = m[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (which('busted', env).status !== 0) {
|
||||
onMissing(
|
||||
'busted',
|
||||
'`luarocks --lua-version=5.1 install busted` ' +
|
||||
'(and `lsqlite3complete` for SQLite tests)',
|
||||
);
|
||||
}
|
||||
|
||||
const luajitPath = which('luajit', env).stdout.toString().trim();
|
||||
const r = spawnSync('busted', [`--lua=${luajitPath}`], {
|
||||
cwd: KOPLUGIN_DIR,
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
});
|
||||
process.exit(r.status === null ? 1 : r.status);
|
||||
@@ -0,0 +1,69 @@
|
||||
# readest.koplugin tests
|
||||
|
||||
Unit tests for `apps/readest.koplugin/library/` modules. Runs under **LuaJIT
|
||||
2.1** (the runtime KOReader uses) via [busted](https://lunarmodules.github.io/busted/).
|
||||
|
||||
## Toolchain
|
||||
|
||||
One-time per machine:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install luajit luarocks
|
||||
|
||||
# Linux (Debian/Ubuntu)
|
||||
sudo apt-get install luajit luarocks
|
||||
|
||||
# Then, regardless of OS:
|
||||
luarocks --lua-version=5.1 install busted
|
||||
luarocks --lua-version=5.1 install lsqlite3complete
|
||||
```
|
||||
|
||||
The `--lua-version=5.1` flag is required: LuaJIT identifies itself as Lua 5.1, and we install rocks against that runtime so production code (which targets LuaJIT) and test code share a Lua interpreter.
|
||||
|
||||
## Running
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
pnpm test:lua
|
||||
```
|
||||
|
||||
Or from this directory:
|
||||
|
||||
```bash
|
||||
eval "$(luarocks --lua-version=5.1 path)"
|
||||
busted --lua=$(which luajit)
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
spec/
|
||||
├── spec_helper.lua # KOReader stubs + lua-ljsqlite3 shim (loaded once)
|
||||
├── library/
|
||||
│ ├── smoke_spec.lua # Sanity check that the harness boots
|
||||
│ └── *_spec.lua # One per module under library/
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## What `spec_helper` provides
|
||||
|
||||
- **`require("lua-ljsqlite3/init")`** → returns a SQLite shim wrapping `lsqlite3complete`. Exposes the subset of the lua-ljsqlite3 API our library modules use (`open`, `exec`, `prepare`, `bind1`, `step`, `reset`, `clearbind`, `close`, etc).
|
||||
- **`require("logger")`** → no-op logger (`warn`/`info`/`dbg`/`err` callable).
|
||||
- **`require("datastorage")`** → fake `DataStorage:getSettingsDir()` returning a per-test `mktemp -d` path.
|
||||
- **`require("device")`** → stub `Device.canUseWAL() == true`, `Device.screen` with `getWidth/getHeight`.
|
||||
- **`G_reader_settings`** (global) → in-memory `readSetting`/`saveSetting`/`flush`.
|
||||
|
||||
Each spec calls `require("spec_helper").reset()` in `before_each` to wipe state.
|
||||
|
||||
## Adding a new module
|
||||
|
||||
1. Write production code at `apps/readest.koplugin/library/foo.lua`.
|
||||
2. Write `apps/readest.koplugin/spec/library/foo_spec.lua`.
|
||||
3. Run `pnpm test:lua` from the repo root.
|
||||
4. Run `pnpm lint:lua` to syntax-check (LuaJIT bytecode compile).
|
||||
|
||||
## Why LuaJIT and not stock Lua?
|
||||
|
||||
KOReader runs LuaJIT exclusively. LuaJIT extends Lua 5.1 with FFI and a few syntax tweaks; stock Lua 5.4 has features (integer division `//`, bit operators `~`, `<const>` annotations) that LuaJIT rejects. Running tests under LuaJIT catches these incompatibilities at test time instead of when KOReader fails to load the plugin.
|
||||
@@ -0,0 +1,55 @@
|
||||
-- coverprovider_spec.lua
|
||||
-- Pure-function tests for library/coverprovider.lua. The blitbuffer-returning
|
||||
-- functions wrap BookInfoManager (coverbrowser plugin) and a streaming HTTP
|
||||
-- download; both require live KOReader and are exercised manually.
|
||||
|
||||
require("spec_helper")
|
||||
|
||||
describe("library.coverprovider", function()
|
||||
local cp
|
||||
|
||||
before_each(function()
|
||||
require("spec_helper").reset()
|
||||
package.loaded["library.coverprovider"] = nil
|
||||
cp = require("library.coverprovider")
|
||||
end)
|
||||
|
||||
describe("coverbrowser_loaded", function()
|
||||
it("returns false when the covermenu module isn't on package.path", function()
|
||||
-- Make absolutely sure no leftover stub is hanging around
|
||||
package.loaded["covermenu"] = nil
|
||||
package.preload["covermenu"] = nil
|
||||
assert.is_false(cp.coverbrowser_loaded())
|
||||
end)
|
||||
|
||||
it("returns true when the covermenu module IS available", function()
|
||||
package.preload["covermenu"] = function() return { _stub = true } end
|
||||
assert.is_true(cp.coverbrowser_loaded())
|
||||
package.preload["covermenu"] = nil
|
||||
package.loaded["covermenu"] = nil
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("cached_cover_path", function()
|
||||
it("returns {covers_dir}/{hash}.png", function()
|
||||
assert.are.equal("/cache/abc123.png",
|
||||
cp.cached_cover_path("/cache", "abc123"))
|
||||
end)
|
||||
|
||||
it("returns nil when either input is missing/empty", function()
|
||||
assert.is_nil(cp.cached_cover_path(nil, "h"))
|
||||
assert.is_nil(cp.cached_cover_path("/cache", nil))
|
||||
assert.is_nil(cp.cached_cover_path("", "h"))
|
||||
assert.is_nil(cp.cached_cover_path("/cache", ""))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("MISSING sentinel", function()
|
||||
it("exposes a stable 'no cover available' marker", function()
|
||||
assert.is_string(cp.MISSING)
|
||||
assert.is_truthy(cp.MISSING)
|
||||
-- Truthy + distinguishable from any real path
|
||||
assert.are.equal("_missing", cp.MISSING)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
@@ -0,0 +1,35 @@
|
||||
-- exts_spec.lua
|
||||
-- The EXTS table is small enough that a direct equality assertion is the
|
||||
-- right test: it doubles as a regression guard against accidental drift
|
||||
-- from `apps/readest-app/src/libs/document.ts`.
|
||||
|
||||
local EXTS = require("library.exts")
|
||||
|
||||
describe("library.exts", function()
|
||||
it("matches the web-side EXTS mapping verbatim", function()
|
||||
assert.are.same({
|
||||
EPUB = "epub",
|
||||
PDF = "pdf",
|
||||
MOBI = "mobi",
|
||||
AZW = "azw",
|
||||
AZW3 = "azw3",
|
||||
CBZ = "cbz",
|
||||
FB2 = "fb2",
|
||||
FBZ = "fbz",
|
||||
TXT = "txt",
|
||||
MD = "md",
|
||||
}, EXTS)
|
||||
end)
|
||||
|
||||
it("covers every format the cloud `book.format` field can hold", function()
|
||||
-- If the web side adds a new format, the syncbooks.lua fileKey builder
|
||||
-- will silently produce nil for the extension. This assertion catches
|
||||
-- the omission early.
|
||||
local required = {
|
||||
"EPUB", "PDF", "MOBI", "AZW", "AZW3", "CBZ", "FB2", "FBZ", "TXT", "MD",
|
||||
}
|
||||
for _, fmt in ipairs(required) do
|
||||
assert.is_string(EXTS[fmt], "missing extension for " .. fmt)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
@@ -0,0 +1,870 @@
|
||||
-- librarystore_spec.lua
|
||||
-- Defines the contract for library/librarystore.lua. Written before the
|
||||
-- implementation so the spec is the source of truth for "how the store
|
||||
-- should behave".
|
||||
|
||||
local helper = require("spec_helper")
|
||||
|
||||
-- Convenient time stamps for sorting tests (unix ms)
|
||||
local T_OLD = 1700000000000 -- 2023-11-14
|
||||
local T_MID = 1750000000000 -- 2025-06-15
|
||||
local T_RECENT = 1770000000000 -- 2026-02-01
|
||||
|
||||
-- Minimal book row factory; tests override fields they care about.
|
||||
local function book(over)
|
||||
local row = {
|
||||
hash = "h" .. tostring(math.random(2 ^ 31)),
|
||||
meta_hash = nil,
|
||||
title = "Untitled",
|
||||
source_title = nil,
|
||||
author = "Anon",
|
||||
format = "EPUB",
|
||||
metadata_json = nil,
|
||||
series = nil,
|
||||
series_index = nil,
|
||||
group_id = nil,
|
||||
group_name = nil,
|
||||
cover_path = nil,
|
||||
file_path = nil,
|
||||
cloud_present = 0,
|
||||
local_present = 0,
|
||||
uploaded_at = nil,
|
||||
progress_lib = nil,
|
||||
reading_status = nil,
|
||||
last_read_at = nil,
|
||||
created_at = T_OLD,
|
||||
updated_at = T_OLD,
|
||||
deleted_at = nil,
|
||||
}
|
||||
if over then for k, v in pairs(over) do row[k] = v end end
|
||||
return row
|
||||
end
|
||||
|
||||
describe("LibraryStore", function()
|
||||
local LibraryStore
|
||||
|
||||
before_each(function()
|
||||
helper.reset()
|
||||
package.loaded["library.librarystore"] = nil
|
||||
LibraryStore = require("library.librarystore")
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- Schema construction
|
||||
-- =====================================================================
|
||||
describe("schema", function()
|
||||
it("opens an in-memory DB and creates the books + sync_state tables", function()
|
||||
local store = LibraryStore.new({ user_id = "alice" })
|
||||
assert.is_not_nil(store)
|
||||
assert.are.equal(0, #store:listBooks({}))
|
||||
store:close()
|
||||
end)
|
||||
|
||||
it("survives being constructed twice (CREATE IF NOT EXISTS)", function()
|
||||
local s1 = LibraryStore.new({ user_id = "alice" })
|
||||
s1:upsertBook(book({ hash = "h1", title = "A", cloud_present = 1 }))
|
||||
s1:close()
|
||||
-- Re-open the same on-disk file: state survives.
|
||||
local DataStorage = require("datastorage")
|
||||
local path = DataStorage:getSettingsDir() .. "/test.sqlite3"
|
||||
local s2 = LibraryStore.new({ user_id = "alice", db_path = path })
|
||||
s2:upsertBook(book({ hash = "h2", title = "B", cloud_present = 1 }))
|
||||
s2:close()
|
||||
local s3 = LibraryStore.new({ user_id = "alice", db_path = path })
|
||||
assert.are.equal(1, #s3:listBooks({})) -- only h2; h1 was in-memory
|
||||
s3:close()
|
||||
end)
|
||||
|
||||
it("sets PRAGMA user_version = 1", function()
|
||||
local store = LibraryStore.new({ user_id = "alice" })
|
||||
assert.are.equal(1, store:getUserVersion())
|
||||
store:close()
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- upsertBook
|
||||
-- =====================================================================
|
||||
describe("upsertBook", function()
|
||||
local store
|
||||
before_each(function() store = LibraryStore.new({ user_id = "alice" }) end)
|
||||
after_each(function() store:close() end)
|
||||
|
||||
it("inserts a new row", function()
|
||||
store:upsertBook(book({ hash = "h1", title = "Foundation", cloud_present = 1 }))
|
||||
local rows = store:listBooks({})
|
||||
assert.are.equal(1, #rows)
|
||||
assert.are.equal("Foundation", rows[1].title)
|
||||
assert.are.equal("h1", rows[1].hash)
|
||||
end)
|
||||
|
||||
it("updates an existing row by hash", function()
|
||||
store:upsertBook(book({ hash = "h1", title = "Foundation", cloud_present = 1 }))
|
||||
store:upsertBook(book({ hash = "h1", title = "Foundation (Revised)", cloud_present = 1 }))
|
||||
local rows = store:listBooks({})
|
||||
assert.are.equal(1, #rows)
|
||||
assert.are.equal("Foundation (Revised)", rows[1].title)
|
||||
end)
|
||||
|
||||
it("OR-merges cloud_present and local_present flags", function()
|
||||
store:upsertBook(book({ hash = "h1", title = "X", cloud_present = 1, local_present = 0 }))
|
||||
-- Local scanner discovers the file and upserts with local_present=1.
|
||||
-- It doesn't know about cloud state, so it omits cloud_present (or sends 0).
|
||||
store:upsertBook(book({ hash = "h1", title = "X", local_present = 1 }))
|
||||
local rows = store:listBooks({})
|
||||
assert.are.equal(1, rows[1].cloud_present)
|
||||
assert.are.equal(1, rows[1].local_present)
|
||||
end)
|
||||
|
||||
it("explicit cloud_present=0 from a deleted_at sync update DOES set it to 0", function()
|
||||
store:upsertBook(book({ hash = "h1", title = "X", cloud_present = 1, local_present = 1 }))
|
||||
-- Tombstone update from sync: explicit cloud_present=0 + deleted_at set
|
||||
store:upsertBook(book({
|
||||
hash = "h1", title = "X",
|
||||
cloud_present = 0, local_present = 1,
|
||||
deleted_at = T_RECENT,
|
||||
_force_cloud_present = true, -- explicit override sentinel
|
||||
}))
|
||||
local row = store:_getRowRaw("h1")
|
||||
assert.are.equal(0, row.cloud_present)
|
||||
assert.are.equal(T_RECENT, row.deleted_at)
|
||||
end)
|
||||
|
||||
it("_clear_fields explicitly nulls listed columns even when existing has them set", function()
|
||||
-- Tombstoned existing row
|
||||
store:upsertBook(book({
|
||||
hash = "h1", title = "X",
|
||||
cloud_present = 0, local_present = 0,
|
||||
deleted_at = T_OLD,
|
||||
_force_cloud_present = true,
|
||||
}))
|
||||
assert.are.equal(T_OLD, store:_getRowRaw("h1").deleted_at)
|
||||
-- Re-add with _clear_fields → deleted_at goes to NULL
|
||||
store:upsertBook({
|
||||
hash = "h1",
|
||||
title = "X",
|
||||
local_present = 1,
|
||||
updated_at = T_RECENT,
|
||||
_clear_fields = { "deleted_at" },
|
||||
})
|
||||
local row = store:_getRowRaw("h1")
|
||||
assert.is_nil(row.deleted_at)
|
||||
assert.are.equal(1, row.local_present)
|
||||
assert.are.equal(T_RECENT, row.updated_at)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- touchBook: bumps updated_at + last_read_at to "now", merges other
|
||||
-- fields. Used after the user closes a book in the reader so the
|
||||
-- next /sync push carries the up-to-date timestamps + progress.
|
||||
-- =====================================================================
|
||||
describe("touchBook", function()
|
||||
local store
|
||||
before_each(function() store = LibraryStore.new({ user_id = "alice" }) end)
|
||||
after_each(function() store:close() end)
|
||||
|
||||
it("returns nil for an unknown hash (no insert)", function()
|
||||
local out = store:touchBook("nope")
|
||||
assert.is_nil(out)
|
||||
assert.are.equal(0, #store:listBooks({}))
|
||||
end)
|
||||
|
||||
it("updates updated_at + last_read_at to current time", function()
|
||||
store:upsertBook(book({
|
||||
hash = "h1", title = "X",
|
||||
cloud_present = 1,
|
||||
updated_at = T_OLD, last_read_at = T_OLD,
|
||||
}))
|
||||
local before = os.time() * 1000
|
||||
local touched = store:touchBook("h1")
|
||||
local after = os.time() * 1000
|
||||
|
||||
assert.is_not_nil(touched)
|
||||
assert.is_true(touched.updated_at >= before, "updated_at not bumped: " .. tostring(touched.updated_at))
|
||||
assert.is_true(touched.updated_at <= after, "updated_at exceeds now")
|
||||
assert.is_true(touched.last_read_at >= before)
|
||||
end)
|
||||
|
||||
it("merges extra fields (e.g. progress_lib) into the row", function()
|
||||
store:upsertBook(book({
|
||||
hash = "h1", title = "X", cloud_present = 1,
|
||||
}))
|
||||
local touched = store:touchBook("h1", { progress_lib = "[42,250]" })
|
||||
assert.are.equal("[42,250]", touched.progress_lib)
|
||||
end)
|
||||
|
||||
it("preserves cloud_present + local_present (no flag downgrade)", function()
|
||||
store:upsertBook(book({
|
||||
hash = "h1", title = "X",
|
||||
cloud_present = 1, local_present = 1,
|
||||
}))
|
||||
local touched = store:touchBook("h1")
|
||||
assert.are.equal(1, touched.cloud_present)
|
||||
assert.are.equal(1, touched.local_present)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- getChangedBooks: returns rows with updated_at > since OR deleted_at
|
||||
-- > since. Mirrors useBooksSync.getNewBooks at
|
||||
-- apps/readest-app/src/app/library/hooks/useBooksSync.ts:22-35.
|
||||
-- =====================================================================
|
||||
describe("getChangedBooks", function()
|
||||
local store
|
||||
before_each(function()
|
||||
store = LibraryStore.new({ user_id = "alice" })
|
||||
store:upsertBook(book({ hash = "old", title = "Old", cloud_present = 1, updated_at = T_OLD }))
|
||||
store:upsertBook(book({ hash = "mid", title = "Mid", cloud_present = 1, updated_at = T_MID }))
|
||||
store:upsertBook(book({ hash = "new", title = "New", cloud_present = 1, updated_at = T_RECENT }))
|
||||
end)
|
||||
after_each(function() store:close() end)
|
||||
|
||||
it("returns ALL books when since=0 (initial sync)", function()
|
||||
local out = store:getChangedBooks(0)
|
||||
assert.are.equal(3, #out)
|
||||
end)
|
||||
|
||||
it("returns only books changed since the watermark", function()
|
||||
local out = store:getChangedBooks(T_MID)
|
||||
assert.are.equal(1, #out)
|
||||
assert.are.equal("New", out[1].title)
|
||||
end)
|
||||
|
||||
it("returns empty when nothing has changed since the watermark", function()
|
||||
local out = store:getChangedBooks(T_RECENT + 1)
|
||||
assert.are.equal(0, #out)
|
||||
end)
|
||||
|
||||
it("includes rows where deleted_at > since (tombstone push)", function()
|
||||
-- Mark "old" as deleted at T_RECENT — its updated_at is T_OLD
|
||||
-- (< since=T_MID), but deleted_at (T_RECENT > T_MID) means
|
||||
-- this push needs to carry the tombstone to the server.
|
||||
store:upsertBook(book({
|
||||
hash = "old", title = "Old",
|
||||
cloud_present = 0, _force_cloud_present = true,
|
||||
deleted_at = T_RECENT,
|
||||
}))
|
||||
local out = store:getChangedBooks(T_MID)
|
||||
local hashes = {}
|
||||
for _, r in ipairs(out) do hashes[r.hash] = true end
|
||||
assert.is_true(hashes["old"], "deleted row missing from changed set")
|
||||
assert.is_true(hashes["new"], "recent row missing")
|
||||
end)
|
||||
|
||||
it("scopes by user_id", function()
|
||||
local DataStorage = require("datastorage")
|
||||
local path = DataStorage:getSettingsDir() .. "/changed_multi.sqlite3"
|
||||
local alice = LibraryStore.new({ user_id = "alice", db_path = path })
|
||||
alice:upsertBook(book({ hash = "a1", title = "A", cloud_present = 1, updated_at = T_RECENT }))
|
||||
local bob = LibraryStore.new({ user_id = "bob", db_path = path })
|
||||
bob:upsertBook(book({ hash = "b1", title = "B", cloud_present = 1, updated_at = T_RECENT }))
|
||||
assert.are.equal(1, #alice:getChangedBooks(0))
|
||||
assert.are.equal(1, #bob:getChangedBooks(0))
|
||||
alice:close(); bob:close()
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- listBooks: filters, sort, hides ghosts and tombstones
|
||||
-- =====================================================================
|
||||
describe("listBooks", function()
|
||||
local store
|
||||
before_each(function()
|
||||
store = LibraryStore.new({ user_id = "alice" })
|
||||
store:upsertBook(book({
|
||||
hash = "h1", title = "Foundation", author = "Asimov",
|
||||
cloud_present = 1, updated_at = T_OLD, last_read_at = T_OLD,
|
||||
}))
|
||||
store:upsertBook(book({
|
||||
hash = "h2", title = "Dune", author = "Herbert",
|
||||
cloud_present = 1, local_present = 1,
|
||||
updated_at = T_RECENT, last_read_at = T_RECENT,
|
||||
}))
|
||||
store:upsertBook(book({
|
||||
hash = "h3", title = "Hyperion", author = "Simmons",
|
||||
local_present = 1, updated_at = T_MID, last_read_at = T_MID,
|
||||
}))
|
||||
end)
|
||||
after_each(function() store:close() end)
|
||||
|
||||
it("returns all visible books by default sorted by updated_at desc", function()
|
||||
local rows = store:listBooks({})
|
||||
assert.are.equal(3, #rows)
|
||||
assert.are.equal("Dune", rows[1].title) -- T_RECENT
|
||||
assert.are.equal("Hyperion", rows[2].title) -- T_MID
|
||||
assert.are.equal("Foundation", rows[3].title) -- T_OLD
|
||||
end)
|
||||
|
||||
it("hides rows with deleted_at != null even when local_present=1", function()
|
||||
-- Cloud delete arrives; local file still on disk
|
||||
store:upsertBook(book({
|
||||
hash = "h2", title = "Dune", author = "Herbert",
|
||||
cloud_present = 0, local_present = 1,
|
||||
deleted_at = T_RECENT,
|
||||
_force_cloud_present = true,
|
||||
}))
|
||||
local rows = store:listBooks({})
|
||||
assert.are.equal(2, #rows)
|
||||
for _, r in ipairs(rows) do assert.is_not.equal("Dune", r.title) end
|
||||
end)
|
||||
|
||||
it("hides rows where neither flag is set (ghost rows)", function()
|
||||
-- Manually craft a ghost via direct upsert without flags
|
||||
store:upsertBook(book({ hash = "h-ghost", title = "Ghost" }))
|
||||
local rows = store:listBooks({})
|
||||
assert.are.equal(3, #rows)
|
||||
for _, r in ipairs(rows) do assert.is_not.equal("Ghost", r.title) end
|
||||
end)
|
||||
|
||||
it("filters by case-insensitive substring search across title and author", function()
|
||||
local r1 = store:listBooks({ search = "asimov" })
|
||||
assert.are.equal(1, #r1)
|
||||
assert.are.equal("Foundation", r1[1].title)
|
||||
|
||||
local r2 = store:listBooks({ search = "DUNE" })
|
||||
assert.are.equal(1, #r2)
|
||||
assert.are.equal("Dune", r2[1].title)
|
||||
|
||||
local r3 = store:listBooks({ search = "z-no-match" })
|
||||
assert.are.equal(0, #r3)
|
||||
end)
|
||||
|
||||
it("sorts by last_read_at desc", function()
|
||||
local rows = store:listBooks({ sort_by = "last_read_at", sort_asc = false })
|
||||
assert.are.equal("Dune", rows[1].title)
|
||||
end)
|
||||
|
||||
it("'last_read_at' sort uses COALESCE(updated_at, last_read_at) — bumped updated_at floats the row", function()
|
||||
-- Foundation has the OLDEST last_read_at + updated_at in
|
||||
-- the fixture, so it sorts last under the default. Now
|
||||
-- simulate "Add to Readest" dedupe bumping ONLY its
|
||||
-- updated_at: the row should jump to the top because
|
||||
-- COALESCE(T_FUTURE, …) = T_FUTURE > all others.
|
||||
local T_FUTURE = T_RECENT + 60000 -- one minute after the freshest row
|
||||
store:upsertBook({
|
||||
hash = "h1", title = "Foundation",
|
||||
updated_at = T_FUTURE,
|
||||
})
|
||||
local rows = store:listBooks({ sort_by = "last_read_at", sort_asc = false })
|
||||
assert.are.equal("Foundation", rows[1].title)
|
||||
end)
|
||||
|
||||
it("'last_read_at' sort still falls back to updated_at when last_read_at is NULL", function()
|
||||
-- Cloud-only book: last_read_at NULL, updated_at = the freshest.
|
||||
store:upsertBook(book({
|
||||
hash = "h-cloud", title = "Anathem", author = "Stephenson",
|
||||
cloud_present = 1, last_read_at = nil,
|
||||
updated_at = T_RECENT + 1000,
|
||||
}))
|
||||
local rows = store:listBooks({ sort_by = "last_read_at", sort_asc = false })
|
||||
assert.are.equal("Anathem", rows[1].title)
|
||||
end)
|
||||
|
||||
it("sorts by title ascending", function()
|
||||
local rows = store:listBooks({ sort_by = "title", sort_asc = true })
|
||||
assert.are.equal("Dune", rows[1].title)
|
||||
assert.are.equal("Foundation", rows[2].title)
|
||||
assert.are.equal("Hyperion", rows[3].title)
|
||||
end)
|
||||
|
||||
it("sorts by author descending", function()
|
||||
local rows = store:listBooks({ sort_by = "author", sort_asc = false })
|
||||
assert.are.equal("Simmons", rows[1].author)
|
||||
assert.are.equal("Herbert", rows[2].author)
|
||||
assert.are.equal("Asimov", rows[3].author)
|
||||
end)
|
||||
|
||||
it("filters by group when group_by + group_filter are set", function()
|
||||
local rows = store:listBooks({ group_by = "author", group_filter = "Asimov" })
|
||||
assert.are.equal(1, #rows)
|
||||
assert.are.equal("Foundation", rows[1].title)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- getGroups
|
||||
-- =====================================================================
|
||||
describe("getGroups", function()
|
||||
local store
|
||||
before_each(function()
|
||||
store = LibraryStore.new({ user_id = "alice" })
|
||||
store:upsertBook(book({ hash = "a1", title = "F1", author = "Asimov", cloud_present = 1, updated_at = T_OLD }))
|
||||
store:upsertBook(book({ hash = "a2", title = "F2", author = "Asimov", cloud_present = 1, updated_at = T_MID }))
|
||||
store:upsertBook(book({ hash = "h1", title = "D", author = "Herbert", cloud_present = 1, updated_at = T_RECENT }))
|
||||
store:upsertBook(book({ hash = "g1", title = "G1", author = "Anon", cloud_present = 1, group_name = "Sci-Fi", updated_at = T_OLD }))
|
||||
store:upsertBook(book({ hash = "g2", title = "G2", author = "Anon", cloud_present = 1, group_name = "Sci-Fi", updated_at = T_MID }))
|
||||
end)
|
||||
after_each(function() store:close() end)
|
||||
|
||||
it("group by author returns {name, count, latest_updated_at}", function()
|
||||
local groups = store:getGroups("author")
|
||||
local by_name = {}
|
||||
for _, g in ipairs(groups) do by_name[g.name] = g end
|
||||
assert.are.equal(2, by_name["Asimov"].count)
|
||||
assert.are.equal(T_MID, by_name["Asimov"].latest_updated_at)
|
||||
assert.are.equal(1, by_name["Herbert"].count)
|
||||
assert.are.equal(2, by_name["Anon"].count)
|
||||
end)
|
||||
|
||||
it("group by group_name skips rows with null group_name", function()
|
||||
local groups = store:getGroups("group_name")
|
||||
assert.are.equal(1, #groups)
|
||||
assert.are.equal("Sci-Fi", groups[1].name)
|
||||
assert.are.equal(2, groups[1].count)
|
||||
end)
|
||||
|
||||
it("memoizes results across calls", function()
|
||||
local g1 = store:getGroups("author")
|
||||
local g2 = store:getGroups("author")
|
||||
-- Same Lua table reference (cache hit, not just equal contents)
|
||||
assert.are.equal(g1, g2)
|
||||
end)
|
||||
|
||||
it("invalidates cache after upsertBook", function()
|
||||
local g1 = store:getGroups("author")
|
||||
store:upsertBook(book({ hash = "h2", title = "D2", author = "Herbert", cloud_present = 1 }))
|
||||
local g2 = store:getGroups("author")
|
||||
assert.is_not.equal(g1, g2)
|
||||
local by_name = {}
|
||||
for _, g in ipairs(g2) do by_name[g.name] = g end
|
||||
assert.are.equal(2, by_name["Herbert"].count)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- listBookshelfGroups: nested folders for group_name, flat for others
|
||||
-- =====================================================================
|
||||
describe("listBookshelfGroups", function()
|
||||
local store
|
||||
before_each(function()
|
||||
store = LibraryStore.new({ user_id = "alice" })
|
||||
-- Top-level folders Fantasy + SciFi, plus a nested Fantasy/Tolkien
|
||||
-- and Fantasy/Tolkien/LOTR. One book at root (no group_name).
|
||||
store:upsertBook(book({ hash = "f1", title = "Conan", group_name = "Fantasy", cloud_present = 1, updated_at = T_OLD }))
|
||||
store:upsertBook(book({ hash = "t1", title = "Hobbit", group_name = "Fantasy/Tolkien", cloud_present = 1, updated_at = T_MID }))
|
||||
store:upsertBook(book({ hash = "t2", title = "Silmarillion", group_name = "Fantasy/Tolkien", cloud_present = 1, updated_at = T_RECENT }))
|
||||
store:upsertBook(book({ hash = "l1", title = "FotR", group_name = "Fantasy/Tolkien/LOTR", cloud_present = 1, updated_at = T_RECENT }))
|
||||
store:upsertBook(book({ hash = "s1", title = "Dune", group_name = "SciFi", cloud_present = 1, updated_at = T_OLD }))
|
||||
store:upsertBook(book({ hash = "u1", title = "Loose", cloud_present = 1, updated_at = T_OLD }))
|
||||
end)
|
||||
after_each(function() store:close() end)
|
||||
|
||||
it("at root: returns top-level segments with aggregate counts and per-sort latest_*", function()
|
||||
local groups = store:listBookshelfGroups("group_name", nil)
|
||||
assert.are.equal(2, #groups) -- Fantasy + SciFi (Loose has no group)
|
||||
local by_name = {}
|
||||
for _, g in ipairs(groups) do by_name[g.display_name] = g end
|
||||
-- Fantasy aggregates Fantasy + Fantasy/Tolkien*2 + Fantasy/Tolkien/LOTR
|
||||
assert.are.equal(4, by_name["Fantasy"].count)
|
||||
assert.are.equal("Fantasy", by_name["Fantasy"].name)
|
||||
assert.are.equal(1, by_name["SciFi"].count)
|
||||
-- Per-sort aggregates: Fantasy's max updated = T_RECENT (from
|
||||
-- the Tolkien/Silmarillion + LOTR rows). SciFi only has Dune
|
||||
-- with T_OLD. Created defaults to T_OLD via the test factory.
|
||||
assert.are.equal(T_RECENT, by_name["Fantasy"].latest_updated_at)
|
||||
assert.are.equal(T_RECENT, by_name["Fantasy"].latest_last_read_at)
|
||||
assert.are.equal(T_OLD, by_name["Fantasy"].latest_created_at)
|
||||
assert.are.equal(T_OLD, by_name["SciFi"].latest_updated_at)
|
||||
end)
|
||||
|
||||
it("drilled in: returns immediate children (not descendants)", function()
|
||||
local groups = store:listBookshelfGroups("group_name", "Fantasy")
|
||||
assert.are.equal(1, #groups) -- only "Tolkien" is a folder; Fantasy itself is a direct-child book
|
||||
assert.are.equal("Tolkien", groups[1].display_name)
|
||||
assert.are.equal("Fantasy/Tolkien", groups[1].name)
|
||||
-- Tolkien aggregate = Tolkien*2 + Tolkien/LOTR
|
||||
assert.are.equal(3, groups[1].count)
|
||||
end)
|
||||
|
||||
it("two levels deep: returns LOTR sub-folder only", function()
|
||||
local groups = store:listBookshelfGroups("group_name", "Fantasy/Tolkien")
|
||||
assert.are.equal(1, #groups)
|
||||
assert.are.equal("LOTR", groups[1].display_name)
|
||||
assert.are.equal("Fantasy/Tolkien/LOTR", groups[1].name)
|
||||
assert.are.equal(1, groups[1].count)
|
||||
end)
|
||||
|
||||
it("at leaf folder: returns no further sub-folders", function()
|
||||
local groups = store:listBookshelfGroups("group_name", "Fantasy/Tolkien/LOTR")
|
||||
assert.are.equal(0, #groups)
|
||||
end)
|
||||
|
||||
-- BookshelfItem.tsx parity: a single-segment path like "Fantasy"
|
||||
-- is itself a folder; the book living at that path is a direct
|
||||
-- child of that folder, not a root-level book.
|
||||
it("creates a folder for a single-segment group_name", function()
|
||||
local s = LibraryStore.new({ user_id = "single" })
|
||||
s:upsertBook(book({ hash = "x1", title = "Conan", group_name = "Fantasy", cloud_present = 1 }))
|
||||
local root_groups = s:listBookshelfGroups("group_name", nil)
|
||||
assert.are.equal(1, #root_groups)
|
||||
assert.are.equal("Fantasy", root_groups[1].name)
|
||||
assert.are.equal(1, root_groups[1].count)
|
||||
-- And the book itself isn't visible at root, only inside Fantasy
|
||||
assert.are.equal(0, #s:listBookshelfBooks({}, "group_name", nil))
|
||||
assert.are.equal(1, #s:listBookshelfBooks({}, "group_name", "Fantasy"))
|
||||
s:close()
|
||||
end)
|
||||
|
||||
-- BookshelfItem.tsx:43-44 — slashIndex > 0, so a leading slash
|
||||
-- doesn't split. "/foo" stays as one segment named "/foo".
|
||||
it("leading-slash group_name keeps the slash as part of the segment", function()
|
||||
local s = LibraryStore.new({ user_id = "ls" })
|
||||
s:upsertBook(book({ hash = "x1", title = "X", group_name = "/foo", cloud_present = 1 }))
|
||||
local groups = s:listBookshelfGroups("group_name", nil)
|
||||
assert.are.equal(1, #groups)
|
||||
assert.are.equal("/foo", groups[1].name)
|
||||
assert.are.equal("/foo", groups[1].display_name)
|
||||
s:close()
|
||||
end)
|
||||
|
||||
it("author/series: parent_path ignored at root, returns flat groups", function()
|
||||
store:upsertBook(book({ hash = "a1", title = "F", author = "Asimov", cloud_present = 1 }))
|
||||
store:upsertBook(book({ hash = "h1", title = "D", author = "Herbert", cloud_present = 1 }))
|
||||
local groups = store:listBookshelfGroups("author", nil)
|
||||
local names = {}
|
||||
for _, g in ipairs(groups) do names[g.display_name] = true end
|
||||
assert.is_true(names["Asimov"])
|
||||
assert.is_true(names["Herbert"])
|
||||
end)
|
||||
|
||||
it("author/series: returns empty when called with non-nil parent_path", function()
|
||||
store:upsertBook(book({ hash = "a1", title = "F", author = "Asimov", cloud_present = 1 }))
|
||||
local groups = store:listBookshelfGroups("author", "Asimov")
|
||||
assert.are.equal(0, #groups)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- listBookshelfBooks: ungrouped at root, exact match when drilled in
|
||||
-- =====================================================================
|
||||
describe("listBookshelfBooks", function()
|
||||
local store
|
||||
before_each(function()
|
||||
store = LibraryStore.new({ user_id = "alice" })
|
||||
store:upsertBook(book({ hash = "f1", title = "Conan", group_name = "Fantasy", cloud_present = 1 }))
|
||||
store:upsertBook(book({ hash = "t1", title = "Hobbit", group_name = "Fantasy/Tolkien", cloud_present = 1 }))
|
||||
store:upsertBook(book({ hash = "u1", title = "Loose", author = "", cloud_present = 1 }))
|
||||
store:upsertBook(book({ hash = "a1", title = "F", author = "Asimov", cloud_present = 1 }))
|
||||
store:upsertBook(book({ hash = "n1", title = "NoAuth", author = "", cloud_present = 1 }))
|
||||
end)
|
||||
after_each(function() store:close() end)
|
||||
|
||||
it("group_name root: returns books with null/empty group_name", function()
|
||||
local books = store:listBookshelfBooks({}, "group_name", nil)
|
||||
local titles = {}
|
||||
for _, b in ipairs(books) do titles[b.title] = true end
|
||||
-- Loose, F, NoAuth all lack group_name; Conan/Hobbit don't appear.
|
||||
assert.is_true(titles["Loose"])
|
||||
assert.is_true(titles["F"])
|
||||
assert.is_true(titles["NoAuth"])
|
||||
assert.is_nil(titles["Conan"])
|
||||
assert.is_nil(titles["Hobbit"])
|
||||
end)
|
||||
|
||||
it("group_name drill-in: returns only direct-child books, not descendants", function()
|
||||
local books = store:listBookshelfBooks({}, "group_name", "Fantasy")
|
||||
assert.are.equal(1, #books)
|
||||
assert.are.equal("Conan", books[1].title)
|
||||
-- Hobbit is in Fantasy/Tolkien — listed by drilling further
|
||||
end)
|
||||
|
||||
it("author root: returns books with no author", function()
|
||||
local books = store:listBookshelfBooks({}, "author", nil)
|
||||
local titles = {}
|
||||
for _, b in ipairs(books) do titles[b.title] = true end
|
||||
-- Only Loose and NoAuth have empty author; F has Asimov,
|
||||
-- Conan/Hobbit default to "Anon" via the factory.
|
||||
assert.is_nil(titles["F"])
|
||||
assert.is_true(titles["Loose"])
|
||||
assert.is_true(titles["NoAuth"])
|
||||
end)
|
||||
|
||||
it("author drill-in: returns books matching the author", function()
|
||||
local books = store:listBookshelfBooks({}, "author", "Asimov")
|
||||
assert.are.equal(1, #books)
|
||||
assert.are.equal("F", books[1].title)
|
||||
end)
|
||||
|
||||
it("group_by=nil: delegates to listBooks (no ungrouped filter)", function()
|
||||
local books = store:listBookshelfBooks({}, nil, nil)
|
||||
assert.are.equal(5, #books)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- listBooksInGroup: feeds the group-cover composer
|
||||
-- =====================================================================
|
||||
describe("listBooksInGroup", function()
|
||||
local store
|
||||
before_each(function()
|
||||
store = LibraryStore.new({ user_id = "alice" })
|
||||
-- Same fixture as listBookshelfGroups: nested Fantasy tree.
|
||||
store:upsertBook(book({ hash = "f1", title = "Conan", group_name = "Fantasy", cloud_present = 1, last_read_at = T_OLD, updated_at = T_OLD }))
|
||||
store:upsertBook(book({ hash = "t1", title = "Hobbit", group_name = "Fantasy/Tolkien", cloud_present = 1, last_read_at = T_RECENT, updated_at = T_RECENT }))
|
||||
store:upsertBook(book({ hash = "t2", title = "Silmarillion", group_name = "Fantasy/Tolkien", cloud_present = 1, last_read_at = T_MID, updated_at = T_MID }))
|
||||
store:upsertBook(book({ hash = "l1", title = "FotR", group_name = "Fantasy/Tolkien/LOTR", cloud_present = 1, last_read_at = T_RECENT, updated_at = T_RECENT }))
|
||||
store:upsertBook(book({ hash = "s1", title = "Dune", group_name = "SciFi", cloud_present = 1 }))
|
||||
store:upsertBook(book({ hash = "a1", title = "Foundation", author = "Asimov", cloud_present = 1 }))
|
||||
store:upsertBook(book({ hash = "a2", title = "Robots", author = "Asimov", cloud_present = 1 }))
|
||||
store:upsertBook(book({ hash = "a3", title = "Empire", author = "Asimov", cloud_present = 1 }))
|
||||
end)
|
||||
after_each(function() store:close() end)
|
||||
|
||||
it("group_name root: includes books across the whole subtree", function()
|
||||
local books = store:listBooksInGroup("group_name", "Fantasy", 4)
|
||||
local titles = {}
|
||||
for _, b in ipairs(books) do titles[b.title] = true end
|
||||
-- All four Fantasy-subtree books visible (Conan + Hobbit + Silmarillion + FotR)
|
||||
assert.is_true(titles["Conan"])
|
||||
assert.is_true(titles["Hobbit"])
|
||||
assert.is_true(titles["Silmarillion"])
|
||||
assert.is_true(titles["FotR"])
|
||||
end)
|
||||
|
||||
it("group_name: respects limit", function()
|
||||
local books = store:listBooksInGroup("group_name", "Fantasy", 2)
|
||||
assert.are.equal(2, #books)
|
||||
-- Sorted by COALESCE(updated_at, last_read_at) DESC,
|
||||
-- so the two T_RECENT books come first (Hobbit + FotR).
|
||||
local titles = {}
|
||||
for _, b in ipairs(books) do titles[b.title] = true end
|
||||
assert.is_true(titles["Hobbit"] or titles["FotR"])
|
||||
end)
|
||||
|
||||
it("author: matches just that author", function()
|
||||
local books = store:listBooksInGroup("author", "Asimov", 4)
|
||||
assert.are.equal(3, #books)
|
||||
end)
|
||||
|
||||
it("group_name nested path: only its own subtree", function()
|
||||
local books = store:listBooksInGroup("group_name", "Fantasy/Tolkien", 4)
|
||||
local titles = {}
|
||||
for _, b in ipairs(books) do titles[b.title] = true end
|
||||
-- Hobbit + Silmarillion (direct) + FotR (descendant) = 3
|
||||
assert.are.equal(3, #books)
|
||||
assert.is_true(titles["Hobbit"])
|
||||
assert.is_true(titles["Silmarillion"])
|
||||
assert.is_true(titles["FotR"])
|
||||
assert.is_nil(titles["Conan"]) -- direct child of Fantasy, not Tolkien
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- Multi-account scoping
|
||||
-- =====================================================================
|
||||
describe("multi-account", function()
|
||||
it("scopes books by user_id; alice's books invisible to bob", function()
|
||||
local DataStorage = require("datastorage")
|
||||
local path = DataStorage:getSettingsDir() .. "/multi.sqlite3"
|
||||
|
||||
local alice = LibraryStore.new({ user_id = "alice", db_path = path })
|
||||
alice:upsertBook(book({ hash = "h1", title = "Alice's Book", cloud_present = 1 }))
|
||||
assert.are.equal(1, #alice:listBooks({}))
|
||||
alice:close()
|
||||
|
||||
local bob = LibraryStore.new({ user_id = "bob", db_path = path })
|
||||
assert.are.equal(0, #bob:listBooks({}))
|
||||
bob:upsertBook(book({ hash = "h1", title = "Bob's Book", cloud_present = 1 }))
|
||||
assert.are.equal(1, #bob:listBooks({}))
|
||||
assert.are.equal("Bob's Book", bob:listBooks({})[1].title)
|
||||
bob:close()
|
||||
|
||||
-- Re-open as alice; her copy of h1 still says "Alice's Book"
|
||||
local alice2 = LibraryStore.new({ user_id = "alice", db_path = path })
|
||||
assert.are.equal("Alice's Book", alice2:listBooks({})[1].title)
|
||||
alice2:close()
|
||||
end)
|
||||
|
||||
it("scopes sync_state by user_id", function()
|
||||
local DataStorage = require("datastorage")
|
||||
local path = DataStorage:getSettingsDir() .. "/state.sqlite3"
|
||||
|
||||
local alice = LibraryStore.new({ user_id = "alice", db_path = path })
|
||||
alice:setLastPulledAt(T_RECENT)
|
||||
alice:close()
|
||||
|
||||
local bob = LibraryStore.new({ user_id = "bob", db_path = path })
|
||||
assert.is_nil(bob:getLastPulledAt())
|
||||
bob:setLastPulledAt(T_OLD)
|
||||
assert.are.equal(T_OLD, bob:getLastPulledAt())
|
||||
bob:close()
|
||||
|
||||
local alice2 = LibraryStore.new({ user_id = "alice", db_path = path })
|
||||
assert.are.equal(T_RECENT, alice2:getLastPulledAt())
|
||||
alice2:close()
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- Sync state
|
||||
-- =====================================================================
|
||||
describe("sync state", function()
|
||||
local store
|
||||
before_each(function() store = LibraryStore.new({ user_id = "alice" }) end)
|
||||
after_each(function() store:close() end)
|
||||
|
||||
it("getLastPulledAt returns nil before any setLastPulledAt", function()
|
||||
assert.is_nil(store:getLastPulledAt())
|
||||
end)
|
||||
|
||||
it("setLastPulledAt round-trips", function()
|
||||
store:setLastPulledAt(T_RECENT)
|
||||
assert.are.equal(T_RECENT, store:getLastPulledAt())
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- parseSyncRow: pure helper, no DB access required
|
||||
-- =====================================================================
|
||||
describe("parseSyncRow", function()
|
||||
local DUMMY_HASH = "00000000000000000000000000000000"
|
||||
|
||||
it("returns nil for the dummy initial-sync hash", function()
|
||||
local out = LibraryStore.parseSyncRow({
|
||||
book_hash = DUMMY_HASH,
|
||||
title = "Dummy", format = "EPUB", author = "",
|
||||
deleted_at = "2026-01-01T00:00:00Z",
|
||||
})
|
||||
assert.is_nil(out)
|
||||
end)
|
||||
|
||||
it("maps snake_case fields to our internal row shape", function()
|
||||
local out = LibraryStore.parseSyncRow({
|
||||
book_hash = "h1",
|
||||
meta_hash = "m1",
|
||||
title = "Foundation",
|
||||
source_title = "Foundation (Original)",
|
||||
author = "Asimov",
|
||||
format = "EPUB",
|
||||
group_id = "gid1",
|
||||
group_name = "Sci-Fi/Classics",
|
||||
uploaded_at = "2026-01-01T00:00:00Z",
|
||||
updated_at = "2026-02-01T00:00:00Z",
|
||||
created_at = "2025-12-01T00:00:00Z",
|
||||
deleted_at = nil,
|
||||
})
|
||||
assert.are.equal("h1", out.hash)
|
||||
assert.are.equal("m1", out.meta_hash)
|
||||
assert.are.equal("Foundation", out.title)
|
||||
assert.are.equal("Foundation (Original)", out.source_title)
|
||||
assert.are.equal("Asimov", out.author)
|
||||
assert.are.equal("EPUB", out.format)
|
||||
assert.are.equal("gid1", out.group_id)
|
||||
assert.are.equal("Sci-Fi/Classics", out.group_name)
|
||||
assert.are.equal(1, out.cloud_present)
|
||||
assert.is_nil(out.deleted_at)
|
||||
end)
|
||||
|
||||
it("converts ISO timestamps to unix ms", function()
|
||||
local out = LibraryStore.parseSyncRow({
|
||||
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
||||
updated_at = "2026-02-01T00:00:00Z",
|
||||
uploaded_at = "2026-01-01T00:00:00Z",
|
||||
deleted_at = nil,
|
||||
})
|
||||
-- 2026-02-01T00:00:00Z = 1769904000 unix seconds
|
||||
-- 2026-01-01T00:00:00Z = 1767225600 unix seconds
|
||||
assert.are.equal(1769904000000, out.updated_at)
|
||||
assert.are.equal(1767225600000, out.uploaded_at)
|
||||
end)
|
||||
|
||||
it("accepts Supabase / Postgres timestamps with +HH:MM offset", function()
|
||||
-- This is the format Supabase actually emits in /sync responses;
|
||||
-- discovered when 1221 cloud books all came back with
|
||||
-- updated_at=NULL because the parser only matched Z-suffix.
|
||||
local out = LibraryStore.parseSyncRow({
|
||||
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
||||
updated_at = "2026-02-01T00:00:00+00:00",
|
||||
uploaded_at = "2024-07-13T16:00:00.123456+00:00",
|
||||
})
|
||||
assert.are.equal(1769904000000, out.updated_at)
|
||||
-- 2024-07-13T16:00:00.123 UTC = 1720886400.123 sec → 1720886400123 ms
|
||||
assert.are.equal(1720886400123, out.uploaded_at)
|
||||
end)
|
||||
|
||||
it("accepts non-UTC offsets and shifts back to UTC", function()
|
||||
-- "+05:30" means the wall-clock is 5h30m ahead of UTC; subtract
|
||||
-- to get unix epoch (which is always UTC).
|
||||
local out = LibraryStore.parseSyncRow({
|
||||
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
||||
updated_at = "2026-02-01T05:30:00+05:30",
|
||||
})
|
||||
assert.are.equal(1769904000000, out.updated_at)
|
||||
end)
|
||||
|
||||
it("accepts Postgres native (space separator, no T)", function()
|
||||
local out = LibraryStore.parseSyncRow({
|
||||
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
||||
updated_at = "2026-02-01 00:00:00+00",
|
||||
})
|
||||
assert.are.equal(1769904000000, out.updated_at)
|
||||
end)
|
||||
|
||||
it("JSON-parses metadata string and extracts series/series_index", function()
|
||||
local out = LibraryStore.parseSyncRow({
|
||||
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
||||
updated_at = "2026-02-01T00:00:00Z",
|
||||
metadata = '{"series":"Foundation","seriesIndex":1.0,"description":"hello"}',
|
||||
})
|
||||
assert.are.equal("Foundation", out.series)
|
||||
assert.are.equal(1.0, out.series_index)
|
||||
assert.is_string(out.metadata_json)
|
||||
-- Round-trip the JSON and check the description survives
|
||||
local json = require("json")
|
||||
assert.are.equal("hello", json.decode(out.metadata_json).description)
|
||||
end)
|
||||
|
||||
it("accepts metadata as an already-parsed table (not just a string)", function()
|
||||
local out = LibraryStore.parseSyncRow({
|
||||
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
||||
updated_at = "2026-02-01T00:00:00Z",
|
||||
metadata = { series = "Dune", seriesIndex = 2 },
|
||||
})
|
||||
assert.are.equal("Dune", out.series)
|
||||
assert.are.equal(2, out.series_index)
|
||||
end)
|
||||
|
||||
it("handles missing metadata gracefully", function()
|
||||
local out = LibraryStore.parseSyncRow({
|
||||
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
||||
updated_at = "2026-02-01T00:00:00Z",
|
||||
})
|
||||
assert.is_nil(out.series)
|
||||
assert.is_nil(out.series_index)
|
||||
assert.is_nil(out.metadata_json)
|
||||
end)
|
||||
|
||||
it("non-null deleted_at sets cloud_present=0 and preserves deleted_at", function()
|
||||
local out = LibraryStore.parseSyncRow({
|
||||
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
||||
updated_at = "2026-02-01T00:00:00Z",
|
||||
deleted_at = "2026-02-15T00:00:00Z",
|
||||
})
|
||||
assert.are.equal(0, out.cloud_present)
|
||||
assert.are.equal(1771113600000, out.deleted_at)
|
||||
assert.is_true(out._force_cloud_present)
|
||||
end)
|
||||
|
||||
it("preserves null group_name without crashing", function()
|
||||
local out = LibraryStore.parseSyncRow({
|
||||
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
||||
updated_at = "2026-02-01T00:00:00Z",
|
||||
group_name = nil,
|
||||
})
|
||||
assert.is_nil(out.group_name)
|
||||
end)
|
||||
|
||||
it("stringifies progress tuple to progress_lib JSON", function()
|
||||
local out = LibraryStore.parseSyncRow({
|
||||
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
||||
updated_at = "2026-02-01T00:00:00Z",
|
||||
progress = { 42, 250 },
|
||||
})
|
||||
local json = require("json")
|
||||
local p = json.decode(out.progress_lib)
|
||||
assert.are.equal(42, p[1])
|
||||
assert.are.equal(250, p[2])
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
@@ -0,0 +1,149 @@
|
||||
-- localscanner_spec.lua
|
||||
-- Unit-tests the pure helpers in library/localscanner.lua.
|
||||
--
|
||||
-- Out of scope: lightScan() and fullSidecarWalk() require live KOReader
|
||||
-- modules (ReadHistory, DocSettings, FFIUtil.runInSubProcess); they're
|
||||
-- exercised manually via the test matrix in docs/library-design.md.
|
||||
|
||||
require("spec_helper")
|
||||
local lfs = require("lfs")
|
||||
|
||||
describe("library.localscanner", function()
|
||||
local localscanner, datastorage
|
||||
|
||||
before_each(function()
|
||||
require("spec_helper").reset()
|
||||
package.loaded["library.localscanner"] = nil
|
||||
localscanner = require("library.localscanner")
|
||||
datastorage = require("datastorage")
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- sidecar_to_book_path: { "/foo/bar.sdr/metadata.epub.lua" → "/foo/bar.epub" }
|
||||
-- =====================================================================
|
||||
describe("sidecar_to_book_path", function()
|
||||
it("strips .sdr/metadata.<ext>.lua and re-appends .ext to the parent", function()
|
||||
assert.are.equal("/foo/bar.epub",
|
||||
localscanner.sidecar_to_book_path("/foo/bar.sdr/metadata.epub.lua"))
|
||||
assert.are.equal("/books/Asimov - Foundation.pdf",
|
||||
localscanner.sidecar_to_book_path(
|
||||
"/books/Asimov - Foundation.sdr/metadata.pdf.lua"))
|
||||
assert.are.equal("/h/Dune.fb2",
|
||||
localscanner.sidecar_to_book_path("/h/Dune.sdr/metadata.fb2.lua"))
|
||||
end)
|
||||
|
||||
it("preserves multi-dot book names", function()
|
||||
assert.are.equal("/x/Foo. Vol. 1.epub",
|
||||
localscanner.sidecar_to_book_path("/x/Foo. Vol. 1.sdr/metadata.epub.lua"))
|
||||
end)
|
||||
|
||||
it("returns nil for paths that aren't sidecar metadata", function()
|
||||
assert.is_nil(localscanner.sidecar_to_book_path("/foo/bar.epub"))
|
||||
assert.is_nil(localscanner.sidecar_to_book_path("/foo/bar.sdr/cover.png"))
|
||||
assert.is_nil(localscanner.sidecar_to_book_path("/foo/random.lua"))
|
||||
assert.is_nil(localscanner.sidecar_to_book_path(""))
|
||||
assert.is_nil(localscanner.sidecar_to_book_path(nil))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- parse_sidecar: load a sidecar Lua file, extract partial_md5_checksum
|
||||
-- + a few meta fields. Defensive against bad input.
|
||||
-- =====================================================================
|
||||
describe("parse_sidecar", function()
|
||||
local function write(path, text)
|
||||
local f = assert(io.open(path, "w"))
|
||||
f:write(text)
|
||||
f:close()
|
||||
end
|
||||
|
||||
it("extracts partial_md5_checksum from a well-formed sidecar", function()
|
||||
local sdir = datastorage:getSettingsDir() .. "/Foundation.sdr"
|
||||
assert(lfs.mkdir(sdir))
|
||||
local spath = sdir .. "/metadata.epub.lua"
|
||||
write(spath, [[
|
||||
return {
|
||||
["partial_md5_checksum"] = "abc123def456",
|
||||
["doc_props"] = {
|
||||
["title"] = "Foundation",
|
||||
["authors"] = "Isaac Asimov",
|
||||
},
|
||||
["page"] = 42,
|
||||
}
|
||||
]])
|
||||
local out = localscanner.parse_sidecar(spath)
|
||||
assert.are.equal("abc123def456", out.hash)
|
||||
assert.are.equal("Foundation", out.title)
|
||||
assert.are.equal("Isaac Asimov", out.author)
|
||||
-- book_path computed from sidecar location
|
||||
assert.are.equal(sdir:gsub("%.sdr$", ".epub"), out.file_path)
|
||||
end)
|
||||
|
||||
it("returns nil for a sidecar without partial_md5_checksum", function()
|
||||
local sdir = datastorage:getSettingsDir() .. "/Untracked.sdr"
|
||||
assert(lfs.mkdir(sdir))
|
||||
local spath = sdir .. "/metadata.epub.lua"
|
||||
write(spath, "return { doc_props = { title = 'X' }, page = 1 }")
|
||||
assert.is_nil(localscanner.parse_sidecar(spath))
|
||||
end)
|
||||
|
||||
it("returns nil for a malformed Lua file", function()
|
||||
local sdir = datastorage:getSettingsDir() .. "/Bad.sdr"
|
||||
assert(lfs.mkdir(sdir))
|
||||
local spath = sdir .. "/metadata.epub.lua"
|
||||
write(spath, "return { -- unclosed table")
|
||||
assert.is_nil(localscanner.parse_sidecar(spath))
|
||||
end)
|
||||
|
||||
it("returns nil when the file does not exist", function()
|
||||
assert.is_nil(localscanner.parse_sidecar(
|
||||
datastorage:getSettingsDir() .. "/Missing.sdr/metadata.epub.lua"))
|
||||
end)
|
||||
|
||||
it("survives a sidecar that returns a non-table", function()
|
||||
local sdir = datastorage:getSettingsDir() .. "/Weird.sdr"
|
||||
assert(lfs.mkdir(sdir))
|
||||
local spath = sdir .. "/metadata.epub.lua"
|
||||
write(spath, "return 'string instead of table'")
|
||||
assert.is_nil(localscanner.parse_sidecar(spath))
|
||||
end)
|
||||
|
||||
it("survives a sidecar that errors at load (e.g. forbidden global)", function()
|
||||
local sdir = datastorage:getSettingsDir() .. "/Throws.sdr"
|
||||
assert(lfs.mkdir(sdir))
|
||||
local spath = sdir .. "/metadata.epub.lua"
|
||||
write(spath, "error('boom'); return {}")
|
||||
assert.is_nil(localscanner.parse_sidecar(spath))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- should_skip_dir: predicate for the recursive walk
|
||||
-- =====================================================================
|
||||
describe("should_skip_dir", function()
|
||||
it("skips system / VCS / IDE dirs", function()
|
||||
assert.is_true(localscanner.should_skip_dir(".git"))
|
||||
assert.is_true(localscanner.should_skip_dir(".svn"))
|
||||
assert.is_true(localscanner.should_skip_dir("node_modules"))
|
||||
assert.is_true(localscanner.should_skip_dir(".Trash"))
|
||||
assert.is_true(localscanner.should_skip_dir("$RECYCLE.BIN"))
|
||||
assert.is_true(localscanner.should_skip_dir(".adobe-digital-editions"))
|
||||
end)
|
||||
|
||||
it("skips macOS metadata dirs", function()
|
||||
assert.is_true(localscanner.should_skip_dir(".Spotlight-V100"))
|
||||
assert.is_true(localscanner.should_skip_dir(".fseventsd"))
|
||||
end)
|
||||
|
||||
it("does NOT skip '.' / '..' (caller handles those)", function()
|
||||
assert.is_false(localscanner.should_skip_dir("."))
|
||||
assert.is_false(localscanner.should_skip_dir(".."))
|
||||
end)
|
||||
|
||||
it("does NOT skip ordinary book directories", function()
|
||||
assert.is_false(localscanner.should_skip_dir("Books"))
|
||||
assert.is_false(localscanner.should_skip_dir("Sci-Fi"))
|
||||
assert.is_false(localscanner.should_skip_dir("Foo.sdr")) -- the walk visits these
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
@@ -0,0 +1,81 @@
|
||||
-- smoke_spec.lua
|
||||
-- Minimal sanity check that the busted harness + spec_helper are wired up.
|
||||
-- If this passes we know:
|
||||
-- 1. busted finds spec files via .busted ROOT/pattern
|
||||
-- 2. spec_helper preloads run (KOReader stubs available globally)
|
||||
-- 3. The lua-ljsqlite3/init shim works against an in-memory SQLite DB
|
||||
-- Other spec files can then assume all of the above.
|
||||
|
||||
local helper = require("spec_helper")
|
||||
|
||||
describe("spec harness smoke test", function()
|
||||
before_each(function()
|
||||
helper.reset()
|
||||
end)
|
||||
|
||||
it("runs at all", function()
|
||||
assert.are.equal(4, 2 + 2)
|
||||
end)
|
||||
|
||||
it("exposes G_reader_settings as a global", function()
|
||||
assert.is_not_nil(_G.G_reader_settings)
|
||||
G_reader_settings:saveSetting("hello", "world")
|
||||
assert.are.equal("world", G_reader_settings:readSetting("hello"))
|
||||
end)
|
||||
|
||||
it("reset wipes G_reader_settings between tests", function()
|
||||
assert.is_nil(G_reader_settings:readSetting("hello"))
|
||||
end)
|
||||
|
||||
it("DataStorage:getSettingsDir() returns a usable temp dir", function()
|
||||
local DataStorage = require("datastorage")
|
||||
local dir = DataStorage:getSettingsDir()
|
||||
assert.is_string(dir)
|
||||
assert.is_truthy(dir:match("^/tmp/readest%-koplugin%-spec%-"))
|
||||
-- Confirm we can write into it
|
||||
local f = assert(io.open(dir .. "/probe.txt", "w"))
|
||||
f:write("ok")
|
||||
f:close()
|
||||
local r = assert(io.open(dir .. "/probe.txt", "r"))
|
||||
assert.are.equal("ok", r:read("*a"))
|
||||
r:close()
|
||||
end)
|
||||
|
||||
it("opens an in-memory SQLite via the lua-ljsqlite3 shim", function()
|
||||
local SQ3 = require("lua-ljsqlite3/init")
|
||||
local db = SQ3.open(":memory:")
|
||||
db:exec([[
|
||||
CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);
|
||||
INSERT INTO t (id, name) VALUES (1, 'alpha'), (2, 'beta');
|
||||
]])
|
||||
|
||||
-- rowexec helper
|
||||
assert.are.equal(2, db:rowexec("SELECT COUNT(*) FROM t"))
|
||||
|
||||
-- prepare/bind/step round-trip
|
||||
local sel = db:prepare("SELECT name FROM t WHERE id = ?")
|
||||
local row = sel:reset():bind1(1, 1):step()
|
||||
assert.is_table(row)
|
||||
assert.are.equal("alpha", row[1])
|
||||
|
||||
row = sel:reset():bind1(1, 2):step()
|
||||
assert.are.equal("beta", row[1])
|
||||
|
||||
-- step returns nil after exhausting matches
|
||||
local none = sel:reset():bind1(1, 999):step()
|
||||
assert.is_nil(none)
|
||||
|
||||
sel:close()
|
||||
db:close()
|
||||
end)
|
||||
|
||||
it("logger stub is silently callable", function()
|
||||
local logger = require("logger")
|
||||
assert.has_no.errors(function()
|
||||
logger.warn("a warning")
|
||||
logger.info("an info")
|
||||
logger.dbg("debug")
|
||||
logger.err("an error")
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
@@ -0,0 +1,253 @@
|
||||
-- syncbooks_spec.lua
|
||||
-- Pure-function tests for library/syncbooks.lua. The network-touching parts
|
||||
-- (pullBooks, downloadBook, downloadCover) require Spore + httpclient +
|
||||
-- NetworkMgr + UIManager.looper at require-time and aren't unit-testable;
|
||||
-- they're covered by the manual matrix. These specs lock down the bits we
|
||||
-- CAN exercise in isolation: the cloud fileKey / local-filename builders
|
||||
-- that the UI tier depends on.
|
||||
|
||||
require("spec_helper")
|
||||
|
||||
describe("library.syncbooks", function()
|
||||
local syncbooks
|
||||
|
||||
before_each(function()
|
||||
package.loaded["library.syncbooks"] = nil
|
||||
syncbooks = require("library.syncbooks")
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- build_file_key — cloud download fileKey for a book file
|
||||
-- =====================================================================
|
||||
describe("build_file_key", function()
|
||||
it("returns the canonical {user_id}/Readest/Books/{hash}/{hash}.{ext} shape", function()
|
||||
local key = syncbooks.build_file_key({
|
||||
user_id = "u-alice",
|
||||
hash = "abc123",
|
||||
format = "EPUB",
|
||||
})
|
||||
assert.are.equal("u-alice/Readest/Books/abc123/abc123.epub", key)
|
||||
end)
|
||||
|
||||
it("derives extension from format via library.exts", function()
|
||||
local cases = {
|
||||
EPUB = "epub", PDF = "pdf", MOBI = "mobi", AZW3 = "azw3",
|
||||
CBZ = "cbz", FB2 = "fb2", TXT = "txt", MD = "md",
|
||||
}
|
||||
for fmt, ext in pairs(cases) do
|
||||
local key = syncbooks.build_file_key({
|
||||
user_id = "u",
|
||||
hash = "h",
|
||||
format = fmt,
|
||||
})
|
||||
assert.are.equal("u/Readest/Books/h/h." .. ext, key,
|
||||
"wrong key for format " .. fmt)
|
||||
end
|
||||
end)
|
||||
|
||||
it("returns nil for an unknown format (caller decides what to do)", function()
|
||||
local key = syncbooks.build_file_key({
|
||||
user_id = "u",
|
||||
hash = "h",
|
||||
format = "ROT13",
|
||||
})
|
||||
assert.is_nil(key)
|
||||
end)
|
||||
|
||||
it("returns nil when user_id is missing or empty", function()
|
||||
assert.is_nil(syncbooks.build_file_key({ hash = "h", format = "EPUB" }))
|
||||
assert.is_nil(syncbooks.build_file_key({ user_id = "", hash = "h", format = "EPUB" }))
|
||||
end)
|
||||
|
||||
it("returns nil when hash is missing or empty", function()
|
||||
assert.is_nil(syncbooks.build_file_key({ user_id = "u", format = "EPUB" }))
|
||||
assert.is_nil(syncbooks.build_file_key({ user_id = "u", hash = "", format = "EPUB" }))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- build_cover_key — cloud download fileKey for a cover image
|
||||
-- =====================================================================
|
||||
describe("build_cover_key", function()
|
||||
it("returns {user_id}/Readest/Books/{hash}/cover.png — same on R2 and S3", function()
|
||||
local key = syncbooks.build_cover_key({ user_id = "u-bob", hash = "deadbeef" })
|
||||
assert.are.equal("u-bob/Readest/Books/deadbeef/cover.png", key)
|
||||
end)
|
||||
|
||||
it("returns nil with missing user_id or hash", function()
|
||||
assert.is_nil(syncbooks.build_cover_key({ hash = "h" }))
|
||||
assert.is_nil(syncbooks.build_cover_key({ user_id = "u" }))
|
||||
assert.is_nil(syncbooks.build_cover_key({ user_id = "", hash = "h" }))
|
||||
assert.is_nil(syncbooks.build_cover_key({ user_id = "u", hash = "" }))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- build_local_filename — what we save the cloud download as on disk
|
||||
-- =====================================================================
|
||||
-- Per design doc: local layout is FLAT (KOReader users prefer flat
|
||||
-- dirs). Filename derived from source_title || title, sanitized for
|
||||
-- filesystem-illegal chars. No JS-parity port required.
|
||||
describe("build_local_filename", function()
|
||||
it("uses source_title when present, else title", function()
|
||||
assert.are.equal("Foundation.epub", syncbooks.build_local_filename({
|
||||
title = "anything", source_title = "Foundation", format = "EPUB",
|
||||
}))
|
||||
assert.are.equal("Dune.pdf", syncbooks.build_local_filename({
|
||||
title = "Dune", format = "PDF",
|
||||
}))
|
||||
end)
|
||||
|
||||
it("replaces filesystem-illegal chars with _ (FAT/NTFS-safe)", function()
|
||||
assert.are.equal("Sci-Fi_Classics_Foundation.epub", syncbooks.build_local_filename({
|
||||
title = "Sci-Fi/Classics/Foundation", format = "EPUB",
|
||||
}))
|
||||
assert.are.equal("Title_with_pipe_and_quote.epub", syncbooks.build_local_filename({
|
||||
title = 'Title|with|pipe"and"quote', format = "EPUB",
|
||||
}))
|
||||
end)
|
||||
|
||||
it("strips control characters", function()
|
||||
assert.are.equal("Foo_Bar.epub", syncbooks.build_local_filename({
|
||||
title = "Foo\1Bar", format = "EPUB",
|
||||
}))
|
||||
end)
|
||||
|
||||
it("clamps long names (no Lua-side surprise on 1KB titles)", function()
|
||||
local long = string.rep("a", 500)
|
||||
local out = syncbooks.build_local_filename({ title = long, format = "EPUB" })
|
||||
-- 200 byte body + ".epub" extension = 205 max
|
||||
assert.is_true(#out <= 205, "filename too long: " .. #out)
|
||||
assert.is_true(out:match("%.epub$") ~= nil, "extension lost")
|
||||
end)
|
||||
|
||||
it("preserves UTF-8 (CJK + emoji) without mangling code points", function()
|
||||
local out = syncbooks.build_local_filename({
|
||||
title = "三体 — 第一部 📖", format = "EPUB",
|
||||
})
|
||||
-- Em dash is allowed; CJK + emoji preserved verbatim.
|
||||
assert.is_true(out:match("三体") ~= nil)
|
||||
assert.is_true(out:match("📖") ~= nil)
|
||||
assert.is_true(out:match("%.epub$") ~= nil)
|
||||
end)
|
||||
|
||||
it("returns 'book.{ext}' when title is empty/missing", function()
|
||||
assert.are.equal("book.epub", syncbooks.build_local_filename({ format = "EPUB" }))
|
||||
assert.are.equal("book.epub", syncbooks.build_local_filename({ title = "", format = "EPUB" }))
|
||||
end)
|
||||
|
||||
it("returns nil for unknown format (matches build_file_key behavior)", function()
|
||||
assert.is_nil(syncbooks.build_local_filename({ title = "X", format = "ROT13" }))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- _row_to_wire — converts our snake_case store row to the camelCase
|
||||
-- Book the server expects on POST /sync. Locks the field mapping
|
||||
-- against drift from transformBookToDB at
|
||||
-- apps/readest-app/src/utils/transform.ts:66-105.
|
||||
-- =====================================================================
|
||||
describe("_row_to_wire", function()
|
||||
it("maps snake_case row fields to camelCase wire fields", function()
|
||||
local out = syncbooks._row_to_wire({
|
||||
hash = "h1",
|
||||
meta_hash = "m1",
|
||||
format = "EPUB",
|
||||
title = "Foundation",
|
||||
source_title = "Foundation (Original)",
|
||||
author = "Asimov",
|
||||
group_id = "gid1",
|
||||
group_name = "Sci-Fi",
|
||||
reading_status = "reading",
|
||||
created_at = 1700000000000,
|
||||
updated_at = 1770000000000,
|
||||
deleted_at = nil,
|
||||
uploaded_at = 1750000000000,
|
||||
})
|
||||
assert.are.equal("h1", out.bookHash)
|
||||
assert.are.equal("h1", out.hash)
|
||||
assert.are.equal("m1", out.metaHash)
|
||||
assert.are.equal("EPUB", out.format)
|
||||
assert.are.equal("Foundation", out.title)
|
||||
assert.are.equal("Foundation (Original)", out.sourceTitle)
|
||||
assert.are.equal("Asimov", out.author)
|
||||
assert.are.equal("gid1", out.groupId)
|
||||
assert.are.equal("Sci-Fi", out.groupName)
|
||||
assert.are.equal("reading", out.readingStatus)
|
||||
assert.are.equal(1700000000000, out.createdAt)
|
||||
assert.are.equal(1770000000000, out.updatedAt)
|
||||
assert.is_nil(out.deletedAt)
|
||||
assert.are.equal(1750000000000, out.uploadedAt)
|
||||
end)
|
||||
|
||||
it("parses metadata_json back to a table for the server", function()
|
||||
local out = syncbooks._row_to_wire({
|
||||
hash = "h1", title = "T",
|
||||
metadata_json = '{"series":"Foundation","seriesIndex":1}',
|
||||
})
|
||||
assert.is_table(out.metadata)
|
||||
assert.are.equal("Foundation", out.metadata.series)
|
||||
assert.are.equal(1, out.metadata.seriesIndex)
|
||||
end)
|
||||
|
||||
it("parses progress_lib back to a [cur, total] tuple", function()
|
||||
local out = syncbooks._row_to_wire({
|
||||
hash = "h1", title = "T",
|
||||
progress_lib = "[42,250]",
|
||||
})
|
||||
assert.is_table(out.progress)
|
||||
assert.are.equal(42, out.progress[1])
|
||||
assert.are.equal(250, out.progress[2])
|
||||
end)
|
||||
|
||||
it("survives missing/malformed metadata + progress without crashing", function()
|
||||
local out = syncbooks._row_to_wire({
|
||||
hash = "h1", title = "T",
|
||||
metadata_json = "{not valid json",
|
||||
progress_lib = "garbage",
|
||||
})
|
||||
assert.is_nil(out.metadata)
|
||||
assert.is_nil(out.progress)
|
||||
end)
|
||||
|
||||
it("returns nil for nil input (defensive)", function()
|
||||
assert.is_nil(syncbooks._row_to_wire(nil))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- resolve_collision — bumps {name}.ext → {name} (1).ext on collision
|
||||
-- =====================================================================
|
||||
-- Pure helper; takes a list of existing filenames + a candidate, returns
|
||||
-- a non-colliding name. Real downloadBook calls lfs.attributes; this
|
||||
-- helper takes a "does it exist" predicate so we can test it.
|
||||
describe("resolve_collision", function()
|
||||
it("returns the input when nothing collides", function()
|
||||
local out = syncbooks.resolve_collision("Dune.epub", function() return false end)
|
||||
assert.are.equal("Dune.epub", out)
|
||||
end)
|
||||
|
||||
it("appends (1) on first collision", function()
|
||||
local existing = { ["Dune.epub"] = true }
|
||||
local out = syncbooks.resolve_collision("Dune.epub",
|
||||
function(name) return existing[name] == true end)
|
||||
assert.are.equal("Dune (1).epub", out)
|
||||
end)
|
||||
|
||||
it("walks (1)..(N) until a free slot is found", function()
|
||||
local existing = {}
|
||||
existing["Dune.epub"] = true
|
||||
existing["Dune (1).epub"] = true
|
||||
existing["Dune (2).epub"] = true
|
||||
local out = syncbooks.resolve_collision("Dune.epub",
|
||||
function(name) return existing[name] == true end)
|
||||
assert.are.equal("Dune (3).epub", out)
|
||||
end)
|
||||
|
||||
it("handles names without an extension", function()
|
||||
local out = syncbooks.resolve_collision("README",
|
||||
function(name) return name == "README" end)
|
||||
assert.are.equal("README (1)", out)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user