Three paragraph-mode problems, all fixed:
- Shift+P inside paragraph mode flashed and re-entered (and pressing it
repeatedly did nothing). A single keypress toggled twice: eventDispatcher
.dispatch() iterated the live listener Set while awaiting each listener, and
the exit's awaited dispatch('paragraph-mode-disabled') let React re-run
useParagraphMode's subscription effect mid-loop, adding a handler the same
dispatch then called. Snapshot the listeners before iterating (dispatchSync
already did), so a listener added during a dispatch can't fire for the
current event.
- Shift+P / Escape only worked when focus sat on the overlay. Handle the
overlay's keys the way a dialog/alert does: focus the dialog element on open
and handle Escape / the toggle shortcut / paragraph navigation in its own
onKeyDown (stopping propagation so the global handler can't double-fire),
instead of a global window listener. Suppress the focus ring on the
programmatically-focused, non-tab-stop container.
- Resume jumped to the chapter start, and repeated enter/exit walked further
back. Two causes: (a) entering/exiting scrolled the underlying view to the
focused paragraph's start, which rewinds a page when that paragraph began on
the previous page — don't scroll on resume/exit (the paragraph is already on
screen); navigation still scrolls. (b) resume preferred the rAF-debounced
store progress and a stored last-paragraph CFI that can come out malformed
and resolve to an empty range, shadowing the correct candidate and sending
findByRange to the first block. Resume from the view's live, foliate-
generated lastLocation CFI first (set synchronously on every relocate,
resolved against the current document so it survives iframe recreation).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Urdu (ur) to TRANSLATOR_LANGS so it appears in the Translate Text
language list (inline TranslatorPopup and Settings → Translation). The
list is not provider-gated, and Google/Azure/Yandex all translate to
Urdu; Urdu is already in MIGHT_BE_RTL_LANGS so the translated output
renders right-to-left.
Closes#4721
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On Windows/Linux, Ctrl+P opens the proofread/replace rules but also
triggers the browser print dialog, since the selection shortcut handlers
return undefined and never preventDefault. Add a print-free `alt+p`
binding for Proofread Selection alongside ctrl+p/cmd+p.
Also fix Shift+P being unable to exit paragraph mode: the paragraph
overlay attaches a capture-phase keydown listener that calls
stopImmediatePropagation() on every key while visible, so the global
toggle shortcut never reached useShortcuts. Honor the configured
"Toggle Paragraph Mode" shortcut directly in the overlay so the same
shortcut that enters paragraph mode also exits it.
Extract the shared shortcut event-matching into matchesShortcut() in
utils/shortcutKeys.ts and reuse it from useShortcuts instead of its
private duplicate.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Android System TTS (and iOS) read-aloud could stop offline and refuse to
continue — #4613 "stops at the end of the chapter, won't advance" and #4408
"stops at random intervals" — after which the play/headphone controls felt
wedged.
Root cause: `TTSController.#speak` only auto-advances when the last event code
is `end`. The native client surfaces an offline engine failure as a terminal
`error` code (Android `UtteranceProgressListener.onError`). This typically
happens on a specific utterance the offline engine can't synthesize — e.g. an
unsupported character — characteristically the first utterance after a chapter
boundary, even with a local/offline voice (online, engines often fall back to
network synthesis, which is why it only breaks offline). On `error` the
controller never called `forward()` and left `state` stuck at `playing`, so
playback dead-ended and the controls couldn't recover. Edge/Web clients throw
instead (handled by `error()`), so only the native client hit this.
Fix (native-scoped, no change to the Edge/Web path): when the active client is
the native client and an utterance ends with a terminal `error` (still playing,
not aborted, not one-time), skip that chunk and advance just as a normal `end`
would — re-speaking the same unsynthesizable text would only fail again. A
consecutive-error cap stops playback gracefully if the engine can't speak
anything, so a wholly-unusable engine doesn't silently race to the end of the
book and the state machine always leaves `playing`.
Tests: tts-controller covers skip-on-error advancing past a bad chunk, and the
consecutive-error cap stopping gracefully (bounded, not wedged in playing).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): decouple the incremental-pull cursor from updated_at (#4678)
`books.updated_at` was overloaded as both the incremental-pull cursor
(`GET /api/sync?since=…` filters `updated_at > since`, devices keep one
global `max(updated_at)` watermark) and the library "date read" sort key.
A server-resolved reading-status merge had to be written with a timestamp
greater than every peer's global cursor to propagate, which forced
`updated_at = now()` and reordered the date-read library by sync-processing
time (the #4677 symptom).
Introduce a server-assigned `synced_at` column on `books`, stamped by a
`BEFORE INSERT OR UPDATE` trigger on every write, used only as the pull
cursor. `updated_at` stays pure client event time used only for sorting.
- Migration 016 + baseline schema: add `synced_at` (NOT NULL DEFAULT now()),
index `(user_id, synced_at)`, trigger `set_books_synced_at`. Backfill
`synced_at = updated_at` before creating the trigger so existing devices'
cursors hand over without a re-sync storm.
- GET: books filters/orders on `synced_at > since` (a delete bumps synced_at,
so the deleted_at clause is dropped); configs/notes stay on updated_at.
- POST: extract `buildStatusPropagationRow` and drop the `updated_at = now()`
bump — the trigger advances synced_at so peers re-pull the status change
while updated_at (the sort key) stays put.
- Client `computeMaxTimestamp` keys on synced_at, falling back to
updated_at/deleted_at for pre-migration servers and configs/notes.
Backward-compatible: `synced_at >= updated_at` always, so `synced_at > since`
is a strict superset of `updated_at > since` — old web clients and the
koplugin keep working with no data loss (at worst a redundant idempotent
re-pull of rare server-merged rows). The koplugin's shared pull/push cursor
is left untouched; a proper split is a follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync): make the books synced_at backfill safe for large live tables (#4678)
The single `UPDATE … WHERE synced_at IS NULL` deadlocked on a 3.8M-row
production `books` table: it rewrites every row in one transaction while the
live /api/sync push path upserts books rows, and the two lock rows in opposite
orders. `ALTER COLUMN … SET NOT NULL` (full-table ACCESS EXCLUSIVE scan) and a
plain CREATE INDEX (write-blocking SHARE lock) compounded it.
Rework migration 016 as an online migration (run via psql, not in a wrapping
transaction):
- backfill in small autocommitted batches via a procedure, using
FOR UPDATE SKIP LOCKED so it never waits on an app-locked row;
- CREATE INDEX CONCURRENTLY instead of a blocking build;
- install the trigger last (so it can't clobber the updated_at backfill);
- drop the hard SET NOT NULL (the default + trigger + backfill keep the column
populated and the client falls back to updated_at); a NOT VALID CHECK +
VALIDATE alternative is included, commented, for operators who want it.
The baseline schema.sql (fresh, empty installs) keeps the simple inline
NOT NULL DEFAULT now() + trigger.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In a PDF two-page spread at a fractional devicePixelRatio (Windows display
scale 150% -> dpr 1.5), a one-pixel white bar appeared at the spine on certain
zoom levels. foliate-js' pdf.js sized the page canvas only via its bitmap, so
the fractional viewport width was truncated and the canvas rendered up to ~1
device pixel narrower than the page box, exposing the background at the spine.
Bump the foliate-js submodule to the fix (readest/foliate-js#35) which pins an
explicit canvas CSS size to the un-truncated viewport dimensions, and add a
regression test that drives render() at dpr 1.5 and asserts the canvas fills
its box exactly.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When "page turn with volume buttons" is enabled, the volume keys were
intercepted for the whole reading session, so switching from reading to
TTS left them flipping pages instead of adjusting playback volume.
Gate the volume-key interception on this book's TTS playback state
(via the existing `tts-playback-state` bus): release interception while
TTS is playing so the OS handles volume, and re-acquire it when TTS is
paused or stopped. The acquire/release pair is keyed on the playback
state so the deviceStore reference count stays balanced.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A wrong Server URL can land on the host's static web UI, which answers
200 OK with an HTML page. connect() treated any 2xx from /users/auth (or
/users/create) as a successful login, so the user was silently
"connected" to an endpoint that can never sync: pulls report 0% and
pushes fail with no error surfaced. This is the root of the symptom in
#4692 (KOReader sync to a Grimmory/Booklore server failing on Android).
Validate that the auth/registration response is an actual KOReader Sync
JSON object (a real server replies e.g. {"authorized":"OK"}; an HTML
page fails JSON parsing) and otherwise return a clear
"Not a KOReader Sync server. Check the Server URL." message.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an always-visible, opt-in progress bar with chapter tick marks in the
persistent footer, so reading progress no longer disappears like the hover
footer slider does.
- New StickyProgressBar: a 1px rounded-border capsule with a fill and
chapter tick marks; display-only, e-ink aware, and RTL safe. Ticks render
inside the clipped track so the rounded ends crop them and they never
exceed the border.
- Chapter ticks come from the TOC, mapped to spine-section start fractions
(getChapterTickFractions); the first and last ticks are trimmed so they
do not crowd the rounded ends.
- Thread the overall size-domain reading fraction through setProgress so the
bar fill aligns with the tick domain.
- Footer layout: when enabled the bar grows on the left and the info widgets
group to the right with even spacing; otherwise the existing layout is
unchanged.
- Horizontal writing mode only; vertical keeps the current footer.
- Add the showStickyProgressBar view setting, a LayoutPanel toggle, and i18n.
Closes#1616.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Sync library-scope replacement rules across devices (settings whitelist).
Book and selection rules already ride along the book config.
- Add regex support: a Regex toggle on the selection popup plus a full
add-rule form (find / replace / scope / regex / case-sensitive) in the
Proofread Rules manager.
- Reuse Ctrl/Cmd+P to open the rules manager when nothing is selected
(handleProofread); opens the create-from-selection popup otherwise.
- Translate the whole-word warning, which was hardcoded English.
- Drag-to-reorder rules within each category (dnd-kit), persisted via the
rule order field across both the book config and global settings.
- Modernize the manager dialog with the settings primitives and a
btn-contrast CTA; fix mobile height clipping and the inset scrollbar.
- Translate all pending i18n strings across 33 locales (includes the
delete-confirm strings surfaced by the extractor).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A backup .zip exported on Windows failed to restore on every platform
(Web, Android, Windows): books restored with metadata but no files or
covers.
`appService.readDirectory` returns paths using the host separator, so on
Windows `file.path` is `hash\cover.png` (backslash). `addBackupEntriesToZip`
used that verbatim as the zip entry name, so entries were named with `\`.
Restore matches a book's files with `filename.startsWith(`${hash}/`)`
(forward slash), which never matched the backslash names, so every book
file was silently skipped.
Normalize the zip entry name to forward slashes when adding files. Entry
names are now cross-platform and restorable everywhere. Already-exported
broken backups need re-exporting from the fixed app.
Fixes#4703
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the standalone "Purge Data" menu item with an opt-in toggle on the
delete confirmation alert (default off). When enabled, the delete escalates
to a full purge that also wipes the book's reading-data sidecars (config and
nav), instead of leaving the metadata folder behind. The single, bulk, and
multi-select deletes all share the same alert, so this also covers batch
deletes that previously kept every metadata folder.
- Alert: add optional children, confirmLabel, confirmButtonClassName slots
- DeleteConfirmAlert: new wrapper owning the toggle and red escalation
- BookDetailView: drop the Purge Data menu item and onPurge prop
- BookDetailModal: route the standard delete to purge when the toggle is on
- Bookshelf/page: route the bulk delete batch to purge when the toggle is on
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(payment): observability for store subscription webhooks
Add monitoring for the App Store / Google Play webhooks so store-side
subscription changes are observable on Cloudflare, where stored Workers
Logs are head-sampled at 1% and would miss almost all low-volume webhook
events.
- Add iap/telemetry.ts: every webhook invocation emits a structured log
line (streamed in full by `wrangler tail`) and a Cloudflare Analytics
Engine data point (100% capture, independent of log sampling). Writes
no-op off the Worker runtime, mirroring the getCloudflareContext guard
in deepl/translate.ts.
- Instrument both webhook routes to record outcome (handled, skipped,
rejected, error), notification type, status, reason, and latency on
every return path.
- Add GET /api/cron/iap-reconcile: a CRON_SECRET-protected sweep that
counts drift (rows still active while their store expiry has passed = a
missed webhook) in both IAP tables and records a reconcile metric.
Detection-only; never mutates state.
- Add the IAP_WEBHOOK_AE Analytics Engine binding to wrangler.toml.
New configuration: CRON_SECRET (reconcile auth) and an iap_webhooks
Analytics Engine dataset bound as IAP_WEBHOOK_AE. The reconcile route is
triggered on a schedule (a Cloudflare Cron Trigger worker that fetches
the URL, or any external scheduler) with an Authorization bearer header.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(payment): move reconciliation to a dedicated cron Worker
Replace the public CRON_SECRET-protected /api/cron/iap-reconcile route
with a dedicated Cloudflare Cron Worker. A Cron Trigger invokes the
worker's scheduled() handler directly, so there is no public HTTP surface
and no shared request secret to manage - the strongest option on
Cloudflare (OpenNext's generated worker only exports `fetch`, so the main
worker cannot host a scheduled() handler).
- Add workers/iap-reconcile: a self-contained worker (own package.json,
tsconfig, wrangler.toml) matching the existing workers/send-email
convention, registered in pnpm-workspace.yaml. Hourly Cron Trigger;
reads the IAP tables via the Supabase service role and records a drift
metric to the shared iap_webhooks Analytics Engine dataset.
- Reconcile logic lives in workers/iap-reconcile/src/reconcile.ts and is
unit-tested from the app suite.
- Remove the public route and its test; drop the now-unused
recordIapReconcile from iap/telemetry.ts (webhook telemetry unchanged).
Configuration: set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY as secrets
on the worker and deploy it with `wrangler deploy` from its directory.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add server-push endpoints so store-side subscription changes (cancel,
refund, expire, renew, grace period) are reflected in the database, not
only the in-app verification flow. Previously only Stripe had a webhook.
- POST /api/apple/notifications: verify and decode App Store Server
Notifications V2, resolve the user by original_transaction_id, map the
notification type to a status, and update the subscription and plan. A
single endpoint serves Sandbox and Production. Refunded one-time
purchases are marked refunded and storage is recomputed.
- POST /api/google/notifications: verify the Pub/Sub shared-secret token,
decode the RTDN, resolve the user by purchase_token, re-verify against
the Play Developer API (overriding the status for terminal events such
as REVOKED/EXPIRED and grace period), and handle voided purchases.
- Add an isEntitledStatus helper and reuse the existing
createOrUpdateSubscription and plan-update logic shared with Stripe.
New configuration: GOOGLE_RTDN_VERIFICATION_TOKEN (shared secret in the
Pub/Sub push URL) and the optional GOOGLE_IAP_PACKAGE_NAME; Apple reuses
APPLE_IAP_BUNDLE_ID and the existing service-account credentials.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implement on-device iOS text-to-speech using AVSpeechSynthesizer,
mirroring the Android native TextToSpeech plugin so the shared
NativeTTSClient drives both platforms through the same command and
tts_events contract.
- Swift NativeTTSPlugin: speak/stop/pause/resume/rate/pitch/voice and
voice enumeration, with region-disambiguated duplicate voice names and
a small preUtteranceDelay to avoid first-word clipping.
- Enable the native TTS client on iOS in TTSController.
- Make TTS teardown resilient: reset UI state up front and tear down the
controller, media session, and background audio in parallel so a slow
native shutdown can never leave the TTS icon or lock-screen session
stuck on.
- Keep iOS on navigator.mediaSession for the lock screen (Android uses
the native foreground service), which restores the Edge TTS cover and
current-sentence metadata.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deleting a book imported via "Import From Directory" with "Read books in
place" ran fs.removeFile on the user's own source file, permanently
destroying the original (it was not even moved to the Recycle Bin). Cloud
sync only uploads after a successful sync, so unsynced originals were
unrecoverable.
deleteBook now only removes files Readest created: the managed copy under
Books/<hash>/ and the app-generated sidecars (cover.png, plus the whole
Books/<hash>/ dir on purge). External sources (book.filePath, base 'None',
covering in-place and transient imports) are never touched.
This reverses behavior that was previously deliberate and tested; the
in-place tests now assert the source file is preserved across
local/both/purge while sidecar removal is unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In paginated fixed-layout mode (PDF / fixed-layout EPUB) with fit-width zoom, a
page taller than the viewport makes the renderer host scroll vertically. Turning
the page kept the previous page's vertical offset, so the next page opened
scrolled to the end instead of the top. The bug only shows on WebKit
(Linux/iOS/macOS), which preserves the scroll offset across the page content
swap; Blink (Android/Chrome) resets it to zero.
Bump foliate-js to the fix (readest/foliate-js#34): reset scrollTop on a page
turn only. Add a unit test for the new computePaginatedScroll helper.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(payment): reflect highest active plan across overlapping Stripe subscriptions
When a user upgrades Plus to Pro, both subscriptions stay active until the old
one is cancelled. Each subscription webhook overwrote plans.plan with only that
event's plan, so whichever webhook arrived last won and could downgrade the
account back to plus.
Derive plans.plan from the highest active (or trialing) subscription via a new
getHighestActivePlan helper, used by createOrUpdateSubscription and by the
cancellation handler so a still-active higher plan is preserved instead of
dropping to free.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(payment): add opt-in live Stripe test for getHighestActivePlan
Skipped by default (CI included); runs only when STRIPE_SECRET_KEY and
STRIPE_TEST_CUSTOMER_ID are set, so it can be exercised locally against a real
customer with overlapping subscriptions. The module under test is imported
dynamically so the file stays import-safe while skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): add save/share button to image gallery toolbar
Add a button to the top-right toolbar of the fullscreen image viewer
that saves the currently viewed image to the device. It uses the native
or web Share flow where available (iOS/Android/macOS, navigator.share)
and falls back to a save dialog or browser download otherwise, reusing
the existing export path via appService.saveFile.
The button icon and label reflect the active flow (share vs save).
Adds dataUrlToBytes/imageExtensionFromMime helpers, unit and component
tests, and translations for the new strings across all locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(share): write shareable file to a Temp subdirectory to avoid 0-byte share
On Android, Tauri's Temp dir is the app cache dir, and the sharekit plugin
copies the shared file to <cacheDir>/<name> before firing the share intent.
When saveFile wrote the shareable file to the Temp root, that copy became a
copy onto itself whose output stream truncated the source to 0 bytes, so the
shared image (and any shared export) arrived as a 0 KB file. Write the file
to a Temp subdirectory instead so the plugin's copy has a distinct source.
Verified on a Xiaomi device: sharing a file in the Temp root truncated it to
0 bytes, while sharing from the subdirectory produced a real, non-empty copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): save image to system gallery on Android
The Android share sheet cannot save an image to a file (no file manager
registers as an ACTION_SEND target), so the Save Image button now writes
the image straight into the system photo gallery via MediaStore. It lands
in Pictures/Readest, visible in Gallery and the Files app, with no picker
and no storage permission on Android 10+.
Adds a save_image_to_gallery command to the native-bridge plugin (Rust +
Kotlin MediaStore insert) and an appService.saveImageToGallery method. On
Android the Save button uses it; iOS/macOS/desktop/web keep the existing
share/export flow, and the button label/icon reflect the actual action.
Also includes local agent memory notes that were staged alongside.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump foliate-js so paginated Duokan full-page covers
(data-duokan-page-fullscreen) render with object-fit: fill instead of
contain. The cover now fills the whole page, distorting to fit when the
aspect ratio differs, matching Duokan's native full-page render. Adds a
browser test asserting the fullscreen cover computes object-fit: fill.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sync POST handler rewrote books.updated_at = now() whenever a pushed
book's resolved reading_status differed from the server row's. A book that
was imported locally and never given a status sends reading_status:
undefined, while the server stores null, so `undefined !== null` reported a
spurious status change. The 1-day re-sync window re-pushes every recently
touched book on each sync, so the server stamped those books with a fresh,
batch-identical timestamp every cycle, floating them to the top of the
date-sorted library (and above a book the user had just read).
Normalize nullish reading_status values before comparing so a statusless
book never registers as a status change. Verified on-device via CDP:
PUSH_SENT carried the old timestamp while PUSH_RETURNED came back with a
fresh now() for exactly the statusless books.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump foliate-js to the textureAwareBackground fix and add a regression
test. A cover page that paints its image via a body background-image
leaves background-color transparent, so the computed background shorthand
starts with "rgba(0, 0, 0, 0)" even though a real image follows. The
paginator misclassified it as transparent and, with a background texture
active (e.g. parchment), dropped the page background so the texture showed
on the first page instead of the cover. Verified on Android WebView.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The saved catalog cards used hover:bg-base-200/40. Since base-200 is only
~5% off base-100, applying it at 40% alpha shifted the background by roughly
2%, and with the dialog itself sitting at base-200 the hover collapsed into
the dialog color, making the hovered card blend in.
Use hover:bg-base-300 (~12% off base-100) so the hover state is clearly
separated from both the resting card (base-100) and the dialog (base-200).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(rsvp): restore in-flow control bar layout reverted by #4589
PR #4585 fixed the mobile RSVP control bar overlap by laying the audio
toggle and settings gear in a single in-flow flex row flanking the
centered transport. PR #4589 branched from main about five minutes
before #4585 merged and merged about ten hours later without rebasing,
so its squash carried the stale pre-#4585 file and reverted the entire
fix, including the regression test #4585 had added.
On narrow phones (360px) the audio and settings icons again overlapped
the right end of the transport, hiding the "skip forward 15" control.
Restore the #4585 layout and re-add a structural guard test asserting
the audio toggle and settings share the transport row and live in no
absolutely positioned cluster. Verified on a Xiaomi 13 (360px) via
on-device CDP: no overlap, play button stays centered.
Also stage the project-memory note for this regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(rsvp): hide Faster/Slower buttons at 350px or below
On very narrow phones (width 350px or less) the control row has no room
for every control. Collapse the Faster/Slower speed buttons via a
max-[350px]:hidden variant (matching the existing 350px tightening tier)
so the transport, audio toggle and settings never overflow. Speed stays
adjustable from the WPM dropdown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Large PDFs (50 MB+) crashed on import/open. pdf.js requests hundreds of
byte ranges in a burst while parsing the document structure, and
foliate-js makePDF dispatched them all concurrently. On Android each read
is served through the WebView's rangefile custom scheme
(shouldInterceptRequest); the flood of simultaneous native requests
exhausts the WebView's Java heap (OutOfMemoryError in handleRequest).
Bump foliate-js to cap in-flight range reads at 6 (the implicit per-host
limit a real HTTP transport already gets), and add a regression test
asserting makePDF keeps at most 6 range reads in flight.
Verified live on a Xiaomi 13 (Android 16 / WebView 147) via CDP: max
concurrent range reads drop from 753 to 6 with no change in open time.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Add OPDS Catalog" dialog (a ModalPortal opened from inside
Settings > Integrations > OPDS Catalogs) rendered behind the Settings
sheet on mobile, so the form could not be reached or filled in.
Root cause: PR #3235 raised the Settings dialog to z-[10050] to clear
the full-screen RSVP overlay (z-[10000]) for in-overlay dictionary
management. That also jumped Settings above the ModalPortal layer
(z-[100]), so any modal opened from inside Settings was buried. The bug
is mobile-only because on desktop the rounded-window frame
(.window-border, z-99) traps the inline-rendered Settings dialog in its
own stacking context, while ModalPortal escapes to document.body and
wins there.
Redesign the overlay z-index into a compact scale (no four-digit
values), each layer clearing the z-99 page frame:
100 RSVP overlay
101 RSVP controls (start dialog, lookup chip)
110 Settings dialog
120 modal / command palette
130 toast / alert
200 app lock
Lock the ordering with a static test that reads the values from source
and would have caught the #3235 regression. Documented in DESIGN.md.
Verified on a Xiaomi device via CDP: elementFromPoint at the dialog
center now resolves inside the Add Catalog form instead of Settings.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(koplugin): repair reading-stats sync push/pull
Reading statistics (statistics.sqlite3 page events) never reached the
Readest sync server. Three stacked defects in the koplugin stats path:
1. push/pull called settings:readSetting/saveSetting on self.settings,
which is the plain readest_sync data table (not a LuaSettings object),
so auto-sync crashed on every book open
("attempt to call method 'readSetting' (a nil value)").
2. pushChanges declares books/notes/configs as required_params, but the
stats push sent only statBooks/statPages, so Spore rejected the request
client-side ("books is required for method pushChanges").
3. statBooks/statPages were listed only under the spec's payload, not as
optional_params. Spore's expected-param set is
required_params + optional_params, so it rejected them too
("statBooks is not expected for method pushChanges").
Fixes:
- Read/persist the cursor as a plain field and save the whole table via
G_reader_settings:saveSetting, mirroring readest_syncauth.
- Send empty books/notes/configs alongside statBooks/statPages; the server
defaults each to [] and processes the stat arrays independently.
- Declare statBooks/statPages as optional_params in readest-sync-api.json.
Also add ReadestStats debug logging across pushBookStats/pullBookStats and
push/pull (cursor, collected counts, dispatch, response status, cursor
advance), and surface the push failure status/body that was previously
swallowed on non-interactive syncs.
Tests: cover push/pull cursor handling, the pushChanges required_params
contract, and a spec-level check that every stats payload key is an
expected pushChanges param.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(koplugin): add interactive Push/Pull stats now menu items
Add "Push stats now" and "Pull stats now" entries to the Readest sync
menu, placed after "Readest library" and before "Push books now" and
gated on being signed in (access_token + user_id) like the other
account-level items. They call pushBookStats(true) / pullBookStats(true)
for a manual interactive sync of reading statistics (the whole
statistics.sqlite3 delta), complementing the automatic pull-on-open /
push-on-close.
Interactive push/pull now also confirm their result (pushed / pulled /
up to date) to match the sibling "now" items, since a silent success
would look like a no-op.
Extract the five new strings into the koplugin .po catalogs (empty
msgstr, English fallback at runtime).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(koplugin): translate reading-stats sync strings (33 locales)
Fill the previously empty msgstr entries for the reading-statistics sync
strings across all koplugin locale catalogs: the two new "Push/Pull stats
now" menu items, the pushed / pulled / up-to-date confirmations, and the
push/pull failure messages. Each locale mirrors its existing
Push/Pull/Failed-to verbs and "reading" terminology for consistency.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds store/ with a reproducible generator for the Web Store listing
screenshots: composites the popup capture onto an on-brand (readest.com)
background with a headline, renders via headless Chromium, and writes 24-bit
no-alpha PNGs at 1280x800 and 640x400 into store/out/ (gitignored).
- store/generate.mjs - config + compositing + render (Playwright + ImageMagick)
- store/popup.png - raw popup capture (700x508); its status strip is blanked
and re-lettered from CONFIG so it stays in sync with code
- store/README.md - usage + requirements
- package.json - adds `pnpm store:screenshots`
- .gitignore - ignores store/out/
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the user-facing popup strings em-dash-free and tighten the success copy
(which now matches the Chrome Web Store screenshots).
Source strings:
- "Sent — it will appear in your library shortly." -> "Saved to your library."
- "Sent — N images could not be fetched." -> "Sent. N images could not be fetched."
- "Could not reach {host} — {reason}" -> "Could not reach {host}: {reason}"
Key-as-content i18n means each English change re-keys the string. All 33 locale
bundles were re-keyed and every affected translation refreshed to match the new
wording, with no em dashes in the translated values either (language-appropriate
punctuation; full-width colon/period for CJK). Extractor reports 0 untranslated,
0 orphan. Updated popup.test.ts and the i18n.ts JSDoc example.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prep for the Chrome Web Store submission of the Send to Readest browser
extension:
- Bump manifest + package version 0.2.0 -> 0.2.1.
- Add a `pnpm zip` script (scripts/zip.mjs) that builds and packages dist/
into a versioned, Web-Store-ready archive: manifest.json at the zip root,
excluding webpack's `.LICENSE.txt` banners and `.DS_Store`. Ignore the
produced `*.zip` in the extension's .gitignore.
- Add STORE-SUBMISSION.md: copy-paste dashboard answers (single purpose,
per-permission justifications, remote-code answer, data-use disclosures,
listing copy) pointing at the canonical privacy policy hosted at
https://www.readest.com/send-to-readest/privacy-policy.
Verified: pnpm test:extension (51 passed), pnpm build-browser-ext, pnpm zip
(valid archive), pnpm lint clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Chinese chapter-detection regex treated certain measure words (the
classifiers for "letter" and "book") as chapter units and let a title
attach directly after the unit. As a result, ordinary prose such as
"the first letter" or "the fourth book records..." was split out as
bogus TOC entries when importing Chinese TXT novels.
Split the unit characters into two explicit tiers that share the same
number prefix and stay in one alternation (a single split pass, so a
segment mixing chapter and volume headings is handled together):
- Chapter units may carry a title attached directly, unchanged.
- Volume/measure-word units only start a heading when the title is
introduced by a separator (colon, comma, space, or parentheses) or
the line ends, never a bare noun directly after the unit.
Real volume and chapter headings still match. Adds regex-level and
end-to-end tests covering both reported cases.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cherry-picked and re-verified the applicable subset of
julianshen/readest@fa1b74a0 (its "address PR #15 bot reviews" commit). The
fork-only AI-annotation-tool change was dropped — readest has no 'ai' toolbar
tool. Each logic fix is covered by a failing-first test.
- TTS position sequence is now an app-wide monotonic counter, so a fresh
TTSController (constructed per `tts-speak`) isn't dropped by consumers holding
`lastSequenceSeen` from a prior session.
- share.ts only swallows AbortError (user cancel); other failures — e.g.
NotAllowedError when a quick action fires without a user gesture — fall back to
the clipboard so the text still reaches the user.
- document.isTxt tolerates MIME params (text/plain;charset=utf-8), uppercase
extensions (BOOK.TXT), and a nameless Blob, so a TXT can't slip onto the
non-text path and yield a null book.
- updater getNightlyPlatformKey matches x86_64/aarch64 explicitly; a 32-bit or
otherwise unknown arch yields no nightly instead of mis-routing to aarch64.
- UpdaterWindow downloadWithProgress resolves on tauriDownload completion even
when Content-Length is absent (no more hang on portable/AppImage/Android).
- nightly_update.rs uses async tokio::fs::read in the async command.
- nightly.yml: serialize runs via a concurrency group (no cancel) and
persist-credentials:false on checkouts.
- edge TTS route only emits the word-boundary header when it fits under ~8KB;
oversized values get dropped by proxies, and the client falls back to [].
- RSVPOverlay drops the contradictory aria-disabled on the functional rate
button (it opens the pace picker).
- nightly verify harness handles artifact stream errors instead of crashing.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switching into Paragraph or RSVP mode while TTS is already playing now
syncs to the live session instead of forcing a stop + restart inside the
mode. Bundles several related TTS fixes uncovered along the way.
Session reuse (enter from normal mode):
- TTSController.redispatchPosition() re-emits the current position on the
canonical tts-position signal with a fresh sequence.
- useTTSControl answers a new tts-sync-request by replaying the current
position then playback state (position-first so RSVP's paused handler
can't discard it).
- Paragraph & RSVP engage following on entry and dispatch the request;
no-op when no session exists.
RSVP refinements:
- Reusing a session skips the start dialog and the get-ready countdown
(starts externally driven); gated on a live tts-playback-state signal
so the countdown can't flash.
- Stopping TTS now pauses RSVP instead of resuming its own pacing.
Word-sync fixes:
- rangeTextExcludingInert honours the range offsets inside a single text
node, fixing word-highlight drift on middle sentences of single-<span>
paragraphs (Edge word highlighting).
- foliate-js TTS.from() starts at the sentence containing the selection,
not the next one (submodule bump).
- Selecting a word and starting TTS now clears the selection.
- Dev-only [TTS] word-sync trace (stripped from production builds).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(sync): design spec for syncing reading status (#4634)
Field-level LWW for reading_status (dedicated reading_status_updated_at),
a new 'abandoned' status in the Readest UI, and a koplugin bridge to
KOReader's native summary.status (whole-library apply + capture).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(sync): implementation plan for syncing reading status (#4634)
Bite-sized TDD tasks across 3 parts: A) cloud field-level LWW
(reading_status_updated_at on server upsert + client pull-merge),
B) 'abandoned'/On-hold status in the Readest UI, C) koplugin bridge
to KOReader summary.status (mapping + reconcile + whole-library
apply/capture).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): add reading_status_updated_at for field-level status LWW (#4634)
* feat(sync): stamp readingStatusUpdatedAt on status change in updateBookProgress
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): stamp status timestamp on explicit library status edits
* feat(sync): resolve reading status by its own timestamp in client pull-merge
* feat(sync): resolve reading status by its own timestamp in server upsert (#4634)
* fix(sync): tighten reading-status merge typing + strengthen test (A5 review)
Replace as-unknown-as double-casts at read sites with typed locals
(clientBook/serverBook); retain a single as-unknown-as only at the
server-wins construction site where the static type is too narrow.
Strengthen test 3 to assert both fields with toEqual.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(library): render the 'On hold' (abandoned) status badge
* feat(library): add 'Mark as On hold' actions + i18n for abandoned status
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* i18n: translate 'On hold' and 'Mark as On hold' for the abandoned status (#4634)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(koplugin): add reading-status mapping + reconcile between Readest and KOReader
* feat(koplugin): persist + sync reading_status_updated_at in LibraryStore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(koplugin): bridge reading status to KOReader summary.status on library sync (#4634)
* test(sync): cover koplugin v1->v2 migration + tighten status-sync tests (final review)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(sync): redesign KOReader first-sync (decisive-only + bootstrap) (#4634)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(koplugin): safe first-sync of reading status (decisive-only + bootstrap) (#4634)
KOReader auto-sets summary.status='reading' on first open, and legacy Readest
statuses have reading_status_updated_at=0, so pure timestamp LWW let opening a
finished book downgrade it. Restrict sync to deliberate statuses (finished/
complete, abandoned/on-hold, unread->clear); never capture KO 'reading'/'New'.
On the unsynced baseline (Readest ts=0) conflicts resolve Readest-authoritative,
then stamp now_ms to exit bootstrap into steady-state LWW. reconcile now returns
write_ko/write_store flags; statussync captures now_ms once and equalizes both
sides (convergent, idempotent, resumable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(koplugin): cover remaining first-sync graph cells + document sort effect (review)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the informational Data pack hints ("Open a book…" / "No data available…")
from the inline trailing slot into the row description so they wrap under the
label instead of stretching the row wide on mobile. Drop the size from the
Download button label and surface it as the row description (matching the
"Downloaded · size" state). Add a "CEFR level" description under the Level row.
Includes translations for the three new i18n keys across all locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): count only uploaded, non-deleted books in synced toast
The "N book(s) synced" toast shown on pull-to-refresh and the
last-synced menu counted every non-deleted book record a pull
returned, including books indexed in the cloud as metadata only
whose file blob was never uploaded. Those books are never added to
the library (updateLibrary requires uploadedAt && !deletedAt), so
the toast over-reported.
Extract the count into a pure countSyncedRecords(type, records)
helper that excludes deleted records and, for books only, requires
uploaded_at — matching what actually lands in the library.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(memory): record agent notes for recent fixes
Persisted project-memory docs accumulated alongside recently merged
work (biometric app-lock, download-file scope regression, inline-block
column overflow, iOS instant-dict double popup, RSVP RTL words, web
security advisories) plus MEMORY.md/share-feature index updates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Series sort comparator compared only seriesIndex, ignoring the series
name. When used as the within-group order for "group by Author → sort by
Series", this ranked every book #1 across all series as a block, then every
#2, etc., scattering each series instead of keeping it consecutive.
Compare series name first, then index, so all books of one series appear
together in series order before the next series begins.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#4639 added a strict `app.fs_scope().is_allowed()` check to download_file/
upload_file. On Android that returns false for the app's own storage, so every
download into the app dir (book covers, dictionaries, books, gloss packs, OPDS
books in the cache dir) failed with "permission denied: path not in filesystem
scope".
Root cause: download_file/upload_file are plain app commands using raw tokio::fs,
so Tauri does not scope file_path. The capability scope patterns that cover the
app's storage ($APPDATA/Readest/**, $APPCACHE/**, **/Readest/**/*) are
command-scoped and absent from the global fs_scope() FsExt exposes (it is
initialized FsScope::default() and only ever gains runtime dialog/persisted-scope
grants), so is_allowed() returns false for the app's own files.
Interim fix mirroring dir_scanner::read_dir: keep rejecting relative and `..`
paths, then accept the path if the fs scope allows it (persisted dialog grants
for custom/external roots) OR it lives inside the app's own storage — matched by
the `Readest` data folder or the app's bundle identifier (app.config().
identifier), which the Android sandbox (/data/user/0/<id>/…, cache dir included)
and the desktop identifier dirs always carry. The `..` rejection keeps the
GHSA-55vr-pvq5-6fmg hardening: foreign targets like ~/.ssh/id_rsa carry neither
segment and stay blocked.
Follow-up (tracked separately): replace the substring fallback with a
BaseDirectory + relative path resolved via app.path(), so targets are in-scope by
construction with no string markers.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FB2 stores series info as `<sequence name="…" number="…"/>` inside
`<title-info>`, but the foliate-js FB2 parser never read it, so
`belongsTo.series` was always empty and the series name/index never
surfaced in the library or book details. Refresh-metadata and
re-import didn't help since they share the same parser path.
Bumps the foliate-js submodule to pull in the `<sequence>` parsing
(readest/foliate-js#28) and adds a regression test + fixture.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The RSVP word window split each word into before/orp/after spans at the
ORP index and laid them out left-to-right. Slicing an Arabic/Hebrew word
by character index breaks letter shaping (letters stop connecting, some
slices render as notdef boxes) and the LTR layout reverses the visual
order, so e.g. علم showed as disconnected, out-of-order letters.
Detect RTL text and render the word as a single centered span — reusing
the existing CJK Highlight Word path — with dir="rtl" so the browser
shapes and orders it correctly, matching the context panel. ORP anchoring
is meaningless for unsplittable shaped scripts, so RTL always renders
whole; no new toggle.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A highlight and its note are now a single BookNote. Adding a note attaches
it to the highlight at that CFI (or creates one with the current global
style) instead of creating a second record, and a unified record renders as
both a highlight overlay and a note bubble.
- onDrawAnnotation chooses the draw kind from the overlay value prefix
(cfi -> highlight, NOTE_PREFIX -> bubble) instead of annotation.note, so a
record with both a style and a note draws both. Fixes notes synced from
KOReader losing their highlight (#4511).
- handleSaveNote updates the existing annotation at the CFI rather than
pushing a new record (#3870); re-styling preserves the note.
- unifyAnnotations migration (book config schema v1 -> v2, run in
deserializeConfig) collapses existing split highlight+note records into one
survivor and tombstones the redundant record (deletedAt) so the merge syncs
to the cloud and KOReader.
- Sidebar: a note's quoted highlight text uses the theme foreground so it
stays legible on the highlight background.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>