forked from akai/readest
70bad93ebf73e80e23a8add427feef9d97dce5f4
1366 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
70bad93ebf |
feat(reader): select word on double-click and run instant action or toolbar (#4846)
Double-click (mouse) or touch double-tap on a word now selects that word, like a long-press selection, then runs the configured instant quick action or raises the annotation toolbar when none is set. The iframe posted iframe-double-click but nothing consumed it, so a touch double-tap did nothing (Android has no native double-tap word-select; on desktop the browser already selects the word natively via the pointerup path). - sel.ts: getWordRangeAt expands a caret to its word-like segment via Intl.Segmenter (CJK and Latin); getWordRangeFromPoint resolves the caret at a point and delegates. - useTextSelector: handleDoubleClick selects the word and routes through the existing makeSelection flow (guarded so the programmatic selectionchange echo is ignored). It no-ops when a native selection already exists, so the desktop double-click path is not double-fired. - Annotator: consume iframe-double-click, resolve the visible section doc/index, and set pointerDownTimeRef to 0 so the deliberate double-tap bypasses the touch long-press hold gate before the instant action fires. Tests: unit coverage for the word-range helpers and the selection routing (plus the desktop guard), and an Android CDP e2e for the double-tap gesture on a real device. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
eaf307e71e |
fix(translate): align RTL translated text to the start (#4844)
Inline translation wrappers set lang but never dir, so RTL target languages (Arabic, Hebrew, Persian, etc.) inherited the source document's LTR base direction. Justified text then pushed its last line to the LTR start (left) instead of the RTL start (right). Derive the wrapper's dir from the target language via getDirFromLanguage so justified RTL translations align to the start. Extract the node construction into createTranslationTargetNode to make the behavior unit-testable. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b87cbfa21a |
feat(sync): Google Drive on web via full-page redirect OAuth (#4843)
Brings the Google Drive provider to the web build. Native uses PKCE + a reverse-DNS redirect + keychain refresh token; none of that works in a browser, and the GIS popup token model is broken by the app's COOP `same-origin` header (needed for Turso's SharedArrayBuffer) which severs the popup's opener handle and fires `popup_closed` instantly. So web uses a full-page redirect, which doesn't rely on `window.opener` and works under COOP. - auth/webRedirectFlow.ts: builds the implicit (response_type=token) auth URL, begins the redirect (CSRF state + return path in sessionStorage), and parses the token from the callback fragment. Implicit flow because a secretless Web client can't do a code exchange. - auth/webTokenStore.ts: sessionStorage-backed access-token store (no refresh token in this model; the token is short-lived). - WebDriveAuth: browser DriveAuth — reads the stored token, fails AUTH_FAILED once expired (prompts a reconnect; no background refresh), accountLabel via about.get. - app/gdrive-callback: OAuth return route — validates state, stores the token, marks Drive the active cloud provider (+ account label), routes back. - buildGoogleDriveProvider: web branch builds the provider on WebDriveAuth + globalThis.fetch (Drive REST is CORS-enabled; streaming stays Tauri-only so web buffers). Official Web client id baked (NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID overrides). googleDriveConnect web Connect = redirect; Disconnect clears the token. Drive row shown on web. No background token refresh: a secretless browser client gets no refresh token and Google blocks hidden-iframe silent renewal, so the user reconnects per session (a server-side token broker would be needed for auto-refresh; out of scope). Tests cover the redirect helpers, token store, and WebDriveAuth. Ops: add `https://web.readest.com/gdrive-callback` + `http://localhost:3000/gdrive-callback` to the Web client's Authorized redirect URIs. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7da41a65ad |
feat(widget): add mobile home-screen reading widgets (#1602) (#4842)
Add a resizable home-screen widget on iOS and Android showing recent
in-progress books with cover, reading progress, and tap-to-open.
- One responsive widget: Android resizable 1x1 to 4x3 (one book per
column, up to 3); iOS Small/Medium/Large families. Covers are cropped,
rounded, with a percent badge and a progress bar (baked into the bitmap
on Android, SwiftUI overlays on iOS).
- TTS controls (previous, play-pause, next) appear in 2+ row sizes when
TTS is active, wired to the existing media session. Reading progress
stays live during background TTS via a fraction computed from the baked
offline locations.
- Publishes a snapshot plus downsized cover thumbnails to the iOS App
Group and Android SharedPreferences through a new update_reading_widget
native-bridge command; refresh is debounced and driven by library and
progress changes, TTS, and app backgrounding.
- Tapping a cover opens readest://book/{hash}, switching the reader in
place when one is already open.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
5f44c95592 |
feat(sync): library-scoped auto-sync for third-party cloud (WebDAV / Drive) (#4835)
Parity with native useBooksSync: keep library.json current on import, delete, and book-close, not just on a manual "Sync now". - useLibraryFileSync: new library-scoped hook (counterpart of useBooksSync), mounted once on the library page. Builds the active provider's engine async and runs engine.syncLibrary on every library change (import adds a row, delete sets deletedAt, closing a book bumps updatedAt), debounced 5s and gated on the global file-sync mutex + Sync Strategy + Upload Book Files. The reader's per-book useFileSync is unchanged (it's the per-book progress sync). - Pass the FULL library (incl. soft-deleted books) to engine.syncLibrary, in both the new hook and the manual FileSyncForm "Sync now": the engine tombstones deleted books in library.json so deletions propagate, and keeping them in the input set stops the discovery pass from re-downloading a book the user just deleted (its remote hash dir lingers until the GC sweep). - Tests: engine tombstones a soft-deleted book in the pushed index and does not re-download one whose remote dir still exists. Gated only by the active provider's enabled flag + strategy (cloud sync is currently ungated from premium). Never runs before the library loads from disk, so it can't push an empty index over the remote. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4d08b01b41 |
feat(library): add recently read shelf to the library (#3797) (#4829)
Add an opt-in "Recently read" carousel at the top of the library that shows the most recently read books for quick resume. The strip reuses the BookItem component and mirrors the bookshelf grid column widths, so covers render and align identically at any column count. It scrolls horizontally with arrow buttons, opens a book through a shared availability-aware path (downloads cloud-only synced books first), and is toggled from the View menu (off by default). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d932444b78 |
fix(sync): cloud-sync settings polish + temporary premium ungate (#4828)
* fix(settings): clamp option-row description to a single line SettingsRow descriptions wrapped to multiple lines on narrow (mobile) widths, giving boxed-list rows uneven heights (e.g. "Uploads book files to your other devices." in the Cloud Sync panel). Clamp the description to one line with ellipsis in the shared primitive so every option row stays uniform; the description is a hint, not a paragraph (longer copy belongs in a Tips block). Codified in DESIGN.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n(settings): shorten sync strategy labels to "Send only" / "Receive only" Rename the Sync Strategy options (shared by the Cloud Sync and KOReader Sync forms). Keys renamed in every locale, preserving existing translations. * feat(sync): temporarily ungate third-party cloud sync from premium Cloud sync (WebDAV / Google Drive) ships available to every plan, incl. free, while the feature stabilises. Gated behind a single CLOUD_SYNC_REQUIRES_PREMIUM flag (off) via isCloudSyncAllowed; the paywall code (CLOUD_SYNC_PLANS / isCloudSyncInPlan) is intact, so re-gating in an upcoming release is a one-line flip. Applies to the Settings provider rows and the reader auto-sync gate. * fix(settings): polish cloud-sync connect buttons Use btn-contrast for the WebDAV and Google Drive Connect CTAs (theme-neutral, e-ink correct); rename "Connect Google Drive" to "Connect"; move the Google Drive sign-in tips below the Connect button. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ae9fb05f2c |
feat(sync): Google Drive sign-in on Android + iOS (mobile OAuth) (#4823)
* feat(sync): Google Drive sign-in on Android (Custom Tab OAuth) Add the Android OAuth runner so Drive can be connected on Android, reusing the same provider / token store / connect flow as desktop. - oauthAndroid.ts: runAndroidOAuth wires the DI OAuth flow to a Chrome Custom Tab via the existing authWithCustomTab native bridge (keeps the Tauri Activity foregrounded so the in-flight redirect survives). Headless-unit-tested. - googleDriveConnect: dispatch the platform runner by OS (Android -> Custom Tab, desktop -> system browser deep link). - IntegrationsPanel: show the Google Drive provider row on Android too. - Native (device-verification pending — no Android toolchain in CI): NativeBridgePlugin.kt handleIntent now also resolves the reverse-DNS com.googleusercontent.apps.<id>:/oauthredirect redirect through the same pending invoke as the Supabase callback; a matching BROWSABLE intent-filter added to AndroidManifest.xml (mirrors the tauri.conf.json deep-link scheme). Full suite 6475 green; lint + format clean. The native sign-in needs on-device Android verification before this ships. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): Google Drive sign-in on iOS (ASWebAuthenticationSession OAuth) Add the iOS OAuth runner so the Drive provider connects on iPhone/iPad, mirroring the Android Custom Tab flow. - oauthIos.ts: runIosOAuth drives the shared PKCE flow through authWithSafari, keyed to the client-id-derived reverse-DNS callback scheme so the web-auth session intercepts the redirect. - nativeAuth.ts: AuthRequest gains an optional callbackScheme; the Supabase login keeps the native "readest" default. - googleDriveConnect.ts: resolveOAuthRunner dispatches ios to runIosOAuth. - IntegrationsPanel.tsx: show the Google Drive cloud-sync row on iOS. Native (device-verify pending, no iOS toolchain in CI): - auth_with_safari honors args.callbackScheme (default "readest"). - Info-ios.plist registers the reverse-DNS scheme in CFBundleURLTypes, mirroring the AndroidManifest gdrive-oauth filter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
324bb8a366 |
feat(reader): add e-ink screen refresh page-turner action (#4687) (#4822)
Add a bindable "Refresh Page" action to Settings > Behavior > Page Turner that triggers a deep e-ink full refresh (GC16) to clear screen ghosting, gated to e-ink mode on Android. It reuses the existing hardware page-turner key-binding machinery: a new 'refresh' slot in HardwarePageTurnerSettings, shown only when isAndroidApp and the e-ink view setting is on. Pressing the bound key calls a new native bridge command instead of paginating. The native side is device-agnostic: EinkRefreshController probes each vendor mechanism via reflection and stops at the first that works, covering Onyx BOOX (Qualcomm View.refreshScreen), Tolino/Nook (NTX postInvalidateDelayed) and Boyue-style Rockchip (requestEpdMode) without bundling any vendor SDK. A success:false result is a soft no-op on non-e-ink hardware. iOS gets a stub. Verified on an Onyx BOOX Leaf5: the Onyx path fires and performs a visible full GC16 refresh. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7e78f80e14 |
feat(sync): Google Drive cloud sync + premium Third-party Cloud Sync section (desktop) (#4821)
* feat(sync): add Google Drive file-sync provider core Second FileSyncProvider for the merged provider-agnostic file-sync engine, behind the provider seam. This is the CI-testable core only: no settings UI and no platform OAuth runners yet (those land in later phases). - GoogleDriveProvider over the Drive v3 REST API: id-addressed path resolution with a per-instance id cache, create-then-name uploads, real idempotent ensureDir, files.list pagination, Retry-After-aware 429/5xx backoff, per-path folder-creation locks with deterministic duplicate collapse, stale-id eviction, and FileSyncError mapping (403 split into rate-limit vs permission). - DI OAuth layer: pkce, parseRedirect (redirect-target + CSRF state), reverseDnsRedirect, tokenStore (iOS client, no secret), oauthFlow. - PersistedDriveAuth with single-flight token refresh; keychain-backed token store with no ephemeral fallback for the refresh token; account label via about.get. - providerRegistry (backend kind to provider) and buildGoogleDriveProvider assembly. - Shared transport-agnostic provider semantic contract, run against both WebDAV and Drive. - Keyed secure-KV bridge contract (set/get/clear_secure_item); the native keychain implementation lands with the desktop OAuth slice that first exercises it. Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0) with the author's explicit permission. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): multi-provider file-sync settings + sync-state foundation PR2 foundation for a second file-sync backend (Google Drive). The behaviour-sensitive reader-hook and Sync-now form generalization land in PR3 alongside OAuth, where Drive actually connects and the multi-provider paths can be exercised and live-verified (and the extracted form gets its second consumer, avoiding a single-use abstraction). - GoogleDriveSettings type (mirrors WebDAVSettings minus URL/credentials/ rootPath, plus accountLabel) wired into SystemSettings, with DEFAULT_GOOGLE_DRIVE_SETTINGS in the defaults. - googleDrive.deviceId + googleDrive.lastSyncedAt added to the backup blacklist so device-local sync identity / cursors never restore onto another device. Covered by the existing backup-settings test. - Generalize webdavSyncStore into fileSyncStore: per-backend progress keyed by provider kind, plus a global library-sync mutex (beginSync returns false when another backend already holds the lock) since every backend's syncLibrary mutates the same local library. Migrate WebDAVForm and IntegrationsPanel to the keyed API; WebDAV behaviour is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(native-bridge): add keyed secure key-value store commands A generic, keyed secret store over the same OS keychain backends as the sync passphrase (set/get/clear_secure_item), so secrets that aren't the single sync passphrase get the same XSS-free cross-launch persistence without each needing its own native command. The Google Drive OAuth token store (PR1's KeychainTokenPersistence) is the first consumer; a future cloud provider's refresh token reuses it. - Desktop (macOS/Windows/Linux): keyring-core, keyed by the item key as the entry account under the existing "Readest Safe Storage" service. - Android: EncryptedSharedPreferences (a dedicated readest_secure_items_v1 file, the item key as the pref key). - iOS: Security framework Keychain (kSecClassGenericPassword, dedicated service, the item key as kSecAttrAccount). Registered in the plugin invoke handler + build COMMANDS + default permission set (autogenerated permission files regenerated; the passphrase entries are preserved). The TS bridge wrappers shipped in PR1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): desktop Google Drive OAuth runner + connect flow The desktop half of Drive sign-in: open consent in the system browser, capture the reverse-DNS redirect the OS routes back, and exchange the code for tokens. - oauthDesktop.ts: runDesktopDeepLinkOAuth wires the DI OAuth flow to the desktop mechanics (open default browser, capture via single-instance / onOpenUrl, cold-browser fallback after a grace period, hard deadline). Fully headless-unit-tested via injected deps. - spawn_fresh_browser.rs (+ registration, Windows-only winreg dep): the cold browser the runner falls back to when the user's already-running browser snapshotted protocol associations before the scheme was registered (a Windows-specific failure). Resolves the default browser from the registry and spawns it cold with an isolated --user-data-dir; a no-op on macOS/Linux where the default-browser open already routes the redirect. Pure helpers unit-tested. - connectGoogleDrive.ts: run the platform OAuth runner, persist the token (fail-loud — Drive is not reported connected if the refresh token does not save), and resolve the account label via about.get (best-effort). OAuth runner adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0) with the author's permission. Scheme registration + the ingress redirect filter + the Drive connect UI land in the following commits; live desktop verification follows once the official Google client id is provisioned. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): filter Google OAuth redirects out of the deep-link ingress The reverse-DNS OAuth redirect (com.googleusercontent.apps.<id>:/oauthredirect) is delivered through the same single-instance / onOpenUrl channels as book-file deep links. Without a filter the book-import consumer would treat the redirect URL as a file path to open. Drop it at the ingress source (useAppUrlIngress) before the app-incoming-url broadcast, so no consumer ever sees it; the Drive sign-in runner still captures it via its own listeners. isGoogleOAuthRedirectUrl matches the scheme prefix (not a specific client id), so it stays correct regardless of which client is baked into the build. Note: registering the scheme in tauri.conf.json (so the OS routes it back to the app) needs the official Google client id, which is a provisioning prerequisite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): bake the official Google Drive OAuth client id + redirect scheme Provisioned the Readest Google Cloud OAuth client (iOS application type, no secret, drive.file scope). Bake the client id as the default in getGoogleClientId (overridable via NEXT_PUBLIC_GOOGLE_CLIENT_ID for forkers, who must also regenerate the manifest schemes) and register the derived reverse-DNS redirect scheme com.googleusercontent.apps.<id> in tauri.conf.json (desktop + mobile deep-link) so the OS routes the OAuth redirect back to the app. The client id is a public client identifier, not a secret. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): Google Drive connect UI + shared FileSyncForm Make Drive usable from Settings, and extract the now-two-consumer sync controls. - FileSyncForm: the provider-agnostic sync controls (sub-toggles, conflict strategy, manual "Sync now" with progress + result toast), parameterised by backend kind and building the provider through the registry. Extracted from WebDAVForm now that a second consumer exists. WebDAVForm keeps its URL/credentials connect panel + browse pane and renders FileSyncForm for the sync section; behaviour is unchanged (WebDAV "Sync now" goes through the same provider via the registry). - GoogleDriveForm: an OAuth connect panel (Connect -> runGoogleDriveConnect -> store token in keychain -> "Connected as <email>"; Disconnect) + FileSyncForm. - googleDriveConnect.ts: assemble the env client id + keychain + desktop runner into connectGoogleDrive/disconnectGoogleDrive for the UI. - IntegrationsPanel: a "Google Drive" row + sub-page, shown only on desktop (mobile OAuth runners land in later phases). Reader-side auto-sync (generalizing useWebDAVSync) is a follow-up; manual "Sync now" already exercises the full Drive stack. Full suite 6412 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(settings): unified Third-party Cloud Sync section (exclusive provider) Group WebDAV + Google Drive into a new "Third-party Cloud Sync" section and make them mutually exclusive — only one cloud provider syncs the library at a time. - New unified "Cloud Sync" sub-page (CloudSyncForm): a provider picker (radio, the AIPanel mutually-exclusive pattern) on top, the shared FileSyncForm sync options below for whichever provider is active. Google Drive is offered only on desktop; on mobile the page is WebDAV only and the picker is hidden. - withActiveCloudProvider helper: enabling one provider disables the other in one save. Both panels' connect/activate paths use it. Unit-tested. - WebDAVForm / GoogleDriveForm refactored into embeddable panels (the unified page owns the header). Drive gains a "configured but inactive" state so switching back re-activates it without a fresh sign-in; explicit Disconnect clears the keychain token. - IntegrationsPanel: remove the two separate WebDAV / Google Drive rows from "Reading Sync" (now KOReader Sync / Readwise / Hardcover only); add the Third-party Cloud Sync section with one Cloud Sync row (status = active provider). Old webdav/gdrive deep-links route to the unified page. Also removes the temporary Drive concurrency probe (the upload already runs at the intended concurrency 4; the probe confirmed it). Full suite 6416 green; lint + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): auto-sync the active cloud provider while reading Generalize the reader sync hook from useWebDAVSync to useFileSync so the active third-party cloud provider (WebDAV OR Google Drive) syncs per-book while reading — pull-on-open, debounced push on progress/booknote changes, cover/file upload — not just via the manual "Sync now" in settings. Since the providers are mutually exclusive, the hook drives exactly the one enabled backend, built through the provider registry. The build is async (the Google Drive provider probes the OS keychain), so the engine lives in state and the pull-on-open waits for it; switching providers mid-session resets the per-book locks. The engine is keyed on connection-relevant settings so a lastSyncedAt write doesn't re-probe the keychain. deviceId / lastSyncedAt now write the active provider's settings slice; the auth-failed toast is provider-neutral; the per-book events are renamed *-file-sync. WebDAV reader-sync behaviour is unchanged. Full suite 6416 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(settings): surface cloud providers in the section with inline switch Show WebDAV + Google Drive as separate rows in the Third-party Cloud Sync section (instead of one "Cloud Sync" row), so both providers are visible and the active one can be switched right there. - CloudProviderRow: a trailing radio makes a provider the single active sync target inline (enabled only when it's already configured — WebDAV creds / a Drive token); the row body / chevron opens its config sub-page (connect, sync options, disconnect). Status reads Active / Configured / Not connected, with a Syncing… indicator. - Each provider drills into its own sub-page again (WebDAV / Google Drive), rendering the embeddable panel under a SubPageHeader; the brief unified CloudSyncForm picker page is removed (its old deep-link maps to Google Drive). - Switching stays exclusive via withActiveCloudProvider; an inline switch trusts the stored credentials/token (no re-validate / re-OAuth). Full suite 6416 green; lint + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): gate third-party cloud sync behind a premium plan WebDAV + Google Drive sync is now a premium feature: available on any paid plan (Plus, Pro, or Lifetime), not on free. - isCloudSyncInPlan(plan) helper (mirrors isEmailInPlan; plus/pro/purchase). - IntegrationsPanel: free users see the Third-party Cloud Sync section with an upgrade row ("Available on Plus, Pro, or Lifetime") that opens the plans page instead of the provider rows; the cloud-sync deep-links are gated too (waiting for the plan to load before deciding). - useFileSync: the reader's auto-sync only runs on a paid plan, so a downgraded user's sync stops even if a provider's enabled flag lingers. Full suite 6418 green; lint + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): escape backslashes in Drive query literals (CodeQL) escapeDriveLiteral escaped single quotes but not the backslash escape character, so a file name containing a backslash (or ending in one) could break out of the single-quoted Drive `files.list` query literal and malform the query. Escape backslashes first, then single quotes, so the backslashes added for the quotes are not doubled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
348c85f648 |
fix(reader): cap auto page-turn corner zone size (#4812) (#4820)
The corner-dwell auto page-turn zone is a quarter-ellipse whose radius is a fraction (0.15) of the reading area on each axis. On wide screens such as desktop or multi-column pages, that fraction grows the zone until it reaches deep into the text, so selecting in a column and resting the pointer there turns the page unexpectedly. Cap each axis of the corner radius at 50px so the engagement zone stays a real corner regardless of page width, while preserving the existing feel on phones. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9e93445336 |
fix(sync): sync WebDAV credentials across devices (#4810) (#4818)
WebDAV connection settings were never part of the bundled settings replica, so the "Credentials" sync toggle had no effect on them and users had to re-enter their WebDAV server, username, and password on every device. Add webdav.serverUrl / username / password / rootPath to SETTINGS_WHITELIST and gate username / password behind SETTINGS_ENCRYPTED_FIELDS, matching how KOSync / Readwise / Hardcover credentials are handled. Per-device bookkeeping (enabled, deviceId, lastSyncedAt, sync sub-toggles) stays local, mirroring KOSync which syncs credentials but not its enabled flag: a fresh device pre-fills the connect form and the user clicks Connect. Also add a webdav deep-merge case to mergeSettings. Without it the top-level shallow merge on pull would replace the whole webdav object with the four-field patch and wipe the local per-device fields. Update the credentials category description to mention WebDAV and migrate the i18n key across all locales. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4874eb9ae7 | feat(reader): add TTS highlight granularity setting (word or sentence) (#4807) | ||
|
|
dced42912f |
feat(reader): filter exported annotations by color and style (#4801) (#4806)
Add a Filter section to the annotation export dialog so users who color-code highlights (e.g. red for important, yellow for difficult words) can export only selected colors and styles. The selection is stored as exclusions in NoteExportConfig, so an empty filter exports everything and any color or style added later is included by default. A new pure helper filterExportGroups applies the filter to both the default formatter and the custom-template paths, and only filters a dimension when at least two distinct values are present so a hidden row never silently drops notes. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
01a54238ae |
fix(annotator): clean up empty highlight on annotation cancel (#4791) (#4804)
Clicking "Annotate" on a selection eagerly creates a highlight (with an empty note) as the anchor for the note being typed, so the selection stays visible while the NoteEditor is open. Cancelling the note instead of saving left that empty highlight behind: it leaked into the config DB, showed as a stale card in the Booknotes list, and left a phantom yellow highlight. handleHighlight now returns the created BookNote only when it pushes a new record (null when it restyles an existing highlight, which predates the flow and must survive a cancel). handleAnnotate tracks that id via the new notebookNewHighlightId store field; cleanup is keyed on the id, not the cfi, so a fresh selection that collides with an existing highlight's cfi can't wrongly delete it. removeEmptyAnnotationPlaceholder tombstones the tracked placeholder only when it still has no note text, and the Notebook tears its overlay down. Cleanup is presentation-driven: an effect removes the placeholder whenever the creation editor stops being shown (Cancel, Escape, overlay, close, swipe, navigate), plus a second effect for book-switch and reader-close. Save survives the guard and clears the tracked id. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4ba78490a7 |
fix(library): prevent series and description overlap in list view (#4796) (#4799)
The list-mode book item used a fixed h-28 height. When a book belongs to a series, the title, authors, series, description, and progress row together exceed 112px and overflow, so the series and summary lines collide and get clipped. Larger system font scaling (such as the Android accessibility font size setting) inflates line heights and makes the overlap worse, which is what the reporter saw on a Pixel 10 Pro. Use min-h-28 instead so the row grows to fit its content. Non-series rows keep the same 112px height, and the list is virtualized with measured heights so variable row heights are fine. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0b4993407c |
feat(reader): add contrast option to PDF/CBZ view menu (#4800)
Add a Contrast stepper to the reader View menu for fixed-layout (PDF/CBZ) documents. It increases and decreases page contrast via a CSS filter on the rendered page images, applies to the whole book, and is stored per-book (local to the current document). The filter is built in applyFixedlayoutStyles by combining any dark-mode invert with the contrast amount into a single filter declaration. Persisted with skipGlobal so it never touches global view settings, and added to FoliateViewer's effect dependencies so the change re-applies across all rendered pages. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
370a516620 |
feat(reader): glue non-breaking spaces after short Russian words (#4769) (#4798)
Russian typography requires short function words (prepositions, conjunctions, particles) to never hang at the end of a line. Add an `nbsp` content transformer that inserts U+00A0 after such words so they stick to the following word. The source file is never modified. The transformer is language-driven via an NBSP_LANGUAGES registry keyed by language code (only `ru` is configured today), so adding another language is a single entry. It runs only for matching books and rewrites text between tags with a regex, leaving tags, attributes, and the XML declaration intact. Runs after whitespace normalization so the inserted spaces are not stripped under the override-layout setting. The space-to-NBSP swap is length-preserving (both are single UTF-16 code units), so DOM character offsets and CFIs stay valid for every word before and after the transform; tests enforce this invariant. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
fb943987eb |
fix(opds): hide popular catalog after adding it to My Catalogs (#4782) (#4787)
Adding a built-in popular catalog (e.g. Project Gutenberg) to My Catalogs left it still rendering in the Popular Catalogs section, so it looked like a duplicate. Only the card's Add button was hidden; the card itself stayed. Filter added (and disabled) entries out of the Popular list entirely via a new pure helper getUnaddedPopularCatalogs, which matches by normalized URL (trim + lowercase) to mirror the store's findByUrl dedup. The section already auto-hides when the list is empty, so it disappears once all are added. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
99b9adfe85 |
refactor(sync): provider-agnostic file-sync engine with incremental WebDAV sync (#4784)
* refactor(sync): extract provider-agnostic layout paths Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): extract wire envelope module Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): extract pure merge module with law tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): add FileSyncProvider and LocalStore interfaces Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): FileSyncEngine orchestration over a provider Port WebDAVSync's per-book + library-wide sync onto FileSyncProvider + LocalStore. Behavior preserved; the #4756 metadata-reconciliation test is retargeted to drive the engine through a fake provider + store. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): move WebDAV client + connect settings under providers/webdav Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): WebDAVProvider implementing FileSyncProvider Wraps the WebDAV transport client, maps WebDAVRequestError to the neutral FileSyncError, and owns Tauri streaming upload/download. Adds a provider-conformance suite future backends can run against. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): shared appService-backed LocalStore bridge Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(reader): drive WebDAV sync through FileSyncEngine Construct a WebDAVProvider + shared LocalStore + engine once per hook; the inline buffered/streaming book-file loader collapses into the provider + store, so the hook no longer imports tauriUpload or the file path helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(settings): drive WebDAV library sync + browse through the provider WebDAVForm now builds a WebDAVProvider + shared LocalStore + engine and calls engine.syncLibrary; the ~170-line inline callback block (buffered/streaming loaders, URL+auth construction) is gone. WebDAVBrowsePane builds a provider for the engine-level deleteRemoteBookDir cleanup helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): remove WebDAV-specific sync module, WebDAV is now a provider Delete src/services/webdav (WebDAVSync/WebDAVPaths + the transitional client and connect-settings shims). The superseded webdav-metadata-sync test is replaced by engine-metadata-sync; webdav-delete now drives deleteRemoteBookDir through a WebDAVProvider and asserts FileSyncError. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): hydrate library before WebDAV Sync now to prevent clobber Sync now while the library store was unloaded (app launched into reader/ settings without mounting the Library view) merged the engine's addBookToLibrary / updateBookMetadata against an empty in-memory library, persisting a downloaded book or a metadata update as the entire library and wiping what was on disk. Pre-existing bug surfaced during the file-sync review. Hydrate the store in handleSyncNow and harden the store bridge with a load-if-unloaded guard (mirrors useLibraryStore.updateBooks). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): make listDirectory honor the FileSyncError contract listDirectory threw a plain Error (and let raw fetch failures escape), so WebDAVProvider flattened every list() failure to FileSyncError(UNKNOWN). Throw the same WebDAVRequestError taxonomy as the file-level helpers (AUTH_FAILED / NOT_FOUND / NETWORK) so the provider maps them correctly. Add list() cases to the provider-conformance suite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(sync): cover streaming upload, discovery/download, and receive paths The metadata-sync gate only exercised the buffered metadata + config-merge paths. Add engine tests for streaming uploadStream (+ HEAD short-circuit + one-shot retry), remote-only discovery -> streaming download -> addBook, and the receive strategy (pull-only, no config or index writes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): incremental WebDAV Sync now + bounded concurrency Sync now was a full walk of every book each run (675 round-trips even when nothing changed). Default to incremental: diff the local library against the shared library.json index per hash and only process books whose local copy is newer (or absent). book.updatedAt bumps on every progress/notes/metadata save (bookDataStore.saveConfig), so the index is a reliable per-book change marker. Remote-newer books pull their config in the reconcile pass so peer progress still propagates. A new 'Full Sync' toggle (default off) re-checks everything. Also run the reconcile / download / push phases over a bounded worker pool (default concurrency 4) instead of one book at a time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): simplify Sync now toast to a single book count The completion toast built a multi-line success bullet list (downloaded / pulled / pushed / uploaded). Replace it with the same single-line info toast the native cloud sync uses: '{{count}} book(s) synced'. Add a booksSynced counter to the engine result (a Set of distinct hashes touched in any direction, since the per-action counters overlap under Full Sync). Failures still surface as a warning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ui): raise toasts above modals so they aren't hidden by open dialogs Toasts rendered at z-50, below the Settings dialog (z-110) and ModalPortal (z-120), so a toast dispatched from an open dialog (e.g. WebDAV 'Sync now') was buried. The documented overlay scale already places toast at 130; the component just hadn't followed it. Move the toast to z-[130] and extend the zIndexScale invariant test to guard TOAST > MODAL/SETTINGS and APP_LOCK > TOAST. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
79ae8a48ba |
feat(reader): sync per-book proofread rules across devices (#4781)
Per-book and selection-scope proofread (find/replace) rules were pushed in the synced book config but dropped on pull (applyRemoteProgress only applied location), so they never propagated across devices. Merge them by id on the config pull, mirroring the booknote CRDT path. Library-scope rules keep syncing via the settings replica. - Add updatedAt/deletedAt to ProofreadRule. Delete is now a tombstone for book/selection scope so a removal is not resurrected by a peer's live copy; library-scope deletion keeps the hard splice (settings-replica whole-field LWW already handles it). - Add mergeProofreadRules (by id, updatedAt/deletedAt last-write-wins) and merge into applyRemoteProgress; refresh the live view only when the merged rules actually changed. - Backfill a content-derived id for id-less rules (legacy/foreign/hand-edited) via ensureRuleId, and seed book/library ids from content so the same rule created on two devices dedupes instead of duplicating. Selection rules keep a per-instance unique id. Without this, id-less rules collide on one Map key and clobber each other. - Filter tombstoned rules from the transformer and the manager dialog list. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0589cb4f4a |
fix(reader): stop a quick-deleted highlight from being re-drawn (#4773) (#4779)
The per-relocate re-apply effect reads a memoized annotation index. A highlight deleted in place after the index was built still sits in its bucket, and selectLocationAnnotations trusted the build-time deletedAt filter, so the effect re-drew the just-deleted overlay and left it orphaned on the page until the book was reopened. Re-check deletedAt at the read site: in selectLocationAnnotations and in the sibling globals re-apply loop in Annotator. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e80ab1762b |
refactor(settings): polish sync and integration panels (#4774)
- Background Image: move the Library/Reader scope into the section title
("Background Image (Library)" and "Background Image (Reader)") instead
of a separate "Applies to ..." sublabel line.
- Send to Readest: render approved-sender emails monospace to match the
inbound address, and wrap long addresses to at most two lines instead
of truncating on one line.
- WebDAV: split the "Uploading X / Y" progress into a status line plus a
one-line book title.
- WebDAV: reword the "Upload Book Files" description to "Uploads book
files to your other devices."
- WebDAV: rename the "Always use latest" strategy to "Send and receive".
KOSync keeps "Always use latest" since it must contrast with its
"Ask on conflict" option.
- WebDAV: remove the Sync History section and its persisted log model;
the sync engine still reports per-book failures in its result.
Updated i18n across all 33 locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
0c7ffa9799 |
fix(reader): stop iOS page-turn animation stutter (#4768) (#4772)
* fix(reader): stop iOS page-turn animation stutter (#4768) iOS users saw occasional page-turn animation stutter that was not present on earlier 0.11.x builds. It traces to foliate-js commit c1c7315 (first shipped in 0.11.4): the large-section rafAnimateScroll fallback and the removal of persistent compositor-layer hints, both added to fix a ~1s Blink freeze on Android Chromium at high DPR. Apple WebKit composites those layers fine, so on iOS (notably 120Hz ProMotion devices) the changes only cost smoothness: large-section turns animate scroll on the main thread, and every turn promotes a layer on-demand instead of using a persistent one. Opt the iOS renderer into foliate-js's new gpu-composite path, which restores persistent compositor layers and skips the main-thread rafAnimateScroll fallback. Other platforms keep the Android freeze fix. Bumps the foliate-js submodule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(deps): repin foliate-js to merged gpu-composite commit (#4768) readest/foliate-js#39 squash-merged to a new commit on main. Move the submodule pin off the now-orphaned PR branch commit to the merged main commit. No content change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
44a6900da0 |
feat(reader): extend selections and highlights across pages (#4741) (#4767)
* docs(plan): design for cross-page corner auto-turn (#4741) Extract useAutoPageTurn so the corner-dwell page turn works for instant highlight drags and for range-editor handle drags, not just native text selection. Decouple the dwell liveness from the DOM selection and anchor each range's non-dragged end to a DOM position so it survives the scroll. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(plan): add keyboard turn-on-cross to cross-page design (#4741) Shift+Arrow selection adjust extends into the off-screen next column without turning the page. Fold it into the feature with an immediate turn-on-cross (no dwell) in the keyboard path, reusing the page-edge geometry from useAutoPageTurn. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): extend selections and highlights across pages (#4741) Extract the corner-dwell auto page-turn (#1354) into useAutoPageTurn, decoupled from the DOM selection, so every selection gesture can drive it in paginated mode, not just native text selection: - Instant Highlight drag: feed the finger corner into the dwell machine and DOM-anchor the highlight start so it survives the page scroll. - SelectionRangeEditor and AnnotationRangeEditor handle drags: feed the dragged-handle corner; anchor the non-dragged end to a DOM position so the edited range spans pages (the annotation editor previously resolved both ends from window coordinates and lost the previous page). - Shift+Arrow keyboard selection adjust: turn the page immediately when the extended focus leaves the visible page, so the growing selection stays in view. An after-turn re-emit rebuilds each gesture's range from the held position so the selection extends onto the new page without waiting for the next move. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d963b911c8 |
fix(reader): zoom linked images on single tap (#4757) (#4766)
A single tap on an image wrapped in an <a> element followed the link instead of opening the image viewer, because postSingleClick returned early for any element inside an anchor before reaching media detection. Compute the media target up front and let it bypass the anchor guard, so a tapped image/table/svg-image opens the viewer just like long-press already does. Footnotes are excluded so footnote anchors keep their popup and navigation behavior. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
163487b5e3 |
feat(reader): add regex and nearby-words search modes (#4560) (#4764)
Add Calibre-parity search modes to the reader's full-text search. The "Match Whole Words" toggle becomes a single-select mode group: Contains, Whole Words, Regular Expression, Nearby Words. - Regex and nearby-words matching live in the foliate-js submodule (bumped here); the sidebar threads `mode` and `nearbyWords` through. - Nearby distance is chosen with a "within N words" control (5/10/20/50, default 10), not parsed from the query, so trailing numbers stay literal search words. - Per-mode modifiers: Match Diacritics is greyed out for regex (no-op). - Calm inline error for invalid regex / too-few nearby words, a no-results state, and a results-count footer. - Nearby matches render a segmented excerpt emphasizing each matched word and highlight every word in the book. - BookConfig schema v2 -> v3 migrates the deprecated `matchWholeWords` boolean to `mode` (still written for sync back-compat). Also fix two search interactions: - option changes (e.g. within-N-words) now take effect immediately by reading the latest config at search time instead of a stale closure. - closing search from the results nav bar now exits the sidebar search mode, not just the results (search-bar visibility lifted to the store). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ac6249cbc3 |
feat(opds): show groups as horizontal carousels when 2+ groups (#4750) (#4755)
Feeds that list several groups previously rendered each group as a full grid, which made scrolling past many groups tedious. When a feed has two or more groups, render each group's items in a compact horizontal carousel, matching what Thorium does. Each carousel is a horizontally virtualized react-virtuoso list, so only the covers in view are mounted and fetched; off-screen covers load lazily as the row is scrolled. Scroll arrows page through by index and stay centered on the cover artwork. Book items also get rounded covers (matching the library bookshelf) and drop the inline acquisition badge, which remains on the publication detail page. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7d1a60b9ea |
feat(library): separate background texture for library and reader (#4754)
* feat(library): separate background texture for library and reader (#4743) The library and reader shared a single background texture, so a reader backdrop with borders or other reading-oriented decoration looked wrong on the bookshelf. Let users set them independently. - Add device-local libraryBackground{TextureId,Opacity,Size} to SystemSettings. Each field falls back to the reader/global value when unset (getLibraryViewSettings), so an existing bookshelf looks unchanged until the user picks a library texture, then decouples per-field. No migration needed; the selection stays per-device like the reader's, while imported images keep syncing via the texture kind. - Make the Color panel's Background Image picker context-aware: opened from the library it edits the library texture, opened while reading it edits the reader texture. A sublabel states which page it applies to. - Apply the library texture at boot and on every library mount, so returning from a textured book restores the bookshelf background. - useBackgroundTexture now unmounts on 'none' instead of early-returning, since library and reader share one style element: switching a page to None must clear a texture the other page mounted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n: translate library/reader background texture labels (#4743) Add translations for the two new context sublabels ("Applies to the Library" / "Applies to the Reader") across all 33 locales, anchored to each locale's existing Library and reading terminology. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e2f65278ec |
fix(opds): dereference publication self link for full metadata (#4749) (#4753)
OPDS 2.0 feeds may list a publication in summary form (title, cover, and only a rel="self" link of type application/opds-publication+json), serving the full record (acquisition links, description, publisher, subjects) only when the client follows that link on click, as Thorium does. Readest ignored it, so such books showed no download option and no description. Add opdsPublication.ts with getPublicationDetailHref and parsePublicationDocument (OPDS 2.0 JSON and Atom entry, absolutizing link/image hrefs), and dereference the link in the OPDS page when a feed-selected summary advertises one, upgrading the detail view in place once it loads. Also render an OPDS 2.0 JSON HTML description as sanitized markup instead of literal tags by falling back from the typed content to the plain description string in getOPDSDescriptionHtml. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8810aa6db0 |
fix(reader): stop trackpad pinch-zoom flicker on image viewer (#4742) (#4748)
On macOS a trackpad pinch-to-zoom is delivered as a rapid stream of ctrl+wheel events. The zoomed image kept its 0.05s transform transition during that stream, so each event restarted the in-flight transition from its interpolated mid-point and the image lagged and flickered. This is the same root cause as the #4451 pan flicker, which was fixed by dropping the transition during the gesture for the pan and touch-pinch paths; the wheel-zoom path was the only continuous gesture left with the transition on. Suppress the transition while a wheel-zoom gesture is streaming, cleared on a short debounce since wheel has no explicit gesture-end event. Discrete zoom (buttons, double-click, keyboard) keeps its smoothing. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6301c620a8 |
fix(library): import books opened via "Open with" by default on mobile (#4746) (#4747)
A recent change (#4407) made Android's "Open with Readest" (VIEW intent, used by Telegram and similar apps) always open the file as a transient book: into the reader but never written to the library, with its filePath pointing at the original content:// URI. Once that temporary URI grant dies the book can no longer be reopened, so the user has to re-share it from the source app every time. Make the transient behavior an opt-out gated by the existing autoImportBooksOnOpen setting, and surface it on mobile: - The VIEW handler now consults the setting via a new shouldOpenTransient predicate. When auto-import is on it falls through to the same library ingest path as a share-sheet SEND (full ingest plus cloud upload); when off it keeps the transient open. - Read the setting from disk in the handler rather than the settings store, since on a cold-start "Open with" the store is not hydrated yet and would wrongly fall back to a transient open. - Show the "Auto Import on File Open" toggle on mobile (was desktop only). - Default autoImportBooksOnOpen to true on mobile so shared files persist and sync by default; the desktop default is unchanged. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
acd4a67dcf |
fix(reader): require a still-hold before instant-highlight on touch (#4745)
* fix(reader): require a still-hold before instant-highlight on touch Instant Highlight (the highlighter quick action) engaged on every pointer-down over text, calling preventDefault, which swallowed the single tap / swipe that turns the page on Android. Tapping the side margins still worked only because they are not selectable text; the synthetic-click fallback was also dead on Android (native touchend calls handlePointerUp with no event). Gate engagement behind a 300ms still hold for touch/pen: a tap releases first and a swipe moves first, so both fall through to pagination, and only a deliberate still hold starts drag-to-highlight. Mouse input keeps engaging immediately (click vs. press-drag is already unambiguous). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(agent): update agent memories Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b1346bf16d |
feat(wordlens): en-en glosses, styling, derivation lemmas, display-time cap (#4744)
* feat(wordlens): support en-en monolingual glosses Gloss difficult English words with a short English definition for learners reading English with English hints. - build: buildEnEn + shortDefGloss read ECDICT's English `definition` column (first/primary WordNet sense, POS-stripped, drop ;-example, <=24 word-boundary with trailing-connector trim). New `en-en` CLI branch; buildEnZh/buildEnEn now share a buildEnPack core. - gating: drop the hardcoded `hint === source` rejections (wordlensSection, WordLensPanel) so same-language packs are allowed; availability is decided by the manifest (resolvePack returns null when no pack exists). - data: data/wordlens/en-en.json (26,578 entries) + regenerated manifest.json. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(wordlens): gloss styling, derivation lemmas, display-time cap Builds on the en-en monolingual gloss support with refinements and regenerated packs. - settings: per-book gloss <rt> font size (em) and color in Settings > Language > Word Lens (getRubyStyles reads viewSettings). - en-en hints: WordNet hybrid (a simpler synonym, else a category hypernym, else the ECDICT definition) instead of raw verbose definitions. - lemmatization: gate difficulty by the lemma rank for every English source pair. enBaseFormCandidates now also covers -able/-ible suffixes and negative prefixes (un/in/im/ir/il), so insufferable resolves to suffer. A candidate is accepted when the English definition names the base OR the Chinese translations share a content character, which keeps true derivations (insufferable -> suffer) and rejects coincidental stems (capable -> cap). en-X packs inherit the en-en lemma table. - display cap: the max gloss length is applied at render time in cleanGloss (MAX_GLOSS_LEN), so the packs store the full hint and the cap can change without regenerating data. - tooling: pnpm wordlens:preview to sample pack entries; cache build corpora under data/wordlens/.sources (gitignored). - data: regenerate en-en, en-zh and en-de/es/fr/pt/ru plus the manifest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e982af1725 |
feat(reader): adjust text selection with Shift/Ctrl/Opt+Arrow keys (#4728) (#4738)
Support standard desktop shortcuts for refining an active text selection: Shift+Left/Right by character, Ctrl/Option+Shift+Left/Right by word. Only active while text is selected; otherwise the keys fall through to page navigation as before. Root cause: after a selection the reader container (not the book iframe) holds focus, so Shift+Left/Right keystrokes reach the parent shortcut handler and matched the page-turn shortcuts, turning the page instead of refining the selection. The new onAdjustTextSelection action runs before the navigation actions: when a selection is active it extends the iframe selection via Selection.modify() and suppresses the page turn; an iframe-forwarded key (already extended natively) just suppresses navigation. handleSelectionchange now refreshes the popup/range for keyboard-driven changes (no pointer drag) so the selection toolbar follows the refined selection. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
bc9b8b23e6 |
fix(reader): stop per-chapter listener leak that degrades paragraph mode (#4735)
The annotator's foliate `load` handler (onLoad) attached a renderer `scroll` listener and, on Android, a global `native-touch` dispatcher listener on every section load. Both the renderer and the eventDispatcher outlive individual sections — and foliate fires `load` for preloaded neighbour sections too — so these listeners accumulated without bound, one set per chapter. Each renderer `scroll` (fired on every paragraph-mode `goTo`) then ran all of them, and on Android the scroll/native-touch handlers do real work. Reading a long book (e.g. a 3000-chapter web novel) in paragraph mode slowed down steadily after a few chapters and only an app restart cleared it. Register these listeners once per view via a new `useRendererInputListeners` hook with cleanup, instead of once per section load. The native-touch handler now resolves the CURRENT primary section's doc/index at fire time rather than capturing a (possibly off-screen, preloaded) section's. The redundant `scroll` → `repositionPopups` listener is dropped — a dedicated effect already repositions popups on scroll. Doc-scoped listeners stay in onLoad, since they die with the section's iframe. Add useRendererInputListeners unit tests covering register-once-per-view, no-accumulation-across-re-renders, latest-handler routing, Android gating, and unmount cleanup. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
140b71ee30 |
feat(dictionary): add adjustable dictionary popup font size (#4443) (#4734)
Expose `::part(dict-content)` on the MDict shadow content and add a dictionary popup font-size setting (Settings → Language → Dictionaries), independent of the main reading view. - mdictProvider: tag the in-shadow body with `part="dict-content"` and a stable `dict-shadow-host` class so the popup's `::part()` rule can reach across the shadow boundary — MDict is the only provider that renders into a shadow root, so ordinary popup CSS can't touch it. - DictionarySettings.fontScale (default 1) with setFontScale + load-merge; synced cross-device via the `dictionarySettings.fontScale` whitelist entry. - DictionaryResultsView drives `--dict-font-scale` + `data-dict-content` on each per-tab container. globals.css re-bases the light-DOM Tailwind text utilities to `em` within that scope and sizes the MDict shadow body via `::part(dict-content)`, so every provider scales from one lever. - SettingsSelect control (85–175%) in the Dictionaries panel. Tests: jsdom unit tests (part attribute, store fontScale, sync whitelist) plus a real-Chromium browser test for the em-rebasing + `::part` + custom- property-inheritance CSS contract jsdom cannot model. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
082edc204b |
fix(sync): sync updated book covers across devices (#4544) (#4731)
* docs: design for syncing updated book data (cover + file) (#4544) Cover-change sync via a content hash (coverHash = partial MD5 of cover.png) plus a cover_updated_at field-level merge timestamp; file updates ride the existing re-import / metaHash dedupe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): sync updated book covers across devices (#4544) Editing a book's cover wrote cover.png locally but changed no hash (the cover is keyed by the file hash), so peers had no signal to re-download it and the change never propagated. Give the cover its own content-addressed version: - coverHash = partial MD5 of cover.png; a peer re-downloads the cover iff the synced hash differs from the local one (idempotent, no churn on identical/re-extracted covers — compatible with the metaHash dedupe). - coverUpdatedAt = field-level LWW timestamp so a page-turn that wins whole-row LWW on updated_at can't clobber a cover edit (mirrors the reading_status_updated_at fix for #4634). Editing a cover recomputes the hash, bumps coverUpdatedAt, and re-uploads only the cover; the server merges cover fields independently; peers re-download on a hash diff. File updates continue to ride the existing re-import / metaHash dedupe (changed file -> changed hash -> re-key). Migration 016 adds cover_hash / cover_updated_at to books. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
acf2b165f3 |
fix(library): keep in-place book paths absolute so uploads stay in fs scope (#4720) (#4730)
* fix(library): keep in-place book paths absolute so uploads stay in fs scope (#4720) resolveFilePath joined `${prefix}/${path}` unconditionally. For base 'None' (in-place / external books, whose filePath lives outside Books/<hash>/) the prefix is empty, so an already-absolute source path became `/C:\Users\...` on Windows (and `//Users/...` on POSIX). The native upload guard added in #4639 (transfer_file.rs `ensure_path_allowed`) then rejected that malformed path as "permission denied: path not in filesystem scope", so uploading a folder-imported book failed on Windows. Return the path verbatim when the prefix is empty. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(library): use btn-contrast for the Import-from-folder confirm button (#4720) Aligns the dialog's confirm CTA with the sibling import dialogs (ImportFromUrlDialog, FailedImportsDialog), which already use the theme-neutral, e-ink-correct btn-contrast instead of the colored btn-primary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
942095bcd6 |
fix(reader): make Shift+P toggle, exit, and resume paragraph mode reliably (#4717) (#4725)
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>
|
||
|
|
a6d28ffcdf |
fix(reader): add Alt+P proofread shortcut and let Shift+P exit paragraph mode (#4717) (#4723)
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> |
||
|
|
30727d353a |
fix(reader): release volume-key page-flip while TTS is playing (#4691) (#4710)
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> |
||
|
|
c781aeddaa |
feat(reader): add sticky progress bar with chapter ticks (#4707)
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> |
||
|
|
9735f497db |
feat(reader): proofread rule sync, regex, reorder, and dialog refresh (#4700) (#4708)
- 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> |
||
|
|
799fc0e0ab |
feat(library): add opt-in "purge reading data" toggle to delete confirm (#4698) (#4705)
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> |
||
|
|
4fa7f76bc1 |
feat(payment): observability for store subscription webhooks (#4704)
* 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> |
||
|
|
359fdddcf4 |
feat(payment): handle App Store and Google Play subscription webhooks (#4701)
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> |
||
|
|
96d65d9960 |
feat(tts): add native local iOS TTS (AVSpeechSynthesizer) (#4697)
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> |
||
|
|
ab935f8510 |
fix(library): preserve original files when deleting "read in place" books (#4696)
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> |
||
|
|
9e163fe746 |
fix(payment): reflect highest active plan across overlapping Stripe subscriptions (#4694)
* 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> |