forked from akai/readest
main
2338 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4af203755d | release: version 0.11.18 (#5003) | ||
|
|
883dae36aa |
fix(opds): auth negotiation and auto-download fixes for self-hosted catalogs (#5002)
* fix(opds): allow LAN catalogs through the proxy in development The SSRF host blocklist added in #4638 unconditionally rejected private addresses, so the dev proxy returned 400 for LAN OPDS catalogs even though next dev runs on the developer's own machine where reaching the LAN is the normal use case. Skip the blocklist when NODE_ENV is development, matching the existing CatalogManager gate that only forbids LAN URLs in production. Production and test behavior are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(opds): negotiate Digest auth when the server rejects preemptive Basic with 400 Calibre's content server in digest mode, or auto mode over http, answers a Basic Authorization header with 400 Unsupported authentication method instead of a 401 challenge. The preemptive Basic header introduced in #4206 therefore dead-ended the request, since the auth retry only fired on 401 or 403, and digest catalogs failed with Failed to load OPDS feed: 400 Bad Request on every platform. When a request that carried preemptive Basic comes back 400, re-issue it once without credentials to surface the WWW-Authenticate challenge and let the existing negotiation pick the scheme the server actually wants. Verified end to end against a live Calibre server on web and Android. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(opds): skip SSL verification in auto-download like the manual download path The native download_file command validates TLS with rustls, which ignores the OS trust store, so downloads from self-signed or private-CA OPDS servers fail in the TLS handshake before any request reaches the server. The manual download path has passed skipSslVerification since #2900 as the workaround for #2871, but the auto-download path never did, so subscribed shelves failed to sync while feed browsing and manual downloads of the same books worked. Pass the same flag in downloadAndImport. Closes #4988 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0c24aad606 | fix(reader): let page margins shrink into the safe-area inset (#4761) (#5001) | ||
|
|
a8d3411203 |
fix(reader): gate captured slide/curl turn on scrollLocked like push (#5000)
Instant Highlight engages after a 300ms still-hold on text and locks scrolling (renderer.scrollLocked) so the finger extends the highlight instead of turning the page. The push paginator honors that lock in its native swipe, but slide and page curl run through the app-side captured turn: applyPageTurnAttributes sets no-swipe, so the native swipe bows out and the captured-turn touch interceptor drives the turn instead. That interceptor started a page turn on any horizontal swipe without checking the lock, so a hold-then-swipe paginated with the slide/curl effect instead of extending the highlight. Gate the interceptor on renderer.scrollLocked before it begins a drag, mirroring the native swipe. Bump the foliate-js submodule (readest/foliate-js#51) to expose scrollLocked via a getter (it was write-only) and add a regression test. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f8ad47a418 |
feat(reader): Auto Scroll reading mode for scrolled flow (#4998) (#4999)
Teleprompter-style continuous scrolling toggled from the View menu (Shift+A), available only in scrolled mode. A PacedScroller drives whole-pixel forward steps at a constant, user-adjustable velocity; the speed (25-500 percent, persisted as autoScrollSpeed) is tuned from a floating control pill that also offers pause/resume and exit, and fades away while scrolling to keep the mode immersive. Tapping the page pauses and resumes instead of turning pages or toggling the bars; manual wheel or drag input simply composes with the paced scrolling. Escape or leaving scrolled mode ends the session. When forward progress stalls the session hops to the next section (single-section scroll mode) or stops with a toast at the end of the book. Vertical-writing books scroll along the horizontal axis with the sign convention foliate uses for scrolled offsets. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
17de9357dd |
feat(reader): redesign the TTS control as a mini player with an expandable player sheet (#4996)
* feat(reader): add formatCountdown helper for TTS timer chips Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): extract shared TTS playback info hook Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): add TTS scrubber with buffer-ahead fill Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): add TTS speed preset chips Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): add persistent TTS mini player Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): add full TTS player sheet with voice and timer sub-views Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(reader): cover player sheet view reset on reopen Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): replace TTS icon and popup with mini player and player sheet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): reserve mini player clearance and retire showTTSBar setting Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retire shipped TTS follow-ups from TODOS Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(i18n): translate the TTS player strings Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): pin mini player transport LTR and unmount the closed player sheet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): collapse sheet speed, voice, and timer controls into one row Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): stabilize timeline estimates with cumulative voice calibration Replace the per-sentence EMA with the cumulative ratio of all measured chars to all measured seconds per voice, so the estimated remainder converges instead of re-pricing on every quirky sentence. Legacy stored calibrations migrate as a small prior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): hide the mini player while the player sheet is open Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tts): clear stale highlights across sections and stop sentence flash in word mode Entering a section now scrubs the TTS highlight from every live view, not just the primary, so the outgoing section's last word no longer stays lit in the preloaded neighbor. reapplyCurrentHighlight no longer redraws the whole sentence during word-mode playback while awaiting the first word boundary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(reader): move the mini player progress line to the bottom edge Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): make the TTS progress bar and scrubber legible in eink mode Grey tints wash out on e-ink: the mini player track gets a 1px hairline with a solid base-content fill (buffer fill hidden), and the sheet scrubber gets a crisp 1px border marking the track extent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(reader): drop the player sheet header label on the main view Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ab628fe258 |
fix(android): background TTS media controls + lock-screen scrubber/seek + Edge click fix (#4994)
* fix(android): keep background TTS media controls alive when backgrounded The media-session update commands pushed metadata and playback state to MediaPlaybackService via Context.startService(), which Android 8+ rejects with "app is in background" once the app leaves the foreground. Every per-sentence update then failed, so the lock-screen control went stale and the foreground service lost the notification refresh that keeps it alive, dropping background playback. Deliver updates to the running service in-process (the pattern the existing requestDeactivation() already uses) so they can never trigger a service start. Also request POST_NOTIFICATIONS whenever the session activates, not only when alwaysInForeground is set, so the foreground-service media notification (the lock-screen control) is not suppressed on Android 13+. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(android): show section duration and enable seek on the TTS media session The Edge/WebAudio TTS engine already reports an estimated section timeline (position and duration) to the media session on every sentence, but the Android service never surfaced it: the lock screen had no scrubber and no seek. Set METADATA_KEY_DURATION so the scrubber shows its length, add ACTION_SEEK_TO, and handle onSeekTo by handing the target back to the JS controller (seekToTime) through the existing media-session-seek event. Playback-state updates that only flip play/pause omit position and duration, so the service now preserves the last known values instead of resetting the scrubber to 0. Native TextToSpeech has no timeline (duration stays 0), so the scrubber simply does not appear there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(android): harden TTS foreground-service promotion + add diagnostics On MIUI the media service was reclaimed on idle ("Stopping service due to app idle"), which means startForeground never promoted it to a real foreground service. Promote with ServiceCompat and an explicit FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK type (robust on targetSdk 34+) and log any failure instead of swallowing it. Decouple the POST_NOTIFICATIONS request from the service start in setActive: a throwing or hung permission request no longer aborts set_media_session_active, so the foreground service always starts. Add trace logging (set_media_session_active, activateSession, startForeground) to pinpoint the promotion path on-device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(android): drop required keepAppInForeground so the TTS service starts The set_media_session_active payload required keep_app_in_foreground, but the media bridge activates with just { active: true }. Tauri rejected the invoke at the serde layer ("missing field keepAppInForeground") before the command ran, so the foreground service never started: no media notification, and Android 15 audio hardening then muted background playback. The flag was dead everywhere: no platform read it (the foreground service always starts and the POST_NOTIFICATIONS request is now unconditional). Remove it from the Rust, Kotlin, iOS, and TS payloads. Effective behavior is always-foreground. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(tts): fade sentence-buffer edges to stop Edge TTS clicks The silence trim (findSpeechBounds) cuts at an amplitude threshold, not a zero crossing, so each Edge TTS sentence buffer begins and ends on a non-zero sample. An AudioBufferSourceNode steps straight from/to silence at its edges, and that discontinuity clicks/pops between sentences (WSOLA is not involved: it is a no-op at rate 1.0 and cross-fades its internal splices). Apply a ~3ms linear fade to each buffer's own copy after WSOLA so the edges ramp to zero. The trim and the controlled inter-sentence gap are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(android): remove the dead "Background Read Aloud" setting alwaysInForeground only drove the removed keepAppInForeground flag; nothing reads it now (the foreground service always starts, and POST_NOTIFICATIONS is requested at play time). Remove the setting, its default, the Android library-menu toggle (which just requested the notification permission), and prune the now-unused i18n key from all locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
3ce5a5c8e3 |
fix(reader): center the lone PDF page in portrait auto-spread (#4984) (#4992)
In auto-spread mode a portrait viewport shows a single page of the two-page spread, but the fixed-layout renderer kept the spread-centering one-sided inline margin on the shown page. With no partner page to meet at the spine, that margin stranded the lone page in one half of the viewport whenever it was narrower than the viewport (any zoom below 100%, or a page whose fit-scaled width is less than the viewport width), which also pushed the page over a page-turn tap zone so every tap turned the page instead of opening the menu. Bump the foliate-js submodule (readest/foliate-js#50) to center the lone portrait page and add a regression test for computeSpreadInlineMargins. Fixes #4984 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
56abcb4a6c |
feat(sync): S3-compatible cloud sync provider (#4990)
* feat(sync): S3-compatible cloud sync provider with premium-gated chooser Add a third file-sync backend for any SigV4 object store (Cloudflare R2, AWS S3, MinIO, Backblaze B2), end to end: SigV4 transport via aws4fetch (path-style addressing, ListObjectsV2 with page draining, per-key deletes, presigned streaming on Tauri, Drive-style error mapping and backoff), S3 settings slice and defaults, exclusive provider activation and cross-window flag broadcast, registry memoization, and an Integrations chooser entry plus connect form that validates the bucket with one signed listing. Shared helpers settingsKeyForBackend and cloudProviderDisplayName replace the scattered per-kind ternaries across the reader and library sync hooks, fleet detection, and the settings surfaces. The chooser now marks third-party providers with a Premium badge and enforces the paywall (CLOUD_SYNC_REQUIRES_PREMIUM on): free plans see the rows but route to the upgrade page instead of the config sub-pages, and a downgraded account's still-selected provider is paused rather than silently falling back to Readest Cloud uploads. Manual provider sync now reports "N book(s) synced" like the native cloud sync, from the engine result returned by runActiveFileLibrarySync. The S3 transport passes the same provider semantic contract as WebDAV and Google Drive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(sync): make the library row the ground truth for local file presence Every sync run re-walked all books whose file is recorded nowhere and paid two plugin:fs|exists IPC per book per run on Tauri, just to relearn "no local source", ending in 0 books synced. The library row already tracks local presence reliably (import, download, and delete all stamp downloadedAt, and the metadata merge keeps it device-local), so the file-push gate now trusts the row: a book the row marks as absent costs zero filesystem and zero remote probes, keeping incremental sync a pure metadata diff at any library size. A session-scoped per-provider memo additionally suppresses re-probes of drifted rows (the row claims a file the filesystem no longer has), keyed to the book's updatedAt so any local change re-qualifies it. Row-vs-filesystem split-brain in either direction is healed by Full Sync, which bypasses the gate, the memo, and the uploaded-file record and audits the real filesystem. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sync): tiered request timeouts for the WebDAV client An unreachable or dead server (a LAN host that went away) left PROPFIND and HEAD requests pending indefinitely, pinning the Integrations panel on "Syncing..." and the browse pane on a spinner. Metadata round-trips (PROPFIND, HEAD, MKCOL, DELETE) answer with headers only, so they now abort after 5 seconds; GET and PUT carry book-sized bodies over possibly slow links and keep a 5 minute ceiling instead. Expiry aborts the request via AbortController and surfaces as a "Request timed out" NETWORK failure through the existing WebDAVRequestError taxonomy. Since every library sync run opens with the HEAD etag probe on library.json, a dead server now fails the whole run within seconds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): provider panel status and layout fixes Three small fixes across the provider settings panels: - A completed manual "Sync now" clears the provider's lastError so the Cloud Sync chooser row and the SettingsMenu sync row stop reading "Sync failed" after the server comes back; a failed manual run now records the error for those surfaces too. Covered by a render harness that drives the real form against a mocked engine. - The sync row shows a relative "Synced a few seconds ago" label (same wording as the SettingsMenu row) instead of an absolute timestamp. - The Google Drive configured-but-inactive state rendered its Tips above the action buttons; Tips now close the page in every provider panel state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(i18n): translate the S3 provider and premium gating strings New keys from the S3-compatible provider (form fields, chooser entry, tips), the Premium badge, and the parameterized provider tips, translated across all 33 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(memory): record the S3 provider and sync optimization notes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
600d69fa50 |
fix(reader): gate route View Transitions on API support (READEST-9) (#4989)
* fix(reader): gate route View Transitions on the API, turns on groups (READEST-9) Reverts #4949, which opened books through the plain router to dodge the "Transition was aborted because of timeout in DOM update" TimeoutError (Sentry READEST-9). Rather than carve the transition out of one flow, gate it at the router: useAppRouter routes through the View Transition router only where the engine has the View Transitions API, and every into-reader path (including the reverted ones) goes back through useAppRouter. The base View Transitions API and nested view-transition groups reach very different browsers, so they become two separate appService capability flags, each backed by a probe in utils/viewTransition: * supportsViewTransitionsAPI (document.startViewTransition): the baseline a route crossfade needs, landing on Chrome 111+, Safari 18+, recent WebView. Gates the router. * supportsViewTransitionGroup (view-transition-group: nearest, Chrome/WebView 140+): the far narrower target the paginator's layered turns require. Gates the turn-style options and the captured-turn fallback. Both flags fold in the Linux WebKitGTK carve-out because it crashes on the snapshot, matching the supportsCanvasContext2DFilter precedent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(tts): enlarge the Now Playing bar and scale its controls responsively Grow the collapsed bar to h-14 with a 10x10 cover and symmetric px-2 padding, drive the play/pause and close icons through useResponsiveSize instead of fixed pixel sizes, and cut the bottom safe-area contribution to a third so the bar sits closer to the screen edge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ccb937015d |
feat(sync): incremental file sync and per-book transfers for the active provider (#4982)
* fix(sync): record remote-present files for no-source books in the upload cursor With "Upload Book Files" on, a device that holds no local copy of a book (e.g. the web app with a cloud-only library) HEAD-probed the remote for every book on every sync: pushBookFile returned 'no-source' and the hash was never recorded in library.json's uploadedHashes, so needsFilePush stayed true for the whole library and each run (tab focus, Sync Now, library change) issued one Drive/WebDAV request per book, 646 requests per sweep in the reported case. The HEAD probe already answers whether the file is on the remote. Carry that in PushBookFileResult.remoteExists and record the hash when a no-source book's file is already mirrored, so the next incremental sync skips it and stays O(changed). Books absent both locally and remotely stay unrecorded so a device that has the bytes can upload them later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sync): reach the no-source verdict without probing the remote A book file sync with "Upload Book Files" on probed the remote for every book before checking whether this device even holds the bytes. On a device with a cloud-only library (the web app), none of the books have a local source, so every sync run (tab focus, Sync Now, library change) issued one name-lookup request per book against Google Drive, a full per-book request storm that never converged: with no local file there is nothing to upload and nothing to record, so the next run repeated it. Resolve the local source first and return 'no-source' from local state alone; the remote head probe now runs only when there is a local file to compare or upload. The probe keeps its non-NETWORK rethrow semantics so the auth-failure latch (#4981) still stops a run on an expired session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sync): incremental file sync and per-book transfers for the active provider Cut the redundant remote work a file-sync run does and route explicit per-book uploads/downloads to the active third-party provider (WebDAV / Google Drive) instead of the gated Readest Cloud transfer queue. Engine (services/sync/file/engine.ts, wire.ts): - etag change-probe: one HEAD on library.json, cached per provider for the session. When the etag matches the last successful pull, reuse the cached index and skip both the index download and the discovery scan. An AUTH failure on the probe aborts the run like the full pull does. - emptyDirs memo carried in the index: dirs found to hold no book file are recorded so clients stop re-listing them every run. Re-checked when the file arrives (uploadedHashes), on Full Sync, or when a legacy client drops the record; pruned only against a listing that actually ran. - skip the index re-push when the rebuilt index is semantically identical to the pulled one, so a restamped byte-copy no longer churns the remote and invalidates peers' etag change detection. - downloadBookFile() for the explicit per-book Download action. Provider reuse (services/sync/file/providerRegistry.ts): - memoise one provider per connection key, shared by every surface (reader per-book sync, library auto-sync, Sync now / pull to refresh). Reuses the Drive path->id cache instead of re-resolving /Readest, books/ and library.json by name query on every engine build. Drive connect/disconnect resets the cache since its token source changes identity with no key input changing. Google Drive (services/sync/providers/gdrive/GoogleDriveProvider.ts): - write fast-path: PATCH a path whose id is cached in place with no lookup, falling back to a full resolve on a 404 (stale id). Removes a files.list per PUT in the steady state. - dev-only request diagnostics: one line per provider op and per HTTP attempt so a run's request budget can be attributed from the console. Per-book transfers (services/sync/file/runLibrarySync.ts): - runActiveFileBookUpload / runActiveFileBookDownload build the active provider's engine and push/pull a single book, stamping downloadedAt like the native path. Wired into the reader/library book actions with toasts. UI, status, and i18n: - Readest Cloud sub-page: drop the quota stats and wrap the "Account and Storage" row in the BoxedList primitive so it aligns to the design system. - Shorten provider status/toast copy ("Active", "Google Drive session expired", "Library sync via {{provider}}", "KOReader") and translate the new keys across all locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bcd27b7047 |
chore(i18n): translate the cloud sync provider-selection strings (#4980)
Adds the 22 strings introduced by the provider-selection series (#4971, #4973, #4975, #4976) to all 33 locales with real translations, plus the per-locale CLDR plural forms for the quota batch-failure message and the hand-curated en _one/_other variants. Keys were appended without running the scanner's removeUnusedKeys pass, which would have pruned unrelated live translations; the diff is additions only. Terminology matches each locale's existing sync strings (library, reading progress, highlights, storage quota). Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3503b0234f |
fix(sync): abort the file-sync run on auth failure instead of marching the library (#4981)
With an expired Google Drive web session, syncLibrary swallowed the AUTH_FAILED from the index pull and proceeded with remoteIndex = null, which is indistinguishable from a first sync: every book looked unpushed, uploadedHashes was empty, and the engine attempted to upload the entire library (Uploading 16 / 682), logging one failure per book. Worse, a null index also skips the peers-tombstone union in the final index re-push, so a transient index-pull failure could have rewritten library.json and resurrected deleted books (#4860 class). - An unreadable index (throw) now aborts the run; an absent one (404 -> null) keeps first-sync semantics. - A terminal-failure latch stops the work pools on the first mid-run AUTH_FAILED (a token can expire mid-run), skips the index re-push, and rethrows so callers surface one re-auth error instead of a per-book failure list. Mirrors the existing deleteRemoteBookDir contract: auth failures rethrow, everything else aggregates. - On web, the auto library sync now skips while the Drive session is expired (hasValidWebDriveToken), matching the settings form's disabled Sync now with its Reconnect CTA. Tests: unreadable-index abort with zero writes, mid-run abort bounded to in-flight work with no index re-push, non-auth failures still non-aborting, absent-index first sync unchanged. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
db1d63cdcc |
test(reader): harden fixed-layout wheel double-scroll test against CI flake (#4978)
The readest#4727 regression test set scrollTop=0, dispatched a synthetic wheel, waited 60ms, then asserted scrollTop stayed 0. On slow CI runners it flaked with "expected 4 to be +0". As sibling scroll pages finish loading, the renderer runs restoreScrollModeAnchor asynchronously, which at scrollTop=0/page-index-0 snaps scrollTop to page 0's offsetTop, the 4px scroll-page-gap margin. The 60ms post-dispatch delay raced that re-anchoring, so the assertion observed 4 instead of 0. That 4 is unrelated to the wheel bug, which is a 120px jump. The buggy handler was scrollBy with instant behavior, a synchronous scroll that lands before dispatchEvent returns. Measure scrollTop synchronously before and after the dispatch with no await in between and assert they match. This isolates the wheel handler's own effect and is immune to the async re-anchoring. Reintroducing the bug still fails the test (before=4, after=124, a clean 120px delta). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
57868a138e |
feat(settings): unified Cloud Sync chooser with Readest Cloud as a first-class provider (#4976)
The Integrations section is now one Cloud Sync chooser: Readest Cloud,
WebDAV, and Google Drive as radio rows, Readest Cloud first, with a
scope subtitle stating what the choice governs (library data, on this
device) and what always stays with the Readest account (settings,
statistics, dictionaries).
- Activation helpers move to services (cloudSyncActivation.ts) and
accept 'readest' (= no third-party provider active); the component
module re-exports for existing imports.
- Row status lines come from a pure, fully-tested state matrix
(cloudSyncStatus.ts) so every string is enumerated in one place:
signed-out / loading / active / available for Readest Cloud;
not connected / configured / paused / syncing / sync failed /
book-file-uploads-off warning / active for third-party rows. The
paused state renders on the affected third-party row (the plan sketch
placed it on the Readest row; the provider that is paused is the
third-party one).
- The Readest Cloud row opens an inline sub-page (SubPageHeader + the
storage/translation Quota + a NavigationRow out to Account) instead of
ejecting from the Settings dialog; signed-out taps go to login and the
radio is unchecked and disabled, so an idle signed-out state never
shows a checked radio while nothing syncs.
- Premium-gated builds keep the Readest Cloud row and show the upgrade
prompt only in place of the third-party rows.
- Two-direction capability Tips in the WebDAV/Drive sub-pages spell out
both what syncs only to the user's server and what still flows through
the Readest account; the Upload Book Files description notes that
Readest Cloud uploads pause while the provider is selected.
- Manage Sync book/progress/note rows swap their description to
'Managed by {{provider}}...' via the existing locked-row pattern while
a third-party provider is selected; the toggles stay interactive and
persist since they govern the native channels after switch-back.
- The chooser rows form a radiogroup (native same-name radios provide
arrow-key group movement) with an accessible group label.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
e08622b416 |
feat(sync): route library sync exclusively to the selected cloud provider (#4380) (#4975)
While WebDAV or Google Drive is the selected cloud sync backend, the native Readest Cloud book/progress/note channels are gated off and the file-sync engine owns library data end to end: - isSyncCategoryEnabled returns false for book/progress/note (and their legacy aliases) when a third-party provider is selected. A runtime override, deliberately not written into syncCategories: the user's own toggles persist and take effect again on switch-back. Account channels (settings, stats, dictionaries, fonts, textures, OPDS catalogs) always stay native. - persistActiveCloudProvider is the single write path for provider switching, used by the chooser, both connect/disconnect flows, and the Drive OAuth callback (which previously bypassed the cross-window broadcast). The broadcast carries ONLY the enabled flags plus providerSelectedAt, never credentials or sync cursors, and only on switch events, so a stale window's routine save cannot revert a switch. - buildWebDAVConnectSettings no longer pre-sets enabled: activation belongs to withActiveCloudProvider, so the fresh-connect path now gets the syncBooks auto-flip and the providerSelectedAt stamp. - Sync health: fileSyncStore records lastError per backend; the durable lastSyncedAt stays in provider settings. The SettingsMenu sync row reads Synced via provider / Sync failed and its tap (with pull to refresh and BackupWindow, all routed through pullLibrary) runs the file engine via the shared runActiveFileLibrarySync helper instead of a gated native pull that would toast undefined book(s) synced. - Mixed-fleet detection: while gated, the auto-sync interval runs a read-only probe of /api/sync since providerSelectedAt; any newer book row means another device still syncs natively, and a once-per-session notice explains the fork instead of leaving it silent. - Readest-Cloud-only affordances hide while a third-party provider is selected: the quota row becomes a caption naming the active provider, Auto Upload disappears from the menu and command palette, the BookItem upload badge and the Transfer Queue Upload All button hide. - providerSelectedAt added to both provider settings types and the backup blacklist. Stacked on the quota-decoupling change for #4959; requires the metadata-parity change so gated channels lose nothing users can see. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f805477091 |
perf(koplugin): defer and cache Library group covers (#4954) (#4974)
Opening the Library with a large grouped library was slow because each folder's 2x2 cover mosaic was recomposed from scratch on every paint (up to 4 MuPDF cover decodes plus scales per cell). On a 685-book library this dominated the synchronous open path (254ms of 300ms) and ran again on the post-sync refresh. Navigation felt fast only because drilling into a group shows single covers, not mosaics. Mirror cloud_covers' async pattern in group_covers: - Cache the composed master bb per group, keyed by a signature that flips when the child set or any child's cover availability changes; serve cheap copies on a hit. Cache a nil result too, so a group whose covers are not ready yet keeps its placeholder without recomposing on every refresh; a later cover download flips the signature and recomposes once. - Compose off the first-paint path: a miss enqueues a background job (one mosaic per UI tick) and returns nil so the cell paints its FakeCover placeholder immediately; finished mosaics coalesce into one refresh. - Free cached masters when the Library closes. Measured synchronous open path drops from 300ms to 151ms on a 685-book library; mosaic compositing moves off the blocking paint and fills in progressively. Adds open-path timing logs to librarywidget and localscanner for on-device diagnosis of future large-library reports. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a72f535346 |
feat(sync): propagate tags and reading status through third-party file sync (#4973)
The library.json index already carries full Book objects, but the metadata merge overlay dropped tags and readingStatus on apply, so tagging or marking a book Finished never reached peers syncing via WebDAV or Google Drive (the same overlay gap that hit group membership in #4942): - mergeBookMetadata carries tags with the metadata LWW subset (raw assignment, so tag removal clears on peers) and merges readingStatus on its own readingStatusUpdatedAt clock, the client-side mirror of the native field-level server merge. This survives the asymmetric race where one device edits metadata after a peer changes the status; whole-book LWW alone would drop the status change. - New shouldApplyRemoteBookMetadata reconciliation predicate triggers on either clock; the engine's index reconcile uses it so a status-only change propagates without a metadata edit. - Merge-law tests (direction, removal, idempotence, asymmetric races) plus engine-level propagation tests for both fields. Prerequisite for gating native sync when a third-party provider is selected (#4380): third-party sync should reach metadata parity before it becomes the only channel. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
942c062d35 |
fix(sync): decouple Readest Cloud storage quota from third-party cloud sync (#4959) (#4971)
When a third-party provider (WebDAV or Google Drive) is the selected cloud sync backend, Readest Cloud storage is no longer written to: - New src/services/sync/cloudSyncProvider.ts policy module: the selected provider is derived from the existing per-device enabled flags (webdav wins deterministically if both are ever set); the premium guard resolves to a PAUSED state instead of silently falling back to Readest Cloud, with the user plan cached for non-React modules. - transferManager gates book uploads on the selected provider: queueUpload returns null when gated; pending book uploads from before a provider switch are visibly cancelled (cancelReason policy) and pruned on the next restore; downloads and replica transfers are never gated. - Book uploads are deferred until settings hydrate (settings.version barrier) so a persisted queue cannot be mis-processed at startup; replica transfers are not stalled. - Quota-exceeded uploads fail fast with zero retries, and a batch import produces one summary toast instead of one toast per book. - Policy cancellations are a distinct bucket via a shared predicate: excluded from failed stats, Retry All, and the per-item Retry button. - Auto-upload call sites (ingest, OPDS, subscriptions) check the provider gate; the explicit Upload Book action explains the gate with a toast instead of silently doing nothing. - Activating a provider auto-enables its syncBooks so books keep backing up somewhere; a one-time migration (20260706) applies the same flip for users who already had a provider enabled. - webdav.deviceId and webdav.lastSyncedAt are excluded from backups, matching the existing googleDrive entries. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a02b236e97 |
fix: more production crashes (View Transition noise, book-dir race, stats transaction) (#4962)
* fix(sentry): drop more benign View Transition rejections Broaden is_ignored_browser_error to also drop "Transition was skipped" (navigation superseded, READEST-F) and "aborted because of invalid state" (READEST-G), matched case-insensitively alongside the existing hidden-tab case. These are expected browser behavior — the navigation completes, only the animation is skipped/aborted. A transition timeout stays visible (real perf signal, handled separately). Fixes READEST-F Fixes READEST-G Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(library): create the book directory idempotently Importing a book did a check-then-create with a non-recursive createDir. Two concurrent imports of the same book both pass the exists check, then the second create fails — on Windows with "Cannot create a file when that file already exists" (Sentry READEST-H). Use a recursive create (create_dir_all), a no-op when the directory already exists. Fixes READEST-H Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(stats): serialize applyRemoteEvents to avoid nested transactions The statistics connection is shared across ReadingStatsTracker instances (split view). applyRemoteEvents runs a manual BEGIN/COMMIT that the per-op native connection lock does not make atomic, so two concurrent pulls opened a BEGIN inside a BEGIN ("cannot start a transaction within a transaction", Sentry READEST-N). Serialize applyRemoteEvents against itself with a small promise mutex. Adds a regression test that fails without the guard. Fixes READEST-N Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f7f85330ae | chore(agent): update agent memories (#4958) | ||
|
|
4527aa277a |
feat(reader): add TTS speak button to dictionary popup (#4876) (#4957)
Add a speaker button to the dictionary popup/sheet header that pronounces the current headword. Tapping it speaks via Edge TTS, falling back to the platform speech engine (Web Speech on desktop/web, native on the mobile app) when Edge is unavailable. To speak as soon as possible, a dedicated wordPronouncer bypasses the reader's TTSController entirely: it never runs EdgeTTSClient.init() (which wastes a round trip synthesizing "test"), calls EdgeSpeechTTS directly (whose static MP3 cache makes repeat words instant), and schedules one chunk on a dedicated Web Audio context isolated from any active read-aloud session. The context is warmed synchronously inside the click gesture so playback is not blocked by autoplay policy after the network await. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6f3b401c24 |
feat(reader): middle mouse button autoscroll in scrolled mode (#4955)
* feat(reader): add middle mouse button autoscroll in scrolled mode Middle-clicking a book in scrolled mode on desktop apps plants an anchor indicator and scrolls with a velocity proportional to the pointer's distance from it, like browser autoscroll (#4951). A quick click sticks until the next click, wheel, or Escape; press-move-release scrolls only while held. Vertical-writing books autoscroll along the horizontal axis. The middle button's default is suppressed while the feature is armed so WebView2's native autoscroll cannot double-drive on Windows. A new Middle-Click Autoscroll toggle in the Scroll settings section (desktop only, default on) turns it off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(reader): drop the middle-click autoscroll toggle Middle-click autoscroll is a common desktop convention and middle click has no other use in the reader, so it is always enabled on desktop apps in scrolled mode instead of being a setting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(i18n): translate missing strings across all locales Fill in translations for 69 keys that landed on main without an i18n pass (search modes, cloud sync and Google Drive settings, page turn animation styles, TTS states, Word Lens hints, file browser sorting, watched-folder auto-import) in all 33 locales, plus the English plural variants for the search result count. Keys the scanner would prune (strings it cannot see statically on this branch) are left untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
da00a94f66 |
feat(sentry): tag events with the WebView engine and version (#4952)
Forwarded browser events carry os/rust/device context but no browser context, so crashes couldn't be correlated with the WebView version. The app now reports its User-Agent at startup via a set_webview_info command; the parsed engine (Chromium/WebKit) and major version are stored and attached as webview.engine and webview.version tags in before_send, covering both Rust panics and forwarded browser events. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
52be6fa066 |
fix(reader): open books without a View Transition to avoid timeout (#4949)
useAppRouter wraps every navigation in a View Transition. Opening a book is a heavy render (the reader mounts and loads the book) that can overrun the transition's ~4s DOM-update budget and abort with a TimeoutError (Sentry READEST-9). Navigate to the reader with the plain router instead, matching every other into-reader path in the app; the transition router stays for lighter navigation. Applies to the tap-to-open flow (useOpenBook) and the post-import queued open (library/page). Fixes READEST-9 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
75f1fafe9f |
feat(reader): slide and page curl turn animations (#555) (#4940)
* feat(reader): slide and page curl turn animations (#555) Add an Animation Style setting (Push, Slide, Page Curl) next to the Paging Animation switch. Slide moves the turning page over the still previous or next page like the Apple Books slide; Page Curl folds it open in 3D so the page underneath is partially visible as it turns. Both styles track the finger: the page follows a horizontal drag and commits past halfway or on a flick, or settles back. The page header and footer stay in place while the page turns. The styles layer a View Transitions snapshot of the outgoing page over the live, stationary incoming page, since the pages of one section live in a single iframe and can never be on screen twice. They work for all writing modes including vertical-rl, and on engines without the View Transitions API (older WebViews) the paginator falls back to the existing push animation, so all platforms keep working page turns. The paginator changes live in the foliate-js submodule; this bumps the pointer, wires viewSettings.pageTurnStyle to the renderer turn-style attribute, and adds browser tests covering slide layering, curl, vertical-rl, finger tracking with commit and revert, and the push fallback. Fixes #555 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): add WebGL page curl renderer for mesh turn animations (#555) Grid mesh deformed around a cylinder: content past the fold wraps over and lands mirrored on top with a whitened page back, transparent where the page has curled away. Corner grabs start as a steep diagonal pinch that straightens as the turn completes so the whole page clears by the end. Groundwork for the Tauri mesh curl; capture and orchestration land separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(native-bridge): capture webview region as PNG on macOS and iOS (#555) New capture_webview_region plugin command returns a binary PNG snapshot of the calling webview (tauri::ipc::Response, no JSON overhead) for the mesh page-curl texture. macOS goes through WKWebView takeSnapshotWithConfiguration via with_webview on the main thread with a 500ms timeout; iOS snapshots in Swift and hands the PNG across the JSON-only plugin boundary base64-encoded, decoded back to bytes in mobile.rs. Windows, Linux, and Android reject for now so the JS side falls back to the CSS curl. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): drive the mesh page curl on Tauri platforms (#555) Wire the WebGL curl renderer and the native webview capture into page turns. A MeshCurlTurn controller runs the pipeline per turn: snapshot the content box, overlay the captured page drawn flat, turn the live view instantly underneath (the paginator's animated paths all gate on the animated attribute), then curl the capture away. Backward turns mirror the fold to the spine edge, matching the layered VT curl's old-page-recedes choreography. useMeshPageCurl wraps the view's prev/next so taps, keys, and wheel turns all curl, and registers a touch interceptor (between the reading ruler and the fixed-layout swipe) that scrubs the curl from the finger, committing past halfway or on a flick and otherwise un-curling and turning back under the overlay. The paginator stays out of the way via no-swipe while the mesh is active; if the native capture ever fails the session falls back to the paginator's CSS arc-fold curl and the shared applyPageTurnAttributes helper restores turn-style. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(build): restore iOS builds on Xcode 26.2 with a vendored swift-rs Swift 6.2's driver no longer honors swift-rs 1.0.7's cross-compilation style (swift build --arch <host> with per-swiftc -target overrides and an inherited SDKROOT): plugin sources compile against the wrong platform's Swift overlays and fail with baffling errors like type 'Bundle' has no member 'main' and extra argument 'privacy' in call. Upstream swift-rs is unmaintained, so vendor it under packages/swift-rs via a crates-io patch and build with SPM's first-class --triple/--sdk flags instead, dropping the leaked SDKROOT so the host-targeted manifest compile stays clean. Artifacts land in the unversioned-triple directory now, so the link search path follows. With --triple, SPM enforces the deployment floor declared in Package.swift (the old override bypassed it): bump native-bridge to iOS 15.0, matching the app's deployment target, since StoreKit's Storefront is used unguarded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(native-bridge): capture webview region on Android via PixelCopy (#555) Implements the Android side of capture_webview_region so the mesh page curl works there too. The Kotlin command scales the CSS-pixel rect by the display density, offsets it by the webview's window position, and reads the pixels back from the window surface with PixelCopy (API 26+, the app's minSdk), which includes the hardware-accelerated WebView that View.draw would miss. PNG encoding runs off the main thread and the result crosses the JSON plugin boundary base64-encoded, decoded back to bytes in mobile.rs like iOS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): right the upside-down page curl on iOS (#555) The renderer oriented its texture with UNPACK_FLIP_Y_WEBGL, which WebKit ignores for ImageBitmap uploads: on iOS the captured page rendered upside down, and the mirrored page back read as rotated 180 degrees instead of the ink-through-paper horizontal mirror Apple Books shows. Upload unflipped and sample page coordinates directly so no pixel-store flag is involved. The page texture in the browser test was only horizontally asymmetric, which is how the flip slipped through; it now uses four quadrants fed through the production PNG-blob-to-ImageBitmap path and pins the vertical orientation. Verified red/green by running the suite on Playwright WebKit, which reproduces the iOS behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): curl the whole page including header, footer, and margins (#555) The mesh curl captured only the margin-inset content box, leaving the running header, footer, and page margins static while just the text column turned. A physical page turn takes the whole sheet with it, as Apple Books does, so the capture and overlay now span the full reader cell. The overlay mounts above the in-cell header (z-10) and footer, so the static copies never show through the turning page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): gate layered View Transition turns and slide from a capture instead (#555) iOS 18 WebKit ships document.startViewTransition but crashes the WebContent process when a page-turn transition snapshots the reader, so the mere presence of the API is not enough for the layered slide/curl turns. Require nested view-transition groups (Chrome/WebView 140+) as the marker of a mature engine before setting turn-style on the renderer. Engines that fail the check no longer lose the slide on Tauri: the mesh curl's capture pipeline generalizes to CapturedPageTurn and now also drives a flat slide overlay (capture the outgoing page, turn instantly underneath, translate the captured page out toward the spine, mirrored for backward turns), clipped to the content box with an edge shadow like the VT slide. On the web, engines without full support fall back to push and the Slide/Page Curl options are hidden from the Animation Style select; a synced slide/curl setting from another device reads as Push there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): make the Android page curl start instantly (#555) The Android capture encoded a full-density PNG: 1080x2400 on a 3x Xiaomi 13 took ~1.5s per turn, so the page sat frozen long enough to read as the curl not working at all. Encode JPEG instead (the page is opaque) and cap the destination bitmap at 2x CSS pixels - PixelCopy scales into a smaller bitmap for free and the moving page stays sharp. Measured on device over CDP: the capture invoke drops from 1550ms to 34ms and the curl overlay mounts 132ms after the tap. The JS side stops hardcoding an image/png blob type and lets the decoder sniff the platform's actual format. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(reader): encode iOS page-curl captures as capped JPEG (#555) Apply the Android speedup to iOS: encode the snapshot as JPEG (the page is opaque) off the main thread, and cap it at 2x CSS pixels via WKSnapshotConfiguration.snapshotWidth on 3x screens, cutting both the encode time and the base64 payload crossing the JSON plugin boundary. The JS side already sniffs the image format from the bytes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9321c2cd39 |
fix(widget): round iOS cover thumbnail size to whole pixels (#4950)
The iOS reading widget downsampled covers to a fractional target size. UIGraphicsImageRenderer allocates a whole-pixel buffer while draw(in:) fills only the exact fractional rect, so for portrait covers whose scaled width rounds up the rightmost pixel column was left partially covered and semi-transparent. Encoded to JPEG that column flattened into a visible bright hairline along the right edge (intermittent, portrait covers only). Round both target dimensions to whole pixels so the draw rect matches the pixel buffer and every edge pixel is fully covered. Android is unaffected because it scales to a fixed 240x360 and center-crops. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e7f0b53bdf |
fix(opds): crawl subdirectories when auto-downloading directory-style catalogs (#4948)
Copyparty and other file servers expose each folder as an OPDS feed where subfolders are rel="subsection" navigation entries. Auto-download only followed the catalog's "by newest" feed or the subscribed feed itself, so books in subfolders were never discovered, and a folder containing only subfolders was skipped entirely. When a catalog has no "by newest" feed, treat it as a directory-style listing and crawl its subsection navigation entries breadth-first, bounded by MAX_CRAWL_DEPTH levels, MAX_FEEDS_PER_CRAWL fetches, and the visited set. Library catalogs with a "by newest" feed keep the previous behavior and are never crawled. Facet and structural rels (self, up, start, top, search) are excluded so the crawl cannot escape the subscribed folder. Fixes #4272 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3f4d4b8643 |
fix(transfer): persist queue when clearing completed/failed/all (#4947)
Clear Completed, Clear Failed and Clear All mutated the Zustand store directly, so the cleared items were never written back to localStorage. On the next load the persisted queue restored them and they reappeared in the Transfer Queue panel. Route these clears through transferManager (like clearPending already does) so each calls persistQueue() after mutating the store. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9202864846 |
fix: real fix for library-save storage-permission crash + narrowed view-transition filter (#4943)
* fix(library): request storage permission when saving to a custom folder On Android a custom library folder on shared storage needs All Files Access. The import/settings/migrate paths request it, but the library-save path (updateBook/updateBooks -> saveLibraryBooks) did not, so on a device without the permission every save failed with EACCES; because callers (sync, imports) don't await/catch it, it surfaced as an unhandled-rejection crash (Sentry READEST-A). saveLibraryBooks now catches a storage-permission error, requests the permission through the existing flow, and retries once. It prompts at most once per session so background saves don't repeatedly open system settings; a still-denied save is logged rather than crashing (the user was already shown the All Files Access screen). Fixes READEST-A Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sentry): drop only the benign hidden-tab View Transition error The View Transition API skips a transition when the tab is hidden; that unhandled rejection is expected browser behavior and pure noise, so it is dropped in before_send. A transition timeout abort (READEST-9) is NOT dropped: a slow DOM update can signal a real performance problem, so it stays visible. Fixes READEST-7 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2963e75bdd |
fix(sync): propagate group membership for already-synced books (#4946)
Group membership synced only for newly-imported books. Re-grouping a book already present on both devices bumped book.updatedAt and won the library-index LWW race, but mergeBookMetadata dropped groupId/groupName from the overlay, so the change never reached peers. New books instead arrive via addBookToLibrary with the full remote object, which is why their group did travel. Carry groupId/groupName in mergeBookMetadata, matching native cloud sync (transform.ts maps group_id/group_name). Values are assigned raw so a group removal also propagates. Fixes #4942 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ec45a080fc |
feat(metadata): surface calibre custom columns from EPUB metadata (#4939)
Parse calibre's embedded user metadata (custom columns) from the OPF in foliate-js, store it on BookMetadata.calibreColumns, render the columns in the book details view, and match column names and values in the library search so a value like a recommends tag can be found by typing it. Closes #4811 |
||
|
|
0b180da6a6 |
fix(koplugin): key library pull cursor on synced_at to stop stale library (#4934) (#4944)
The KOReader plugin's incremental books pull went permanently stale: after a while it stopped receiving any updates made from other devices, and only deleting readest_library.sqlite3 + "Pull books now" recovered it (until it re-broke). The iOS/web library was unaffected. Root cause: since #4678 the server keys the books pull on the server-stamped synced_at column, and the web client (computeMaxTimestamp) advances its cursor from synced_at. The koplugin was left on updated_at: pullBooks advanced last_books_pulled_at from max(updated_at, deleted_at). updated_at is client-supplied, and the koplugin bumps it from the device clock (touchBook = os.time()*1000). An e-reader clock ahead of the server (or any row anywhere carrying a future updated_at) drove the cursor past server-now, so the server's synced_at > since filter returned nothing forever. The cursor was also shared between the pull side (compared vs server synced_at) and push-delta detection (getChangedBooks vs local updated_at), so it could not simply be retargeted. Fix: - parseSyncRow reads synced_at; new row_pull_cursor() prefers it and falls back to max(updated_at, deleted_at) for a pre-synced_at server, mirroring computeMaxTimestamp. - Split the cursor: last_books_pulled_at now tracks server synced_at (pull only); new last_books_pushed_at tracks local updated_at for getChangedBooks and is advanced on both pull and push, preserving push dedup. - v2 -> v3 migration seeds the push watermark from the old shared value and resets the pull cursor to 0, auto-healing already-stale installs with one full re-pull (no manual sqlite deletion) and no re-push storm. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
843ab3448b |
feat(tts): keep TTS playing when the book is closed (#4941)
* refactor(tts): controller owns its foliate TTS instance and emits lifecycle events view.close() nulls view.tts, so the controller keeps its own handle (mirrored to view.tts while attached; reads prefer the public mirror). state becomes an accessor that dispatches tts-state-change on a microtask, and terminal conditions (end of content, error exhaustion) fire an explicit tts-session-ended: 'stopped' is a transit value that occurs on every paragraph advance and must never be read as death. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): make TTSController detachable from the reader view detachView enters headless mode: layout-dependent work is guarded, the dead hook's preprocess/section-change closures are severed, and text supply continues through created documents while position events keep flowing. attachView adopts a new view without touching in-flight audio, re-seeding the fresh text instance from the old cursor AT the synchronous swap point (auto-advance during async prep would otherwise replay a paragraph) and aborting via an attach epoch when a detach supersedes it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): move media session ownership to a session-scoped bridge ttsMediaBridge binds directly to the controller (metadata per mark, clamped position state, transport handlers, the silent keep-alive element) so the lock screen keeps working when the reader hook is unmounted. The hook's last-writer-wins handler effect and its per-render re-registration are gone; the panel now derives isPlaying from the controller's state channel, so lock-screen transport keeps the in-reader UI truthful. useTTSMediaSession had no consumers left and is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): add hash-keyed TTS session manager with sleep timer and headless persistence Sessions key by book hash (bookKey is regenerated per open), the playback-state relay dedupes transit stopped values so paragraph advances never flicker followers, and terminal handling rides the explicit tts-session-ended event. The sleep timer survives reader unmount, and headless positions persist through the book config on disk (view/progress stores are cleared on close and reopen loads from disk). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): keep TTS playing across book close and reattach on reopen Back-to-library and Android back dispatch tts-close-book (detach when the session is not terminated: transit stopped states during chapter transitions must not kill it); quit and window-destroying closes keep the hard tts-stop so the foreground service tears down with the webview. The unmount cleanup transfers ownership to the manager instead of shutting down, covering deep-link book switches and split-view pane closes. Mounting a book adopts a matching background session once the view is ready (primary pane only) and stops a different book's session unless it is still mounted elsewhere. The sleep timer moves to the manager and a one-time toast announces the first background continuation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): now-playing bar in the library for background sessions Floating pill above the shelf while a TTS session outlives its reader: cover, title, sleep-timer countdown, play/pause following the manager-relayed playback channel, and a hard stop. Tapping the body reopens the book in the SAME window regardless of the new-window preference, since the session is a per-webview singleton. Deleting the playing book stops the session before its data is cleared. The bookshelf reserves scroll clearance via a --now-playing-inset var the bar sets while visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tts): make the header close button background-eligible The header X routes through handleCloseBook (onCloseBook), not handleCloseBooksToLibrary, so the sticky eligibility ref never got set and closing a book from the header hard-stopped a live TTS session. Replace the ref with an explicit keepTTSAlive parameter on saveConfigAndCloseBook/handleCloseBooks: back-to-library, Android back, and pane closes pass true; beforeunload, quit-app, and window close invoke handleCloseBooks with an event object, which coerces to a hard stop. This also removes the stickiness where one background close would have made a later quit detach instead of stop. Verified live in Chrome dev-web: close from the header keeps audio playing with the now-playing bar shown; reopening reattaches the same session (generation numbering continues); opening a different book stops it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
42f9b8fe3c |
feat(tts): gapless Web Audio playback engine for Edge TTS with chapter timeline and seek (#4931)
* feat(tts): add PCM speech-bounds detection for sentence audio trimming Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): add WSOLA time-stretch for pitch-preserved playback rate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): add sentence duration store with per-voice speaking-rate calibration Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(tts): serve edge audio as ArrayBuffer with in-flight fetch dedup Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): add WebAudioPlayer with gapless chunk scheduling and backpressure Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): play edge TTS through gapless Web Audio pipeline Replaces the per-sentence audio element with trimmed, time-stretched buffers scheduled on the shared AudioContext. Marks dispatch at audible time so schedule-ahead cannot run foliate's cursor past the voice; a decode failure or missing audio skips the chunk instead of wedging the session; pause and resume ride context suspend and resume with no iOS rewind hack; the object-URL cache is gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): add section timeline with measured and estimated sentence durations Includes the foliate-js submodule bump for the getSentences export (fork branch feat/tts-get-sentences; fork PR must merge before this lands so the pinned SHA resolves). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): expose section playback position and sentence-snapped seeking Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): surface playback position and seek in the media session Position state is clamped, never skipped, so the lock-screen scrubber stays live when estimates overshoot; seekto units map per backend (native ms, web seconds). The AudioContext warms up in the tts-speak gesture path before any await, and the silent keep-alive element now runs on all platforms so desktop hardware media keys survive the removal of the per-sentence audio element. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): add seekable chapter progress bar to the TTS panel The scrubber joins the transport cluster with a thin range-xs track and flanking tabular time labels so it cannot be misgrabbed for the chunky rate slider (which persists a global setting). States: reserved disabled slot until the lazy timeline lands, persists across chapter transitions, optimistic thumb with failure toast, monotonic position, tilde-prefixed estimated totals, sentence-event updates under e-ink. The popup grows only when a timeline-capable client is active. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: record deferred TTS listening-engine follow-ups Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: record background TTS decoupling design decisions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): slim the panel scrubber to a native track with remaining time Match the footer Jump to Location slider (plain native range: thin track, small thumb) instead of the chunky daisyUI pill, show remaining time with a minus prefix on the right, and drop the This chapter caption. Popup height shrinks accordingly. Verified live in Chrome. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: pin foliate-js to merged main with getSentences export Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tts): catch autoplay rejection from the keep-alive element Running the silent keep-alive on all platforms exposed an un-awaited play() that headless Chromium rejects without a user gesture, failing CI on unhandled rejections while every test passed. The keep-alive is best-effort; the production path is gesture-qualified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4dbe9cc9f1 | fix: Sentry production hardening (release/OS tags, unhandled-rejection & render-loop guards) (#4929) | ||
|
|
5301020a02 |
feat(koplugin): pull sync on device wake with book open (#4928)
Waking the device with a book already open now pulls progress, annotations and stats like reopening the book does, instead of requiring a manual sync or book reopen. The pull is delayed 1s so Wi-Fi can come back up after wake (same delay upstream kosync uses on resume), debounced against rapid Suspend/Resume pairs (Android fires them on focus changes), and the pending task is dropped on widget close so it cannot run against a torn-down ReaderUI. Closes #4924 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6013341cb8 |
fix(turso): bump plugin submodule to serialize connection operations (#4927)
Advances the tauri-plugin-turso submodule to include readest/tauri-plugin-turso#2, which serializes execute/select/batch on a single turso connection behind an async mutex. Fixes the "concurrent use forbidden" crash seen in production (unhandled promise rejection in the reader on Android): turso rejects overlapping operations on one connection, and the plugin previously drove a shared connection from concurrent Tauri commands with no serialization. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6b403d019e |
feat(calibre): add Readest calibre plugin to push books and metadata (#4918)
* feat(calibre): add Readest calibre plugin to push books and metadata (#4863) Add apps/readest-calibre-plugin, a calibre GUI plugin that uploads selected books with their metadata into the user's Readest cloud library, modeled on BookFusion's open-source plugin. - Selective manual push from the calibre toolbar with per-book status (uploaded / updated / up to date / failed) and quota handling - Books are content-addressed with the same partial MD5 as the apps, so re-pushing updates the existing entry instead of duplicating; metadata edits re-push without re-uploading the file - Metadata mapping includes series, tags, identifiers and optional calibre custom columns; carries over server-side fields (progress, reading status, grouping, cover) that POST /sync would null out - Auth mirrors readest.koplugin and the desktop app: email/password plus browser OAuth (Google/Apple/GitHub/Discord) through a localhost callback server with the fragment-to-query relay - Pure-logic modules (api.py, wire.py, oauth.py) are calibre-free and covered by 56 unit tests (make test); make zip builds the plugin Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(release): package readest-calibre-plugin in releases Mirror the KOReader plugin packaging: a build-calibre-plugin job stamps PLUGIN_VERSION in __init__.py with the release version from apps/readest-app/package.json, builds the zip via make, and uploads Readest-<version>.calibre-plugin.zip to the GitHub release. The version committed in git stays a development placeholder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(agent): add calibre plugin project memory Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(calibre): embed metadata in OPF and dedupe by calibre uuid Rework book identity so metadata can be embedded into the uploaded file without creating duplicates, as requested in review: - Embed calibre metadata (including custom columns) into a temporary copy of the book file at upload time via calibre's set_metadata; the library file is never modified - Dedupe by the calibre book uuid carried in the entry's metadata identifier, which survives file-byte changes, with a live-row preference when both hash and uuid match rows - Detect file content changes via calibreSourceHash, the raw library file fingerprint stored in the pushed metadata, so detection works from any machine; v1 rows fall back to book_hash which equals the raw hash for them - A changed file now replaces the old entry in one sync push (new row with carried-over reading status, grouping, progress and created date, plus a tombstone for the old row) and deletes the old cloud files to reclaim quota - Metadata-only edits still update the library entry without re-uploading the file Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(calibre): set copyright holder to Bilingify LLC Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c86decc2c1 | fix(test): make Android double-tap e2e pass on default-config CI devices (#4921) | ||
|
|
1d3dfd395f |
fix(ios): stop share extension hijacking shared .txt files (#4917)
The iOS share extension is a web-article URL clipper, but its activation rule enabled NSExtensionActivationSupportsText. A .txt file is public.plain-text (conforms to public.text), so that key made the URL-only extension activate for plain-text files it cannot handle: the share sheet hung instead of the file taking the main app's CFBundleDocumentTypes "Copy to Readest" import path, which handles txt fine (like EPUB and PDF, which never matched the extension). Drop NSExtensionActivationSupportsText so the extension activates only for web URLs. Shared .txt files now route to the working document-open import path; sharing a web page URL from Safari or Chrome still works. Add a regression guard asserting project.yml (the xcodegen source of truth for the generated, skip-worktree Info.plist) never re-enables text activation. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
967a7833ca |
feat(sentry): add crash reporting for Android, iOS, desktop, and web (#4914)
Report crashes and unhandled errors from every layer to one Sentry project via a single build-time SENTRY_DSN (empty => disabled, so local and fork builds do not report): - JS/WebView + Rust panics via tauri-plugin-sentry (rustls transport; minidump handler desktop-only). - Android native (JVM/NDK/ANR) via sentry-android 8.47.0 with manifest auto-init (excludes the discontinued lifecycle-common-java8 transitive). - iOS native via sentry-cocoa started from a tracked SentrySupport/+load bootstrap that reads the DSN from a Rust readest_sentry_dsn() FFI (no generated-file edits). - SENTRY_DSN resolved at build time from the environment, then .env.local, then .env (build.rs bakes it via cargo:rustc-env; build.gradle.kts for Android). CI passes the SENTRY_DSN secret through the existing .env.local step. Crashes + errors only: traces sample rate 0, no session replay, no PII. Symbolication (source maps / ProGuard / dSYM upload) is a follow-up. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6391bfe788 |
feat(settings): redesign theme mode toggle as a segmented control (#4831) (#4913)
The three theme-mode toggles were small btn-circle btn-sm icons spaced by gap-4, so on mobile they were hard to hit and easy to mis-tap. Replace them with a segmented control: an ARIA radiogroup of three adjacent radio segments sharing one track. Each segment is a full-height tap target (min 44px wide, 36px tall) with no dead space between them, and the active segment gets the app's canonical base-300 fill. In e-ink mode the active segment uses a solid eink-inverted fill instead of a nested border, so it stays legible without a second border clashing with the track outline. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
745f28f346 |
fix(reader): distinguish two-finger scroll from pinch-zoom on touchscreens (#4858) (#4912)
On touchscreen laptops (e.g. Surface), scrolling a fixed-layout book webtoon-style with two fingers moving the same direction accidentally triggered pinch-zoom. The old code committed to a pinch on the first two-finger touch and applied the raw distance ratio from the first move, so a slightly non-parallel scroll drifted the finger spacing and zoomed. Defer the decision with a pending state: on two fingers, compare the change in finger separation against the midpoint travel. A pinch changes separation while the midpoint stays put; a scroll moves the midpoint while separation barely shifts. Zoom only engages once separation change crosses a 24px deadzone and outweighs the pan distance; a 12px pan locks the gesture as a scroll and lets the page scroll natively. On pinch confirm, re-baseline the distance so zoom starts at 1x with no snap. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2b524439bf |
fix(reader): keep running header/footer readable over light PDFs in dark mode (#4901) (#4911)
The running section title and page-number footer used text-neutral-content, which is a light color in dark mode. A light-mode PDF stays white under a dark theme (invertImgColorInDark defaults to false), so the light text sat on the white page and became unreadable. Blend the header/footer text against whatever is behind it using mix-blend-mode: difference with a fixed white/75 anchor, so it inverts to dark on a light page and stays light on a dark margin. white/75 matches the former neutral-content brightness over the dark theme, so reflowable books look unchanged. E-ink keeps its plain base-content text; StatusInfo and the sticky progress bar manage their own colors and are left untouched. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8c91ad411c |
fix(reader): open annotation deep link when a different book is open (#4887) (#4910)
An annotation deep link (readest://book/{hash}/annotation/{id}?cfi=...) for a
book that is not the one currently shown in the reader was ignored: the reader
stayed on the open book. It only worked from the library page.
Two causes, both in the reader-mounted path:
- useOpenAnnotationLink fell through to navigateToReader when the target book
had no live view. router.push to the same /reader route does not re-run the
reader's one-shot init effect, so it was a no-op and the book never changed.
Route it through the in-place switch event (open-book-in-reader) carrying the
cfi, mirroring useOpenBookLink.
- The "already open, jump in place" check scanned all viewStates, which keep
stale entries for books switched away from (their views are detached from the
DOM, never cleared on switch). Switching A -> B -> A matched the stale A view
and called goTo on a dead view. Scope the check to the currently displayed
bookKeys instead.
useBooksManager.openBookInReader now accepts an optional cfi and jumps to it
once the switched-in view is ready (marking it a preview so the saved position
is not overwritten).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
c8e2c95335 |
feat(library): auto-import new books from watched folders (#3889) (#4902)
* feat(library): add autoImportFromFolders setting (default off) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(library): add selectNewImportableFiles folder-scan filter Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(library): add useAutoImportFolders trigger hook Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(library): auto-import new books from watched folders on open and focus Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(library): add Auto Import New Books from Folders toggle Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(library): don't resurrect deleted books or re-toast bad files on folder auto-import - Add collectKnownSourcePaths() pure helper that includes soft-deleted books - importBooks() accepts { silent } option and returns failedPaths - autoImportFromWatchedFolders uses collectKnownSourcePaths and session-scoped autoImportFailedPathsRef to skip already-failed files on subsequent scans Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(library): make folder auto-import a per-folder option in the import dialog Replace the global autoImportFromFolders toggle (and its standalone Settings menu entry) with a per-folder opt-in shown as a sub-option of Read in place in the Import-from-Folder dialog. Store the watched set as settings.autoImportFolders (a subset of externalLibraryFolders; device-local, backup-blacklisted). The library rescan now iterates autoImportFolders instead of a global-gated externalLibraryFolders. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
71cb3ace91 |
feat(android): Android Auto media support for TTS playback (#3919) (#4907)
Readest now shows up in the Android Auto launcher as a media app and projects the TTS media session so playback can be controlled from the car display (play/pause, previous/next sentence). - Declare the com.google.android.gms.car.application meta-data and the automotive_app_desc media capability that Android Auto requires to list the app - Make MediaPlaybackService safe to bind for browsing: audio focus, the silent keep-alive player, and the foreground notification no longer start in onCreate but on an explicit ACTIVATE_SESSION command, so a car client connecting to browse does not steal audio focus or post a phantom playing notification - Deactivate the session with an in-process call instead of stopService, which would neither run onDestroy nor clear the foreground state while a media browser keeps the service bound - Serve the current book as a playable browse item (cover downscaled to stay under the binder transaction limit) and handle onPlayFromMediaId/onPlayFromSearch - Honor the foreground service contract when MediaButtonReceiver cold-starts the service with no active session Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4b2c5f93ab |
fix(window): keep Linux window opaque so it can't turn invisible (#3682) (#4904)
On Linux the window was created fully transparent to draw rounded corners (#1982), but on WebKitGTK a transparent window composites as transparent whenever its web process is too busy to repaint damaged regions (for example during a library backup). Interacting with the app then makes it appear to turn invisible, showing the desktop through the window. Make the window opaque everywhere: the main window in lib.rs and the reader/extra windows in nav.ts. Drop the rounded-window treatment (hasRoundedWindow=false) so no rounded 1px border floats on the now square opaque window, and give the Linux loading placeholders a solid background. An opaque window retains its last painted frame instead of going invisible; the tradeoff is square corners on Linux. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
84c5a9dae6 |
fix(window): enter fullscreen from maximized windows (#4034) (#4903)
The fullscreen toggle had an isMaximized branch (from #872) that called unmaximize() and never setFullscreen() when the window was maximized. Phosh windows are always maximized, so the button appeared to do nothing; on Windows it only worked when the window was not maximized. Toggle fullscreen unconditionally. The maximize handler already exits fullscreen first, so the two controls stay consistent. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |