forked from akai/readest
ae9fb05f2ce332dfdd24c3ee62a67632e81b92dd
126 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
4874eb9ae7 | feat(reader): add TTS highlight granularity setting (word or sentence) (#4807) | ||
|
|
13e0fb814f |
feat(webdav): sort and filter the WebDAV browser (#4724) (#4786)
Add per-folder sort and search to the WebDAV browse pane in Settings, Integrations, WebDAV. - Sort by name, date modified, date created, or size, ascending or descending; the choice persists in WebDAV settings so a chosen "recent first" order survives across sessions. - Filter the current folder by file name or matched book title. - Request and parse the WebDAV creationdate property; servers that omit it fall back gracefully to a stable name order with no broken dates. - Sort and search resolve a per-hash book directory to its library title so they operate on what the user actually sees. Sort and filter are pure, unit-tested helpers in webdavBrowseUtils; creationdate parsing is covered by a listDirectory test. Verified on a Xiaomi device against a live WebDAV server (675 books): name, modified asc/desc, title filter, and persistence across an app restart. 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> |
||
|
|
cd3a53f507 |
fix(sync): WebDAV Sync now pulls latest book metadata and merges config (#4756) (#4776)
* fix(sync): pull newer WebDAV book metadata to devices that already hold the book (#4756) syncLibrary only pulled title/author/cover for books missing from the local library. For a book a device already held it only pushed, so a peer's metadata edit never propagated back, and the final library.json re-push clobbered the peer's newer metadata with this device's stale copy. Add a last-writer-wins reconciliation pass keyed on book.updatedAt: when the shared index has a strictly newer copy of a locally-held book, merge its metadata, re-pull the cover, persist it via a new updateBookMetadata callback, and keep the merged copy authoritative for the index re-push so neither direction loses the edit. Surface a "metadata updated" counter in the sync toast and history, and translate the new strings across all locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): merge remote config before pushing in WebDAV Sync now (#4756) The manual library "Sync now" pushed each book's config.json blind, so it could overwrite a peer's booknotes (element-set CRDT) or regress newer remote progress (per-config LWW) that this device had not pulled yet. The reader hook already pull-merges before pushing; the library path did not, so notes and progress could diverge or regress on the remote until a device happened to open the book. Give syncLibrary's config push the same read-merge-write cycle: pull-merge then push the merged superset, persisting it locally so the device converges too. Gated on canPull so 'silent' converges while 'send' keeps the local copy authoritative and 'receive' still never pushes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- 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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
d5c640996d |
fix(opds): show Add Catalog dialog above Settings on mobile (#4669)
The "Add OPDS Catalog" dialog (a ModalPortal opened from inside Settings > Integrations > OPDS Catalogs) rendered behind the Settings sheet on mobile, so the form could not be reached or filled in. Root cause: PR #3235 raised the Settings dialog to z-[10050] to clear the full-screen RSVP overlay (z-[10000]) for in-overlay dictionary management. That also jumped Settings above the ModalPortal layer (z-[100]), so any modal opened from inside Settings was buried. The bug is mobile-only because on desktop the rounded-window frame (.window-border, z-99) traps the inline-rendered Settings dialog in its own stacking context, while ModalPortal escapes to document.body and wins there. Redesign the overlay z-index into a compact scale (no four-digit values), each layer clearing the z-99 page frame: 100 RSVP overlay 101 RSVP controls (start dialog, lookup chip) 110 Settings dialog 120 modal / command palette 130 toast / alert 200 app lock Lock the ordering with a static test that reads the values from source and would have caught the #3235 regression. Documented in DESIGN.md. Verified on a Xiaomi device via CDP: elementFromPoint at the dialog center now resolves inside the Add Catalog form instead of Settings. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e00a1e4f06 |
fix(settings): tidy Word Lens data pack and level rows on mobile (#4655)
Move the informational Data pack hints ("Open a book…" / "No data available…")
from the inline trailing slot into the row description so they wrap under the
label instead of stretching the row wide on mobile. Drop the size from the
Download button label and surface it as the row description (matching the
"Downloaded · size" state). Add a "CEFR level" description under the Level row.
Includes translations for the three new i18n keys across all locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
6c7c86f346 |
feat(applock): biometric unlock (fingerprint / Face ID) at startup on mobile (#4650)
* feat(applock): wire up biometric plugin + biometricUnlockEnabled setting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(applock): add guarded biometric service wrapper * feat(applock): auto-prompt biometrics on the lock screen with PIN fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(applock): guard concurrent biometric prompts + tighten lock-screen tests - Add biometricInFlightRef to prevent concurrent authenticateWithBiometrics calls - Assert PIN input still rendered after biometric failure (test 2) - Replace flaky waitFor negative assertion with a 50ms flush in test 3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(applock): mobile biometric toggle + default-on at PIN setup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(i18n): add biometric app-lock strings * fix(applock): seed biometricUnlockEnabled via app-lock store init; close test gaps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * build(applock): pin tauri-plugin-biometric in Cargo.lock Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
c2ac207945 |
refactor(wordlens): rename "Word Wise" to "Word Lens" (#4633)
"Word Wise" is a Kindle trademark, so rename the inline-gloss feature to
"Word Lens" throughout the product.
- User-facing strings → "Word Lens" across all 34 locales; brand translated
for Chinese (zh-CN 单词透镜, zh-TW 單詞透鏡) and German (Word-Lens-Daten).
- Code identifiers: WordWise→WordLens, wordWise→wordLens, WORD_WISE→WORD_LENS.
- Files/dirs: src/services/wordwise→wordlens, WordWisePanel→WordLensPanel,
wordwise{Ruby,Section}.ts, build/sync scripts, test dirs/fixtures,
data/wordwise→data/wordlens.
- Storage paths: CDN base, R2 key, on-device cache dir, WORDLENS_R2_BUCKET env,
pnpm wordlens:{manifest,sync}. manifest.json is path-agnostic so its
sha256/bytes stay valid (verified).
- biome.json: point the formatter-ignore at data/wordlens so the generated
one-line gloss packs aren't pretty-printed on commit.
Migration notes:
- Re-run `pnpm wordlens:sync` to upload packs to cdn.readest.com/wordlens/.
- Persisted view-settings keys renamed (wordWiseEnabled/Level/HintLang and
wordWiseAutoDownload) — saved values reset to defaults once on upgrade.
- Cached packs under the old Data/wordwise/ orphan (harmless re-download).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
480ab5b71e |
feat(hardcover): automatically sync progress and notes (#4614)
Hardcover sync previously only ran when the user opened the reader menu and tapped "Push Progress" / "Push Notes". Add an opt-in Auto Sync toggle (default OFF) to the Hardcover settings so progress and notes are pushed automatically while reading. - useHardcoverSync: silent debounced (10s) auto-push of progress on page turns and of notes on annotation/excerpt changes, gated on enabled && autoSync === true; pending pushes flush on the existing sync-book-progress close event and cancel on unmount. Manual menu actions are unchanged (still loud). - HardcoverSettings.autoSync flag (default false); existing connected users stay manual until they opt in. - HardcoverForm: new "Auto Sync" toggle row. Also backfills two untranslated strings surfaced by i18n:extract from the reading-stats feature (#4606) across all locales, plus the new "Auto Sync" key. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
79496f88d7 |
feat(settings): move update & telemetry controls into Settings → Behavior (#4592)
Relocate the update and telemetry toggles out of the library settings menu into the Behavior (Control) panel, where global app settings live: - New "Update" boxed-list (gated on hasUpdater): Check Updates on Start + Nightly Builds. - New "Privacy" boxed-list: Help improve Readest (telemetry). - Behavior section order: Update → Security → Privacy. - Rename "Nightly Builds (Unstable)" → "Nightly Builds" and drop the "; may be unstable" note (the channel stays off by default). - Updater dialog now shows the full version name (e.g. 0.11.4-2026061506) instead of a parsed date. - Extract + translate the new strings (Update, Privacy, Nightly Builds, Early daily builds) across all 33 locales. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4908245042 |
feat(reader): Word Wise inline vocabulary hints (#4589)
* feat(reader): Word Wise — inline native-language vocabulary hints Kindle-style Word Wise: a short native-language gloss renders above difficult words as you read (always-on ruby), gated by a CEFR vocabulary-level slider (A1–C2); tapping a glossed word opens the existing dictionary. - Pipeline: CEFR→frequency-rank difficulty, inflection-aware gloss index, pure offset-aware planner (EN regex + jieba for CJK). - Rendering: <ruby cfi-skip>…<rt cfi-inert> injected per occurrence — CFI-transparent (verified), so highlights/bookmarks/progress are unaffected; kept out of TTS word offsets and find-in-book. - Delivery: gloss packs are version-controlled in data/wordwise/, mirrored to R2, and downloaded on demand into local storage (sha-verified, single-flight) when enabled. - Settings: a Word Wise sub-page under Settings → Language (enable, level, hint language, per-pack download/manage, auto-download toggle). - Build tooling: scripts/build-wordwise-data.mjs (ECDICT / CC-CEDICT+HSK / WikDict + FrequencyWords, with lemmatization) and scripts/sync-wordwise-r2.mjs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * data(wordwise): bundled gloss packs + manifest + attribution 13 frequency-trimmed gloss packs (en↔中文 + es/fr/de/pt/it/ru↔en, ~19 MB) generated by build-wordwise-data.mjs from ECDICT (MIT), CC-CEDICT + HSK, and WikDict + FrequencyWords (CC-BY-SA). Source of truth, mirrored to the CDN via `pnpm wordwise:sync`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
bfb85c2f68 |
feat(reader): sync paragraph mode & speed reader with TTS read-along (#3235) (#4576)
* docs(reader): TTS-sync design spec for paragraph mode + RSVP (#3235) Hardened via brainstorming + /autoplan (CEO/Design/Eng dual-voice review). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tts): emit canonical tts-position event from TTSController (#3235) Controller emits { cfi, kind, sectionIndex, sequence } alongside the existing tts-highlight-mark/-word events. Monotonic sequence lets downstream consumers (paragraph mode, RSVP — later slices) drop out-of-order positions. Additive; existing events untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): containment+cursor CFI->index mappers for TTS sync (#3235) RSVPController.syncToCfi + setExternallyDriven: containment match (fixes mid-token skip), monotonic cursor + binary search (avoids O(N)-per-word jank, no per-word getCFI), -1/no-op on no match (no silent jump to word 0), timer suspension while externally driven. ParagraphIterator.findIndexByRange: hinted + binary-search containment mapper returning -1 on no match (never first()). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tts): forward tts-position + tts-playback-state onto the app bus (#3235) useTTSControl republishes the controller's canonical tts-position (tagged with bookKey) via a dedicated listener — NOT inside the suppression-gated highlight handlers, so page-follow suppression can't silently desync the modes. Adds tts-playback-state (playing/paused/stopped) so RSVP can track playback without the hook-local isPlaying. Verified by extending the real-foliate-view browser harness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): paragraph mode follows TTS playback (#3235) When paragraph mode + TTS are both active, the focused paragraph follows the spoken position (sentence granularity, all engines). Section-generation contract (stash cross-section position, apply after the iterator re-inits); sync-focus path that does NOT arm isFocusingRef (avoids the relocate-eaten wrong-section paragraph-0 bug); stale-sequence drop; decouple on manual nav, re-engage on next playing. Start-alignment + visible indicator deferred to later slices. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(rsvp): speed reader follows TTS playback (#3235) Edge word-boundary voices: RSVP shows the spoken word via syncToCfi. Non-Edge (sentence-only) voices: sentence-paced estimator (clamp 60..600 wpm from voice rate, hold at +60 words cap, snap to first word on each new sentence mark). RSVP auto-advance suspended while TTS-driven. Decouple on manual nav via a rsvp-manual-nav signal; re-engage on next playing. Cross-section positions re-extract then apply. Pure decideRsvpTtsPosition helper unit-tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): fixed-layout gate + ttsSyncStatus for TTS sync (#3235) Gate sync to reflowable books (D7): fixed-layout reports 'unsupported' and never engages. Both modes expose ttsSyncStatus (idle/following/syncing/ decoupled/unsupported) as the data source for the upcoming indicator. RSVPControl now forwardRef-exposes the status via an imperative handle. Cross-bookKey events ignored (regression-tested). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): 'following audio' indicator for TTS sync (#3235) 5-state pill (following/syncing/decoupled, idle+unsupported render null) shown top-center in the paragraph overlay and as a status row in the RSVP overlay. Decoupled state is the tap-to-resume control; first decouple fires a one-time toast. eink-bordered, glyph+text (no color-only), RTL logical props, touch targets, safe-area top inset. RSVP 'plain' variant matches its themed surface; non-Edge shows '· estimated'. New i18n keys need extraction before merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(rsvp): in-overlay TTS toggle + audio-paced speed control (#3235) Voice-glyph audio toggle in the RSVP control row (trailing, by the gear) starts/ stops read-along from inside the full-screen overlay, start-aligned to the current word (range validated against the live doc). While TTS-driven, the WPM control shows a locked 'Audio pace' affordance that opens a compact rate picker; rate changes go through a new tts-set-rate bus event reusing the existing throttled setRate path. Pure buildRsvpTtsSpeakDetail helper unit-tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(reader): e2e paragraph mode follows TTS across a section boundary (#3235) Real <foliate-view> browser e2e: with paragraph mode active, the focused paragraph follows the TTS walk and re-targets to the new section after a Ch4->Ch5 boundary (proves no stuck wrong-section paragraph-0 / isFocusingRef trap). Asserts on the owning section of the current range. Test-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n(reader): translate TTS-sync strings across 33 locales (#3235) Following audio / · estimated / Resume audio / Stopped following audio / Play audio / Pause audio / Audio pace / Speed follows audio. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): resolve TTS CFI anchors across iframe realms (#3235) RSVP and paragraph follow silently failed to track the spoken word: the CFI anchor from view.resolveCFI(...).anchor(doc) is a Range created in the book iframe's realm, so 'anchor instanceof Range' (top realm) was always false (cross-realm instanceof) -> resolveCfiToRange/applySyncCfi returned null -> syncToCfi never advanced. Add isRangeLike() duck-type (cloneRange is unique to Range) and use it at all 4 CFI-resolution sites. Confirmed live via CDP: before = syncToCfi false (frozen); after = exact word map + RSVP follows Edge TTS at ~171 wpm (audio pace). Unit tests reproduce the cross-realm anchor (jsdom is single-realm so the old code passed there but died in the app). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rsvp): stop estimator/word fight + map transport to TTS play/pause (#3235) Two read-along refinements (verified live via CDP with Edge TTS): 1. No more jump-ahead-then-snap-back flashing. Word-boundary engines (Edge) emit BOTH sentence marks and word boundaries; RSVP was routing sentence -> the estimator (self-paces ~190xrate, up to +60 words ahead) while word positions snapped it back. Now once a word position is seen, sentence positions are ignored and any running estimator is stopped, so words alone drive RSVP. 2. The RSVP transport (center play/pause, Space, center-tap) maps to TTS play/pause while read-along is engaged (tts-toggle-play), instead of RSVP's own suspended timer. Pausing TTS keeps RSVP suspended (no runaway); a full stop releases it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rsvp): keep indicator on pause + reach dict management from RSVP (#3235) - Pausing read-along no longer dismisses the 'following audio' indicator / 'Audio pace' lock (layout shift). New 'paused' sync status keeps the indicator row and WPM lock present while TTS is engaged-but-paused; only a full stop clears them. Verified live via CDP: pause keeps the layout, no shift. - Dict management is reachable from RSVP: the settings dialog is z-50, far below the full-screen RSVP overlay (z-[10000]), so it opened invisibly behind it. handleManageDictionary now exits RSVP first (position saved/resumable) so management shows over the reader. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rsvp): show dict management over RSVP instead of exiting it (#3235) Per feedback: opening dictionary management from the RSVP lookup popup no longer closes the speed reader. The settings dialog is raised above the full-screen RSVP overlay (z-[10000] -> SettingsDialog !z-[10050]) so it shows on top, and RSVP's capture-phase keyboard handler bails while the settings dialog is open so its inputs accept Space and Escape closes settings (not RSVP). Verified live via CDP: management opens over RSVP, RSVP stays active behind it, Escape returns to it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dict): only apply drag-handle margin compensation when the handle shows (#3235) The dictionary sheet header used -mt-4 to compensate for Dialog's drag handle, but that handle is sm:hidden (shown only below sm). On sm+ the handle is display:none, so -mt-4 pulled the header up into the top edge (broken layout when the lookup renders as a sheet on a short/wide window). Mirror the handle's breakpoint: -mt-4 sm:mt-0. Verified live via CDP. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): in-mode TTS audio toggle for paragraph mode (#3235) Paragraph mode already follows TTS, but there was no way to start read-along from inside it. Add an audio toggle to the ParagraphBar (mirroring RSVP's): it starts TTS start-aligned to the focused paragraph (range validated live, + section index) and stops it. Track session-active vs playing so a pause keeps the indicator ('paused' status) instead of collapsing to idle. Pure buildParagraphTtsSpeakDetail helper unit-tested. Verified live via CDP: tapping the icon starts audio from the focused paragraph and the focus follows speech. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): highlight current TTS word/sentence in paragraph mode (#3235) Paragraph mode follows TTS by advancing the focused paragraph, but the spoken word wasn't highlighted within it like normal mode. The overlay renders a CLONE of the paragraph, so the iframe's TTS highlight isn't visible there — reproduce it on the clone with the CSS Custom Highlight API (no DOM mutation, spans inline boundaries natively, leaves the fade-in animation untouched). - TTSController already tags tts-position with kind word|sentence. The hook decides granularity: word boundaries (Edge) drive a per-word highlight; once seen, the coarse sentence event is skipped so the whole sentence doesn't flicker over the current word. Engines without word boundaries (WebSpeech/Native) fall back to the sentence highlight. - Offsets are computed relative to the paragraph start (so they map 1:1 onto the clone's text) and tagged with the paragraph index so a stale highlight never paints the wrong paragraph. Cleared on stop / section change / disabled. - The ::highlight() style mirrors the user's ttsHighlightOptions color+style. Pure helpers (offset math, word/sentence decision, css builder) unit-tested. Verified live via CDP: word highlight tracks Edge word-by-word and follows across paragraph boundaries (news -> ... -> ladies), matching the TTS color. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4b0bbc77b0 |
fix(reader): open TXT files shared via "Open with" (#4571)
* fix(reader): open TXT files shared via "Open with" by converting to EPUB
The Android "Open with Readest" (VIEW intent) transient path hands the
reader the original .txt file (its filePath points at the content:// URI),
unlike the managed library which stores the already-converted EPUB. The
DocumentLoader had no branch for a raw .txt, so open() returned
{ book: null } and initViewState crashed with
"TypeError: Cannot read properties of null (reading 'metadata')",
leaving the user stuck on the library splash.
Add an isTxt() check that converts the raw .txt to EPUB in-memory (the
same TxtToEpubConverter the import path runs) and parses that. The
converter emits a .epub-named file, so the importer's own
DocumentLoader.open() on the converted file is unaffected.
Verified on-device (emulator, warm + cold start): the TXT now opens and
renders in the reader instead of crashing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): allow adjusting highlight opacity in e-ink mode
Drop the isEink prop that disabled the highlight Opacity slider under
e-ink. Opacity is still meaningful on e-ink, so let users change it.
Removes the prop from HighlightColorsEditor, its ColorPanel call site,
and the test render helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(send): mock fetch to fix flaky article conversion test
The article/page conversion paths fetch a favicon + author image for the
synthetic cover via globalThis.fetch. In jsdom that hit the real network:
a live fetch to the sample URL can hang up to faviconFetcher's 6s timeout,
exceeding the 5s test timeout and intermittently failing the suite. Stub
fetch so the cover falls back to its initial-letter tile (the pattern other
tests in this suite already use). Article test: ~5003ms hang -> ~80ms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(agent): update annotation-share-toolbar memory
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
67c22c770b |
feat(reader): Share intent + customizable annotation toolbar (#4014) (#4570)
* docs(spec): annotation Share tool + customizable toolbar (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(plan): implementation plan for Share tool + customizable toolbar (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(annotator): add 'share' annotation tool type and button (#4014) * feat(annotator): add pure toolbar order/visibility helpers (#4014) * feat(annotator): add annotationToolbarItems view setting (#4014) * feat(annotator): add shareSelectedText ladder helper (#4014) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(annotator): render Share tool and honor toolbar order in selection popup (#4014) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(settings): add drag-and-drop annotation toolbar customizer (#4014) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(settings): open the toolbar customizer from the Behavior panel (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(i18n): extract and translate annotation share/toolbar strings (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(annotator): extract canShareText helper, preserve hidden Share on cross-platform edit (#4014) Addresses final-review findings: de-duplicate the triplicated canShare definition into share.ts::canShareText, trim ShareCapableService to the fields actually read, and stop the toolbar customizer from dropping a synced 'share' tool when edited on a non-share-capable device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(settings): WYSIWYG drag-and-drop toolbar customizer (#4014) Rework the customizer per live testing: - Render 'In toolbar' as a faithful, content-width, start-aligned preview of the real selection popup (gray bar, icon-only buttons); 'Available' tools show as labeled chips. - Multi-container dnd-kit pattern: in-place dragging (no DragOverlay, which a transformed modal offsets), pointerWithin collision so empty zones accept drops, live onDragOver reparent, itemsRef to dodge dnd-kit's drag-start handler-capture stale closure. - Add 'Add all' (canonical predefined order) and 'Clear all' shortcuts. - Align zone labels with the SubPageHeader breadcrumb. - Empty toolbar now suppresses the selection popup entirely (no empty bar), while still allowing highlight-edit/notes popups. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(i18n): translate Add all / Clear all toolbar shortcuts (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(annotator): size selection popup to visible tool count (#4014) With the customizable toolbar a fixed-width popup looked sparse for a 2-3 tool toolbar (buttons spread to the corners). Size the popup to the number of visible tools (responsive) capped at the previous max; annotated selections keep the max width since they show highlight options / notes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): reword empty-toolbar hint to 'No tools, drag one here' (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(annotator): render default tools (not Share) in popup layout screenshot (#4014) The visual regression test rendered every annotationToolButtons entry, so adding the Share tool shifted the toolbar to 9 buttons and broke the baselines. Share is hidden by default (added via Customize Toolbar), so the popup screenshot should mirror the default-enabled set — filter to DEFAULT_ANNOTATION_TOOLBAR_ITEMS, keeping the existing baselines valid. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
763b579c8f |
fix(android): launch installed dictionary for system lookup, closes #4559 (#4568)
On targetSdk 36, ACTION_PROCESS_TEXT handlers were hidden by Android 11+ package-visibility filtering — only auto-visible web browsers resolved the intent, so system-dictionary lookups landed in the OEM browser (VIVO/iQOO) even with a dictionary like Eudic installed. Add a <queries> declaration so dictionary apps are visible, and filter web browsers out of the handler set so an OEM browser that registers PROCESS_TEXT can't swallow the lookup: - no browser among handlers → unchanged implicit dispatch (keeps native Always) - browser + one dictionary → launch it directly (explicit component) - browser + several dictionaries → chooser excluding browsers, remembering the pick via EXTRA_CHOSEN_COMPONENT so later lookups go straight through - only a browser installed → report unavailable instead of opening it Routing is a pure, JUnit-tested decideLookupDispatch(). Adds get/clear lookup-dictionary commands + an Android-only reset row in the dictionary settings to switch the remembered app. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
61d804a54f |
fix(dict): resolve Android content-URI filenames via native basename (#4553)
On some Android devices the SAF picker returns an opaque, extension-less content:// document URI (e.g. .../downloads.documents/document/msf%3A20). Dictionary bundle grouping derived each filename from getFilename() — a pure string-parse of the URI — so no .ifo/.idx/.dict marker was found, every file was orphaned, and the user saw "Skipped incomplete bundles" even though the bundle was complete. Devices whose URI happens to embed the name (e.g. primary%3ADictionaries%3A21cen.dict.dz) worked, which is why it reproduced only on some Android devices. The same string-parse also wrote the bundle files (and synced metadata / contentId) under the mangled URI-segment names, so a re-import elsewhere did not dedupe. tauri's Android path.file_name (basename) special-cases content:// / file:// URIs and queries the content resolver for the real DISPLAY_NAME — the same call AppService.openFile already relies on. Resolve the display name once at selection time, store it on SelectedFile.name, and have bundle grouping classify by that name instead of re-parsing the URI. The old extension filter already used basename but discarded the resolved name; threading it through removes that divergence. Also fix the Settings -> Dictionaries "+" badges (Import Dictionary / Add Web Search) collapsing to a black spot in e-ink mode by adding eink-inverted, mirroring the font import button (#4454). Fixes #4489 Fixes #4472 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9dc41e7adf |
feat(reader): reference page numbers from EPUB page-list with manual page count fallback (#4549)
Add a 'Reference Pages' reading progress style that shows physical book page numbers in the footer progress info: - When the book carries a page list (EPUB3 nav page-list or EPUB2 NCX pageList — foliate-js already parses both and resolves the current pageItem on relocate; it was just never consumed), display the current page label and use the highest numeric label as the total, so a trailing roman-numeral index page can't corrupt the total (#672). - When the book has none, a per-book 'Reference Page Count' input appears; the reading fraction is mapped linearly onto the entered count (#4542). The count is saved per book only and never propagates to global view settings. - Falls back to percentage display when neither source is available. Verified with the sample books from #672: Caleb's Crossing (EPUB3 page-list, 419 pages) and Count Zero (EPUB2 NCX pageList/page-map, 346 pages — chapter 2 lands exactly on page 22 per its page-map), plus a stripped no-pagelist copy for the manual-count path (175/350 at 50%). Closes #672 Closes #4542 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b07c9eb631 |
fix(eink): make Custom Fonts panel readable in e-ink mode (#4454) (#4464)
The selected custom-font card used `bg-primary/50`, whose opacity suffix dodges the e-ink `.bg-primary` normalizer — leaving a dark primary fill under force-black `text-base-content` text, i.e. black-on-black (#4454). Add `eink-bordered` so the selected card gets the same white-bg / black-border / black-text treatment every other selected surface gets, while staying distinct from the faint-bordered unselected cards. The Import Font "+" badge had the same class of bug: e-ink's substring matchers catch its `group-hover:bg-base-content` and `text-base-content/60` utilities and paint a black glyph on a black circle. Pin the badge to an intentional base-content circle with a base-100 glyph so the "+" stays legible. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c23c21d37d |
fix(kosync): reflowable conflict comparison via local CFI; scrolled-mode + library fixes (#4367)
* feat(kosync): compare reflowable conflicts via locally-resolved CFI percentage KOReader reports progress as a percentage from its own pagination, which isn't directly comparable to Readest's progress. For reflowable books, resolve the remote XPointer to a local CFI and compute the equivalent fraction (getRemoteLocalFraction), comparing that against the local percentage and falling back to the reported percentage only when it can't be resolved locally (non-XPointer progress or a missing section). The resolved fraction also drives the conflict-dialog remote preview so the shown value matches what was compared. Loosen the conflict threshold to 0.01 when the remote progress was last pushed from this same device (remote.device_id === local deviceId), so sub-page drift between a push and the next pull doesn't prompt. Render sync percentages with 2 decimals via formatProgressPercentage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): correct scrolled-mode reopen drift over background-image sections Bump the foliate-js submodule to include the scrolled-mode reopen drift fix for sections with background images, and add a browser regression test plus its EPUB fixture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(library): redirect to login on pull-to-refresh when signed out Guard the pull-to-refresh handlers so an unauthenticated user is sent to the login screen instead of attempting a library pull and OPDS subscription check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(memory): add kosync conflict + toc/scrolled-restore notes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): prevent CFI crash on inert-only section bodies Reopening/paginating across a background-image or otherwise content-less section could crash with "Cannot destructure property 'nodeType' of 'param' as it is undefined" in foliate's fromRange, aborting the relocate so the reading position was never saved. Bumps the foliate-js submodule to 569cc06 (visible-range walker skips cfi-inert skip-links; isTextNode/isElementNode are null-safe) and adds a regression test reproducing the exact crash. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(kosync): keep auto-push working when a pull finds no real conflict In the 'prompt' strategy, pullProgress set syncState to 'conflict' unconditionally on every pull that returned remote progress, even when promptedSync found no actual difference. Since auto-push only runs while 'synced', and a pull fires on every book-open and window re-activation, progress stopped being pushed. promptedSync now returns whether a real conflict was surfaced, and pullProgress only stays in 'conflict' for genuine conflicts (otherwise 'synced'). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): show most recent sync time and reorder settings tabs Library settings menu now reports the latest of the book/config/note sync timestamps as "Synced …" instead of only the books timestamp. Reorder the settings tabs so Integrations precedes TTS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
78794499a2 |
fix(dictionary): correct System Dictionary platform gating on web and iPad (#4362)
* fix(dictionary): keep other dictionaries usable when System Dictionary syncs to an unsupported platform `dictionarySettings.providerEnabled` is whole-field synced across devices, so enabling System Dictionary on macOS/iOS sets the flag on web/Linux/Windows too. There the row is hidden and the feature is a no-op, but the settings UI read the raw flag and locked every other dictionary's toggle read-only. Gate the lock on `isSystemDictionaryEnabled(settings)` — the same platform-aware check the annotator uses — so it matches real lookup behavior and never triggers where the system dictionary can't run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dictionary): dispatch system dictionary handoff by native OS (fixes iPad) iPadOS sends a desktop "Macintosh" user agent, so the UA-based `getOSPlatform()` reported iPad as 'macos' and the handoff invoked the macOS-only `show_lookup_popover` Rust command that iOS never registers ("Command show_lookup_popover not found"). Derive the OS from the app service's `is*App` capability flags (sourced from the Tauri OS plugin, correct on iPad) via a new synchronous `getInitializedAppService()` accessor, so iPad routes to the iOS plugin command path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ui): constrain reader View Options dropdown to h-8 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(agent): note System Dictionary platform-detection and synced-flag patterns Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
36e11de332 |
feat(reader): swipe-to-adjust brightness gesture on mobile (#3021) (#4356)
* docs: design spec for gesture-based brightness control (#3021) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: revise brightness-gesture spec per /autoplan review (#3021) CEO+Design+Eng dual-voice review. Key fixes: capture-phase listener (bubble-phase could not suppress foliate paginator), opt-out toggle, 18px threshold, selection guard, brightness seed race, rAF teardown, e-ink stepped overlay, contrast capsule, perceptual curve reuse, listener-level test harness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): swipe-to-adjust brightness gesture on mobile (#3021) Left-edge vertical swipe adjusts screen brightness on iOS/Android, with a Sun-icon progress overlay. Capture-phase non-passive listener suppresses the foliate paginator / page-flip / UI-toggle handlers; selection guard, strip reservation in scrolled mode, eager brightness seed, rAF throttle + teardown. Opt-out toggle in Settings > Behavior > Device (default on). Perceptual curve shared with the menu slider. Pure-helper + listener-level tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): detect brightness-swipe edge by screenX; i18n + shorter label (#3021) On-device fix: paginated mode lays the iframe doc out as wide side-by-side columns, so clientX/documentElement.clientWidth are document coordinates and a left-edge touch on a later page never fell inside the strip (armed stayed false). Detect with screenX against the parent window width, matching usePagination. Also: translate the two new setting strings across all locales and shorten the toggle description to 'Slide along the left 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> |
||
|
|
3c134380b7 |
feat: add empty state hints and loading indicators for annotations, bookmarks, notes, font import, and Moon+ Reader import (#4338)
* feat: add empty state hints and loading indicators for annotations, bookmarks, notes, font import, and Moon+ Reader import - BooknoteView: show 'No annotation yet' / 'No bookmark yet' when empty - Notebook: show 'No note yet' when no notes/excerpts exist - Annotator: add loading overlay with spinner during Moon+ Reader import - mrexpt: yield to event loop every 5 entries to keep spinner animating - CustomFonts: show in-place loading card during font import, spinner transitions to font name without layout jump * fix(ui): respect e-ink overlay styling and drop dead className branch - Annotator: use modal-box on the mrexpt import overlay so eink picks up the no-shadow + 1px border override automatically. - CustomFonts: collapse importing-card clsx ternary whose branches were identical into a flat className. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
648c35b334 |
feat(reader): add disableSwipe option to disable swipe-to-paginate (#4335)
Issue #4288: users with hardware page-turn buttons (e.g. e-ink readers) want to disable swipe-to-paginate so accidental finger drags during highlight selection don't flip the page mid-annotation. The existing "Tap to Paginate" toggle only covers taps; swipe was always on. - New `disableSwipe: boolean` on `BookLayout` (default `false`, declared right after `disableClick`). - foliate-js submodule bump: paginator gates `#onTouchMove` and `#onTouchEnd`'s snap-to-page on a new `no-swipe` attribute, so native touch behaviour (text selection) stays intact. - `FoliateViewer` sets/removes the `no-swipe` attribute alongside `animated`, and the `ControlPanel` toggle pushes the change to the live renderer so it takes effect without a viewer reset. - The fixed-layout swipe interceptor in `usePagination` also bails when `disableSwipe` is on, covering both reflowable and fixed- layout books. - New "Swipe to Paginate" UI row directly below "Tap to Paginate"; both can be off simultaneously. - i18n: 33 locales translated. Also polish: rephrase the two helper texts under "Read books in place" in `ImportFromFolderDialog`. The previous copy ("Copy no book into the library to save space.") used an awkward double-negative; the new wording is clearer and the locked variant drops "registered as" / em-dash for a plain two-sentence form. i18n updated. Closes #4288 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
93abca8960 |
feat(dict): faster MDict/StarDict import + lazy lookup; raw .dict; UX (#4334)
Make the dictionary import path usable on large bundles and bring the multi-device flow up to par. Import perf - Skip `MDX.create()` at import time. The factory triggers full init — decompresses every key block and sorts millions of keys with localeCompare just to expose the header. Replace with a tiny `readMdxHeader()` that only reads the small XML header for Title / Encoding / Encrypted (saves ~17 s on a 250 MB MDX on web). - `partialMD5`: read the 9 sample slices in parallel rather than sequentially. Each freshly-picked-File slice round-trip on Chrome costs ~100 ms cold; parallelisation collapses 9 of them into one. - Native fast-path in `nativeAppService.writeFile`: when the source is a `NativeFile`, delegate to Tauri's `copyFile` rather than streaming the file through `NativeFile.stream()`. Streaming a 250 MB body through 1 MB IPC chunks on Android took ~100 s; native copy is bound by disk throughput instead. Exposes `NativeFile.getNativeLocation()` for the FS layer to use the underlying path + baseDir directly. Lazy lookup - Pass `lazy: true` to `MDX.create` / `MDD.create`. The js-mdict change in this PR skips the upfront decompress-every-block + sort during init (~80 s on the same 250 MB bundle) and decodes only the relevant key block on demand per lookup. First-lookup main-thread block drops from ~81 s to ~230 ms. (Closes #4228.) Raw .dict - Drop the import-time gate that flagged non-gzip dict bodies as `unsupported`. The runtime body loader (`loadDictBody`) already probes the gzip header and falls through to a passthrough buffer for raw files, so the gate was the only thing preventing raw `.dict` bundles from importing on devices that received them via cloud sync. (Closes #4179, partially addresses #4248.) Import-flow UX - `handleImport` now always surfaces a toast for every non-cancelled attempt: picker errors, missing app service, no-op imports, and unsupported-but-imported bundles each get their own message instead of failing silently. - Call `markAvailableByContentId(newDict.contentId)` after add/replace so the "Bundle is missing on this device" warning clears immediately — no need to close-and-reopen the panel. System Dictionary - Drop the cascading toggle behavior in `setEnabled`. Each provider's enabled flag persists independently; exclusivity is enforced at lookup time. Toggling System on/off no longer wipes the user's preferred set of in-app providers. - Render non-system rows as read-only when System is on (toggle still shows what's queued to restore; tooltip explains the lock). - `isSystemDictionaryEnabled` short-circuits to `false` on platforms where the handoff isn't implemented. `providerEnabled` is whole- field synced across devices, so a flag set on macOS would otherwise leak to a Windows device with no way to look up a word. js-mdict - Submodule bump to e6dbc99 which adds the opt-in `lazy: true` `MDictOptions` flag (skip `_readKeyBlocks` + post-init sort; new `lookupKeyBlockByWordLazy` path on `MDX` and `MDD`). Eager mode is unchanged and every existing js-mdict test still passes. i18n - 208 new translations across 33 locales for the new UX strings. Closes #4228 Closes #4248 Closes #4179 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
ff605e000d |
feat(library): in-place import from registered external folders (#4315)
* feat(library): in-place import with cloud sync and symmetric local delete
Adds an `inPlace` option to importBook so a source file inside a
registered external library folder is referenced directly via
`book.filePath` instead of being copied into Books/<hash>/. Sidecars
(cover, config, nav) still live under Books/<hash>/.
ingestService routes through shouldImportInPlace, which marks an
import in-place when the absolute source path lives under any of
`settings.externalLibraryFolders` and is NOT inside a per-root
`Books/` subtree. The Readest data dir (`customRootDir`) is
intentionally excluded — that directory is Readest's home and
should freely hold hash copies; in-place is for user-registered
roots (Duokan, Calibre, Moon+ Reader, an iCloud mirror, …).
Cloud sync treats in-place books as first-class:
- uploadBook reads bytes from (book.filePath, 'None') when set.
The cloud key is unchanged, so a peer downloading the book
lands it under Books/<hash>/ as a normal hash copy.
- useBooksSync strips `book.filePath` before pushing — it is a
device-local path that is meaningless on any other device.
- ingestService no longer skips upload for in-place books;
autoUpload / forceUpload behave like any other book. Only
transient imports opt out.
- deleteBook 'local'/'both' now physically removes the source
file at book.filePath (base 'None'). Local-delete semantics
are symmetric with hash-copy books: the local copy is gone,
the cloud backup remains, a future pull restores under
Books/<hash>/. removeFile errors are swallowed.
New `SystemSettings.externalLibraryFolders?: string[]` (no UI yet;
registration entrypoint lands in a follow-up). Added to
BACKUP_SETTINGS_BLACKLIST alongside `localBooksDir` /
`customRootDir` so device-local paths don't ride cloud backups.
Tests: cloud-service, ingest-service, and backup-settings suites
cover in-place delete, multi-root matching, per-root `Books/`
guard, and the backup-strip.
* feat(library): one-tap "read in place" toggle in folder import
Surface the in-place / copy choice as a single "Read books in place (don't copy)" checkbox in the Import-from-Folder dialog. When the user opts in, the chosen directory is registered in `settings.externalLibraryFolders` and ingestService's `shouldImportInPlace` will route the books straight to importBook with `inPlace: true` — no copy into Books/<hash>/, sync still works, local delete still removes the source file (the symmetry was set up in the previous in-place commit).
User experience:
- First-time users hit the toggle once per library folder. The choice is also persisted to localStorage so subsequent dialog opens default to whatever they picked last.
- Repeat imports from a folder that's already registered as an external library folder force the toggle ON and disable it, with a help line explaining that imports from this folder are always in-place. The check is exact-string (after path normalization) so registering /Users/me/Duokan only locks the toggle for that exact path — picking /Users/me/Downloads after Duokan still shows the toggle in its normal state.
- URL-ingress / drag-drop replays go through `runFolderImport` without the dialog and default `readInPlace: false`. They still benefit from in-place automatically when the dropped path lives under an already-registered root, because that decision is made by `shouldImportInPlace` based on settings, not by the dialog flag.
Mechanics:
- ImportFromFolderResult gains `readInPlace: boolean`. ImportFromFolderDialog gains an `initialReadInPlace` prop (seeded from the new `readest:lastImportFolderReadInPlace` localStorage key) and an `isRegisteredExternalRoot` predicate it uses to render the locked / unlocked toggle.
- runFolderImport calls a new `registerExternalLibraryFolder` helper that appends the chosen directory to `settings.externalLibraryFolders` and persists settings, but only when `result.readInPlace` is true. `isRegisteredExternalRoot` does the inverse lookup the dialog needs. Both helpers normalize paths the same way `shouldImportInPlace` does so the predicate matches the ingest layer.
- The new feature has no effect for users who never flip the toggle: `externalLibraryFolders` stays empty, the path-prefix check in `shouldImportInPlace` returns false for every import, and books continue to be copied into Books/<hash>/ exactly as before.
Self-healing for externally-removed in-place books:
Once the dialog lets users opt their library into in-place mode, the source file becomes a piece of state Readest doesn't control — another app may rewrite it (e.g. Duokan persisting reading progress into the epub), the user may move it in Finder, or an external drive may unmount between sessions. Previously, clicking such a book would navigate into the reader, fail inside loadBookContent's `fs.openFile(book.filePath, 'None')` with a low-level IO error, flash an "Unable to open book" toast, and auto-bounce back to the library — leaving the stale library record in place so the next tap reproduces the same dance.
BookshelfItem.handleBookClick now probes availability before navigating, but only for purely-local in-place books (`book.filePath && !book.uploadedAt && !book.deletedAt`). If `appService.isBookAvailable` returns false — which for in-place books means the recorded `book.filePath` no longer exists at the OS level — we dispatch `delete-books` for that hash and show an info toast explaining the removal, instead of opening the reader.
Scope is intentionally narrow:
- Cloud-synced books still flow through `makeBookAvailable`'s on-demand download path; missing local copies trigger a re-download, not a deletion.
- Hash-copy books (no `filePath` set) are not probed: a missing Books/<hash>/ file under normal use signals a bug or filesystem corruption, not user intent, and silently dropping the record would hide the real problem.
- The dispatched delete-books event reuses the existing Bookshelf deletion path, so sidecar metadata and selection state are cleaned up the same way as a user-initiated delete. For in-place books that path doesn't touch any file outside Books/<hash>/, so the now-missing source location (or whatever the user did with it externally) is left alone — symmetric with 165f15a6.
* fix(library): centralize book content resolution
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
|
||
|
|
4a5674ef4f | chore: bump turso to version 0.6.1 (#4312) | ||
|
|
e0ce6c8c22 | feat(reedy): Appendix A · Phase 4 — custom thread UI on AgentRuntime (#4308) | ||
|
|
6bc4a96b99 |
feat(reedy): Phase 1B — wire Reedy into the chat, settings, and Sources UI (#4296)
* feat(reedy): wire RetrievalBackend interface + metrics into the chat adapter
Phase 1B backend integration. Adds a RetrievalBackend interface so
TauriChatAdapter holds a uniform reference instead of branching across the
file, with two impls — LegacyIdbBackend (wraps the existing IDB ragService
unchanged) and ReedyBackend (lazy-opens reedy.db, adapts the active
provider's embedding model to Reedy's narrower shape, exposes a Vercel
`lookupPassage` tool). selectBackend() gates Reedy behind both
aiSettings.reedy.enabled AND isTauriAppPlatform() per plan D15 so the MVP
cohort is desktop-only.
The Reedy path streams via streamText({ tools: { lookupPassage }, stopWhen:
stepCountIs(3) }) with a status-aware system prompt that tells the model
how to phrase responses for each RetrieverStatus value.
Replaces the module-global `lastSources` + 500ms poll with a per-instance
ReedySourceStore keyed by a synthetic per-turn id the adapter generates,
so the Sources dropdown stops racing on global state. Both legacy and
Reedy backends now feed citations through the same store; the UI is
backend-agnostic via a shared SourceItem shape both ScoredChunk and
RetrievedChunk satisfy.
Adds the reedy_metrics table to the reedy migration (versioned with
app_version + session_id + turn_id per row) plus a ReedyMetrics writer
that ReedyBackend uses to record indexing-lifecycle and tool-use events.
Always-on local; no network egress. NoopReedyMetrics keeps construction
cheap before the DB opens.
Tests: retrievalBackend selectBackend gates, ReedySourceStore semantics
(append/replace/subscribe/clear), ReedyMetrics debounced batching +
exportBundle, and a TauriChatAdapter contract test that asserts the
Reedy/legacy code-paths pass the right args to streamText.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reedy): UI wiring — settings toggle, clickable sources, feedback bundle
Phase 1B UI integration. AIAssistant now constructs the active backend
(legacy or Reedy) via selectBackend with the platform gate from M1.7,
wires a ReedySourceStore for the chat adapter, and routes Sources-dropdown
clicks to `getView(bookKey)?.goTo(source.cfi)` when the source has a CFI.
Legacy-path sources still render as static rows because they have no CFI.
AIPanel grows a 'Reedy Retrieval (Beta)' BoxedList with the toggle and a
'Send Reedy feedback' button that calls exportReedyMetricsBundle and
triggers a JSON download of the last 90 days of events. The toggle is
disabled on web with an explanatory description per plan D15.
Thread accepts an onSourceClick callback and renders each source as a
button when its source carries a CFI, otherwise as a static div — so the
Sources dropdown is backend-agnostic via the shared SourceItem shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
98049282eb |
feat(ai): add OpenRouter provider and unify provider HTTP transport (#4289)
* OpenRouter: new OpenAI-compatible provider with chat + embedding model routing, health check via /models, and a fetchOpenRouterModels() helper for the settings UI. API key, base URL and model fields are persisted in AISettings, surfaced in AIPanel, indexed by commandRegistry, and added to backupService's credential allow-list so the key round-trips through encrypted backups. * utils/httpFetch: introduce getAIFetch() as the single decision point for outbound AI traffic. In Tauri it returns @tauri-apps/plugin-http's fetch (Rust/reqwest transport, no renderer CORS preflight, no Android cleartext block); on the web build it falls back to window.fetch. OllamaProvider is migrated end-to-end — both ai-sdk-ollama streaming and the /api/tags health probe — and the new OpenRouterProvider uses the same path, so any future provider only has to call getAIFetch(). * Tests: unit tests for OpenRouter provider behavior (model selection, availability, health check) and a backup-settings round-trip test ensuring openrouterApiKey is treated as a credential field. |
||
|
|
c5384b2a6b |
fix: respect Android Back / Esc inside Settings sub-pages and Import-from-Folder dialog (#4286)
* fix(library): cancel Import-from-Folder dialog on Android Back / Esc The dialog was previously relying on <Dialog>'s built-in `native-key-down` listener to handle Back / Escape, but `useKeyDownActions` (used here for the Enter-to-confirm shortcut) registers its own sync listener that returns `true` on every Back keypress, consuming the event before <Dialog> ever sees it. As a result Android Back and Escape were silently swallowed inside this dialog. Wire `onCancel` so the hook actually performs the cancel itself, and guard it (like Enter) while a folder pick is in flight to avoid canceling mid-pick. * fix(settings): step back to parent panel on Android Back / Esc inside sub-pages Several settings panels render an in-place sub-view based on local state (FontPanel -> Custom Fonts, LangPanel -> Custom Dictionaries, IntegrationsPanel -> KOSync / WebDAV / Readwise / Hardcover / OPDS / Send-to-Readest). Pressing Android Back (or Escape) while one of these sub-pages was open used to close the entire Settings dialog because only <Dialog>'s own `native-key-down` listener handled the event. Mount a `useKeyDownActions` hook at each parent panel, gated on the sub-page being open, that calls the existing "go back" handler and consumes the event. Because `dispatchSync` walks listeners LIFO, the panel-level hook (registered after <Dialog>'s) claims Back first while a sub-page is open; once the sub-page is closed the hook is disabled and Back falls through to <Dialog> as before, closing the whole Settings dialog. This keeps all logic in the three parent panels — no changes needed to the seven sub-page components — and a single hook in IntegrationsPanel covers all six integrations sub-pages. |
||
|
|
5e366018df |
fix(cbz): ComicInfo metadata + CBZ page count + WebDAV i18n (#4282)
* fix(cbz,i18n): ComicInfo metadata + CBZ page count + WebDAV i18n Closes #4253 (ComicInfo.xml not read) and #4255 (CBZ shows "1 page left"). CBZ / ComicInfo (foliate-js submodule + Readest derivation): - comic-book.js: find ComicInfo.xml in subdirectories too, parse description / subject / identifier / published / series fields beyond the prior name+position pair. Series Count populates the canonical `belongsTo.series.total`; no top-level duplication. - bookService.ts / readerStore.ts: derive `metadata.seriesTotal` from `belongsTo.series.total` in parallel to the existing series / seriesIndex derivation. - ProgressBar / FooterBar / DesktopFooterBar: drop the hard-coded `pagesLeft = 1` for fixed-layout books and compute it from `section.total - section.current`. FooterBar uses `FIXED_LAYOUT_FORMATS.has(bookFormat)` so CBZ picks `section` (correct image count) instead of `pageinfo` (locations). - ProgressBar: switch the remaining-pages text to "in book" for fixed-layout titles (no chapter structure) and keep "in chapter" for reflowable books. WebDAV refactor for translation coverage: - WebDAVBrowsePane / SyncHistoryPanel called `t(...)` (passed as a prop) instead of `_(...)`. The i18next-scanner only looks for `_`, so ~53 strings were unreachable and shipped in English to every locale. Switched both components to call `useTranslation()` themselves; helpers that aren't React FCs take `_: TranslationFunc` so the scanner sees the literal calls. - WebDAVClient.checkConnection now returns a `code` discriminator (`SERVER_URL_REQUIRED` / `AUTH_FAILED` / `ROOT_NOT_FOUND` / `UNEXPECTED_STATUS` / `NETWORK`); raw English `message` is reserved for the dev console. New `formatConnectError` and `formatSyncError` helpers in WebDAVForm translate via a switch where each branch is a literal `_('...')`. Same treatment for the sync-failure path that previously surfaced raw e.message. - "Syncing 0 / {{total}}" is now parameterized as "Syncing {{n}} / {{total}}" with n=0 at startup so the digit formats naturally and the template can be reused mid-sync. - "Cleanup · {{count}} book(s)" hard-coded options used unsupported ternary; rewrote as plural-aware key. i18n scanner fix (i18next-scanner.config.cjs): - vinyl-fs walked into directories whose names end in source-file extensions (Next.js route folder `runtime-config.js/`, Playwright screenshot folder `*.test.tsx/`) and crashed with EISDIR. Resolved by expanding globs via `fs.globSync` and filtering to files only before handing to the scanner. TypeScript-syntax sites that broke esprima during extraction: - WebDAVBrowsePane / WebDAVForm: `(e as Error).message` and `failed[0]!.title` inside `_(..., options)` arguments. Replaced with `e instanceof Error ? e.message : String(e)` and `failed[0]?.title ?? ''` — also runtime-safer. User-facing em-dash cleanup: - Removed em-dashes from translation keys across SyncHistoryPanel / WebDAVForm / WebDAVBrowsePane / SyncPassphraseSection / send/page / replicaCryptoMiddleware / AIPanel. Tagline in `layout.tsx` kept. Locale translations: - ~2400 translations applied across all 33 locales for the keys that were either newly extractable, freshly worded, or pre-existing but untranslated. Zero `__STRING_NOT_TRANSLATED__` remain after the run. Misc: - next.config.mjs: drop `eslint.ignoreDuringBuilds: true` so build runs the same lint as CI. - Collection type: add `total?: string` for ComicInfo series count. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(test): fix vitest invocation, run with 4 workers `pnpm test:pr:web` was chaining `pnpm test -- --watch=false`, which pnpm expanded into: dotenv -e .env -e .env.test.local -- vitest -- --watch=false The second `--` made vitest treat `--watch=false` as a positional file pattern, not a flag. Vitest then fell back to defaults (in CI's non-TTY env that still meant a one-shot run, so the suite passed), but the worker pool was effectively serialized for big chunks of the 243-file run — wall ~90 s on a 4-vCPU runner where the parallel-sum of phases was ~236 s (≈2.6× effective parallelism). Replace the chained pnpm invocation with a direct call to `vitest run --maxWorkers=4`, matching the 4 vCPUs the GH Actions ubuntu-latest runner provides. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
b78daed562 |
feat(send): gate email-in to Plus, Pro, and Lifetime plans (#4280)
Email-in (`<user>@readest.com`) is now a paid feature. The other
Send channels — in-app /send page, mobile share-sheet, browser
extension — stay open to free users.
Three enforcement layers:
- `pages/api/send/address.ts` and `pages/api/send/senders.ts` return
403 with `{ code: 'plan_required', plan, requiredPlans }` for free
users. No `send_addresses` row is allocated on the blocked path.
`pages/api/send/inbox.ts` and `pages/api/send/inbox/file.ts` are
deliberately left open — they're shared with the file-upload and
extension channels.
- `workers/send-email` looks up `plans.plan` after resolving the
recipient and bounces (not silently drops) inbound mail for free
users with a one-sentence message pointing to upgrade plus the free
clip channels. Bounce rather than drop so a downgraded user
understands why their mail stops landing.
- `components/settings/integrations/SendToReadestForm.tsx` reads the
user's plan from the JWT before any API call. Free users see one
friendly card — headline, value prop, "View plans" CTA → /user, and
a softer line about the free alternatives — instead of address /
senders / activity sections of disabled controls. The
IntegrationsPanel NavigationRow stays visible so users can discover
the feature.
Single source of truth for the entitled tier set: `EMAIL_IN_PLANS` +
`isEmailInPlan(plan)` in `src/utils/access.ts`. Mirror copies live in
the Worker (no shared import surface) — keep them in sync.
Edge cases:
- Downgraded user: existing `send_addresses` row stays. All three
layers block; re-upgrading silently restores the same address.
- Loading flicker: `userPlan` starts as `null` so the loading skeleton
stays up rather than briefly flashing the upgrade card for a paid
user on a slow client.
12 new unit tests cover the gate on `/api/send/address` and
`/api/send/senders` (GET + POST blocked for free users, no Supabase
access on the blocked path, allowed for plus / pro / purchase).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
5c82351ab9 |
feat(integrations): add WebDAV sync to Reading Sync settings (#4204)
* feat(integrations): add WebDAV sync to Reading Sync settings Adds a WebDAV entry under Settings -> Integrations -> Reading Sync with configure/browse UI, library-wide Sync now, and per-book sync of progress, annotations and (opt-in) book files + covers. Reading progress and annotations are always synced when WebDAV is enabled; only Sync Book Files stays as a toggle since it's bandwidth-heavy. * feat(webdav): add diagnostic sync history panel and document viewSettings invariant Surface a per-run history for the WebDAV "Sync now" button so users can self-triage failures without rummaging through the dev console — a screenshot of the panel is now enough to file a useful bug report. The same change tightens the docs around viewSettings so the "device-local UI preferences" boundary is impossible to misread on the next refactor pass. Sync history panel: * New WebDAVSettings.syncLog ring buffer (cap 10), persisted alongside the rest of settings so a screenshot survives across app restarts. WebDAVSyncLogEntry captures startedAt, finishedAt, status (success / partial / failure), trigger, the eight counters from SyncLibraryResult, the toast text, and an optional per-book failure list with a phase tag (download / upload-config / upload-file). * SyncLibraryResult gains a failedBooks: SyncFailureEntry[] field. The two existing failure points in syncLibrary (download catch, upload catch) now record per-book reason+phase via formatFailureReason(), which keeps the persisted blob small by stripping stacks/whitespace and capping length at 200 chars. * WebDAVForm.handleSyncNow now timestamps the run, builds an entry from the result on success/partial paths and from the caught error on failure paths, and appends through a fresh-read appendSyncLogEntry() so concurrent toggle changes can't clobber the log. * New SyncHistoryPanel + SyncStatusBadge + SyncHistoryDetails components render the log inline in the Settings page. The detail row groups counters into three semantic columns (activity, skipped, outcome) on a six-column grid so labels can wrap freely while numbers stay tabular and right-aligned. Per-book failures render as a separate stack below the counters. viewSettings invariant: * buildRemotePayload and pullBookConfig already implement the right thing — only progress/location/xpointer/booknotes travel; viewSettings stays device-local. Comments now spell out the contract on both sides so future contributors don't reintroduce viewSettings on the wire by mistake. * fix(webdav): preserve prior state across reconnect, drop stale closure in ensureDeviceId Two bugs in the WebDAV sync flow surfaced during review: 1. WebDAVForm.handleConnect rebuilt the entire `webdav` settings block from the four credential fields the user just typed, dropping `deviceId`, `syncBooks`, `strategy`, `syncProgress`, `syncNotes`, `lastSyncedAt`, and `syncLog` on every reconnect. Most concerning is the deviceId rotation: a disconnect + reconnect made the next sync look like a brand-new device, defeating the cross-device clobber detection encoded in `RemoteBookConfig.writerDeviceId`. Extract a pure helper `buildWebDAVConnectSettings` that spreads the previous webdav object first so reconnect is non-destructive, matching the sibling pattern in KOSyncForm. 2. useWebDAVSync.ensureDeviceId merged the new deviceId into the closure variable `settings`, which can be stale when `pullNow → pushNow` fires back-to-back on book open or when the settings panel writes a sibling field concurrently. Read latest settings via `useSettingsStore.getState()` to match the pattern already used in `updateLastSyncedAt` and `persistWebdav`. Adds three unit tests for the new helper, including the reconnect preservation invariant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(webdav): address review observations on encodePath, pull skip, and remote GC Three follow-ups from the review pass on top of 3f721d04. Each one was called out as a smaller observation the reviewer noted but did not push: * WebDAVClient.encodePath silently re-escaped literal % characters despite a comment claiming existing %-escapes are preserved. A caller that pre-encoded a space as %20 would see %20 become %2520 in the request URL, breaking any path that came in already escaped. Tokenise each segment into already-escaped %XX runs and everything-else, and only run encodeURIComponent on the latter. Add four unit tests exercising pure-unicode, pure-pre-escaped, mixed, and root-slash paths. Implementation note: two regexes are needed because a /g RegExp.test is stateful and would skip every other token in this map; the split regex has /g for the iteration, the classifier regex is anchored without /g for the per-token check. * OPEN_PULL_SKIP_MS doc-comment claimed it catches the close-then-reopen flow, but useWebDAVSync unmounts on reader close so lastPulledAtRef resets to 0 — the new instance always passes the cooldown check on remount. The guard actually only fires on re-invocations of the open-book effect inside one hook lifetime (book-to-book navigation, double-render before hasPulledOnce flips). Rewrite both the constant's doc-comment and the call-site comment to match the real semantics. * WebDAVSync push path doesn't DELETE the per-hash directory of a tombstoned book. The deletion *is* propagated through library.json so other devices hide the book, but storage on the WebDAV server grows monotonically. Add a TODO at the pushLibraryIndex call with a sketch of what a future garbage-collection sweep would need (a per- device acknowledgment field on RemoteLibraryIndex so we don't wipe data a peer hasn't seen the deletion for yet). * refactor(webdav): extract WebDAVBrowsePane and SyncHistoryPanel from WebDAVForm The WebDAV settings form was nearing 1500 lines and hosted three loosely related surfaces — credential entry, sync controls + manual trigger, and the in-app file browser — that didn't share much state. Reviewer flagged it as a refactor candidate; this commit does the actual split. * WebDAVBrowsePane (new, 534 lines): owns currentPath, the directory listing, per-entry download status, the navigation handlers and the per-file icon / filename helpers. Reads credentials from the settings prop and otherwise reaches for envConfig / useLibraryStore / useAuth itself rather than threading them through props (matches how the rest of the integrations panels are wired). * SyncHistoryPanel (new, 293 lines): the diagnostic history surface plus its three private helpers (SyncStatusBadge, formatSyncSummary Line, formatSyncTimestamp, SyncHistoryDetails). Moved verbatim from the inline definitions at the bottom of WebDAVForm — the component was already presentation-only and accepting the translation fn as a prop, so no API change. * WebDAVForm (676 lines, down from 1456): keeps the mode switch (configured vs. not), the credential form, the sync sub-controls (Upload Book Files / Sync Strategy / Sync now button), and the large handleSyncNow effect — those last two are intrinsically tied to the settings store and would have just been pushed back up the prop chain by any extraction. The standalone SyncHistoryPanel and WebDAVBrowsePane are now mounted as siblings inside the configured branch. No behavioural change — both new files run the same effects, build the same JSX, and read/write the same store fields as before. All existing webdav-related unit tests still pass. Resolves the last of the reviewer's smaller observations on 3f721d04 (file length). * fix(webdav): stream book uploads to avoid renderer OOM on large files Both syncLibrary (manual Sync now in WebDAVForm) and useWebDAVSync (per-book auto/manual sync triggered on book open) materialised the full book binary as an ArrayBuffer in the V8 heap before PUTting it. With multi-hundred-megabyte PDFs / scanned books, the renderer either accumulates buffers across sequential pushes (library sync) or blows its heap ceiling on a single book (per-book sync), surfacing as a blank white screen on desktop and a binder-OOM kill of the WebView on Android. Add a BookFileStreamingLoader option to pushBookFile that, on Tauri targets, hands the file path off to tauriUpload's Rust-side streamer so bytes never enter JS. The HEAD short-circuit is shared across both paths, so steady-state syncs still cost a single round-trip per book. Web targets keep the buffered fallback (no streaming HTTP primitive available there). Wire the streaming loader through SyncLibraryOptions.loadBookFileStreaming for the library Sync now path, and inline it in useWebDAVSync.pushBookFileNow for the per-book path. Covers stay on the buffered loader — they're capped at a few hundred KB and don't justify widening the API. * fix(webdav): keep Sync now state alive across Settings navigation/close WebDAVForm tracked the library-wide Sync now run in component state, so any navigation that unmounted the form (drilling back to the Integrations list, or closing the SettingsDialog entirely) destroyed the in-flight indicator while syncLibrary's promise kept running off-thread. On return the user saw a re-enabled button with no progress affordance, an empty Sync History (until the run finally finished), and could trigger a second concurrent syncLibrary against the server. Hoist isSyncing / progressLabel into a process-local zustand store (webdavSyncStore) and consume it from WebDAVForm. The store outlives any single mount, so re-mounting the form picks up the running sync's state on first render — button stays disabled, progress label keeps ticking, and the re-entrancy gate (now reading the live store rather than a stale closure) blocks duplicate clicks. Also surface 'Syncing…' in the IntegrationsPanel row so users get the cue without drilling into the sub-page. Not persisted: the store dies with the renderer, which is the right semantic — a sync killed by app exit shouldn't look like it's still going on next launch. * feat(webdav): cleanup mode for orphan book directories on the server WebDAV pushes set Book.deletedAt as a tombstone but never DELETE the per-hash directory on the server, so the remote Readest/books/ tree accumulates dead entries from books the user deleted long ago. Add a dedicated cleanup mode in the WebDAV browser to evict them in batch. Cleanup mode is reached via a new sweep button next to Refresh. Entering it pins the listing to Readest/books/, filters down to directories whose local Book carries deletedAt, and replaces the per-row icon with a checkbox. The footer carries a single right-aligned Delete from server action; selecting one or more rows and clicking it sends a confirm dialog (appService.ask, so it actually blocks on Tauri) and then runs sequential DELETEs against the server. Each row splices out of the listing the moment its DELETE returns, so the listing itself is the progress indicator; the button keeps a stable width by always reserving space for the spinner via the invisible class. The local library is left untouched. Book.deletedAt is the authoritative deletion signal in readest's sync model — clearing or rewriting it here would cause sibling devices to either resurrect the book or lose the deletion event. Restore is therefore not offered: the per-entry download button already provides full recovery (tauriDownload + ingestFile streams the file back, ingestFile clears deletedAt as a side-effect, and the next sync round-trip merges remote progress and notes), and a metadata-only restore would leave users staring at unopenable shelf rows whenever the bytes had been GCed off local disk. Browse mode is friendlier too. Per-hash subdirectory rows under Readest/books/ resolve their hash to the local library's title and short-form hash for skimmability; soft-deleted entries get a folder-off icon plus a 60% dimmed title (a redundant signal for touch platforms where the desktop-only hover tooltip doesn't fire). Cleanup runs are persisted into the existing sync history with a kind: 'cleanup' discriminator and a booksDeleted counter, so destructive batch operations are auditable alongside regular Sync now runs without polluting the common case (the new counter is zero-suppressed on plain sync entries). * test(webdav): cover deleteDirectory and deleteRemoteBookDir Pin the contract of the cleanup-mode delete plumbing: HTTP method, Depth: infinity header, Authorization header and target URL on the low-level deleteDirectory; success/failure/auth-failure routing and per-hash path construction on the high-level deleteRemoteBookDir. Status-code semantics are exercised end to end (200/204 ok, 404 idempotent, 401/403 AUTH_FAILED, 5xx generic, network throw NETWORK), so a future refactor can't silently drop the explicit Depth header or merge the auth-failure path into the per-book result struct without tripping a regression. --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a30efe49c1 |
fix(send): make recent-activity status labels translatable (#4236)
The labels (Added to your library / Waiting to be processed / Processing… / Failed) went through `_(activityStatusLabel(item.status))`, a dynamic key the i18next-scanner cannot extract — so non-English locales rendered them in English. Inline the four literal `_()` calls into the JSX so the scanner picks them up. Translates the two missing keys in all 33 non-English locales. Also sweeps three pre-existing untranslated System Dictionary keys that were introduced in #4219. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
0b18de0581 |
feat(send): Send to Readest — multi-channel capture into your library (#4230)
* feat(send): Send to Readest — multi-channel capture into your library A Send-to-Kindle equivalent: email, web-upload, share, or one-click capture books and articles into the cloud library; they sync to every device. Architecture (client-side processing): out-of-app channels drop a raw payload into a per-user send_inbox; Readest clients drain it through one shared ingestService.ingestFile(). The server never parses or converts. - ingestService.ingestFile() — channel-agnostic import orchestration extracted from library/page.tsx (DI-based, forceUpload support). - send_addresses / send_allowed_senders / send_inbox tables + RLS + 4 SECURITY DEFINER claim/lease RPCs (migration 012_send_to_readest.sql). - Conversion subsystem (DOCX/RTF/HTML/article/TXT -> EPUB) in a Web Worker. - send-email Cloudflare Email Worker; inbox-drainer controller + useInboxDrainer hook; /api/send/* routes. - Send to Readest settings panel: inbound address, approved-sender allowlist, recent activity, per-device drain toggle. - /send web page (file drop + article URL) + SSRF-guarded fetch-url proxy. - OS-shared files routed through ingestFile; Manifest V3 browser extension. Security: inbox state changes only via SECURITY DEFINER RPCs (clients get SELECT-only on send_inbox); approved-sender allowlist gates email; SSRF guard on the one server-side URL fetch; inbox payload signed URLs authorize against send_inbox.user_id. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: run format:check in the pre-push hook Biome format checking is fast (~0.4s), so gate pushes on it too — catches mis-formatted files that bypassed the staged-only pre-commit hook before they reach CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(send): address CodeQL security findings - ReDoS (senders.ts): the email regex had ambiguous quantifiers around the literal dot. Rewrote it linear-time (domain labels exclude '.') and cap the input at 254 chars. - XSS (convertToEpub.ts): run untrusted HTML through DOMPurify (sanitizeForParsing — keeps document structure) before DOMParser, so title extraction and Readability never parse executable markup. - SSRF (fetch-url.ts): harden the host guard — block bare single-label hostnames, IPv4-mapped IPv6, CGNAT/benchmark/multicast ranges, and the unspecified address. DNS rebinding stays a documented residual risk. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
05da6bdf43 |
feat(dictionary): add system dictionary provider for macOS, iOS, and Android (#4219)
Hand selected words off to the platform's native dictionary surface when the user opts into the new "System Dictionary" entry under Settings → Languages → Dictionaries. The setting is exclusive: enabling it disables all other providers (and vice versa) so the in-app lookup button either always opens the popup or always invokes the OS — no mixed states. Per platform: - macOS: AppKit's -[NSView showDefinitionForAttributedString:atPoint:] via a top-level Tauri command in src-tauri/src/macos/system_dictionary.rs. Anchored at the selection's bottom-center (CSS pixels mapped into NSView coords), so the inline Lookup HUD appears just below the highlighted text without raising Dictionary.app to the foreground. - iOS: UIReferenceLibraryViewController presented as a half-detent pageSheet on iPhone (medium → large drag-to-expand) and as a formSheet on iPad. Implemented in the native-bridge plugin. - Android: ACTION_PROCESS_TEXT intent with EXTRA_PROCESS_TEXT_READONLY, dispatched without createChooser so users get the standard system disambiguation dialog with "Just once / Always" buttons. Reports unavailable=true when no app handles the intent so the TS layer can silently skip rather than open an empty chooser. Web/Linux/Windows hide the row entirely. The provider is a sentinel — the registry filters it out of the popup tab list (it has no in-popup UI) and the annotator's handleDictionary checks isSystemDictionaryEnabled to dispatch directly to the native bridge before opening the in-app DictionaryPopup. |
||
|
|
a20f68fc11 |
feat(readwise): allow overriding the Readwise sync base URL (#4196)
* feat(readwise): allow overriding the Readwise sync base URL Add an advanced option to point Readwise sync/export at a custom, Readwise-compatible endpoint instead of the hardcoded official API. When the override is unset or blank, behavior is unchanged. - ReadwiseClient resolves a custom `baseUrl` over `READWISE_API_BASE_URL`, trimming whitespace and trailing slashes. - ReadwiseSettings gains an optional `baseUrl` field; it syncs as plaintext via the settings sync whitelist. - ReadwiseForm exposes the URL under a collapsed "Advanced" disclosure on the connect screen, and surfaces a custom URL read-only once connected. Disconnect preserves the custom URL for easy reconnect. Closes #4114 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * i18n(readwise): rename "Sync Base URL" label to "Custom URL" Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
411d3ad687 |
fix: export annotations even without TOC, closes #4186 (#4188)
* i18n(ios): add more localized languages in plist * fix: export annotations even without TOC, closes #4186 |
||
|
|
787bbf2103 |
feat(reader): custom hardware-button page turning (#4177)
* feat(reader): add custom hardware-button page turning (#4139) Lets users bind hardware remote keys (media keys, D-pad/arrow keys) to previous/next page via a learn-mode capture UI in reader settings — an accessibility feature for page-turner remotes. - New global hardwarePageTurner system setting (enabled + key bindings). - hardwareKeys.ts: key normalization, matching, and page-turn resolution. - deviceStore: reference-counted media-key interception + learn mode. - usePagination: flips pages from bound media keys (native bridge) and D-pad/keyboard keys (DOM keydown), scoped to the active book and suppressed while the toolbar is visible. - Page Turner settings section on all platforms; web/desktop bind keys via DOM keydown only, native media-key interception stays mobile-only. - Android: intercept media + learn-mode keys in dispatchKeyEvent. - iOS: forward media keys via MPRemoteCommandCenter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(i18n): add and translate hardware page turner strings (#4139) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(reader): refine hardware page turner (#4139) - Handle book-iframe key events (iframe-keydown messages) so custom bindings work as soon as a book is open, not only after the settings panel has been shown. - Add Previous/Next Section bindings alongside the page bindings. - Rename the hardwareKeys util to keybinding. - Wire the Page Turner section into the settings Reset action. - Drop the focus ring on the capture buttons; BoxedList gains an optional description. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(i18n): translate page turner section and key strings (#4139) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7716f189c3 |
fix(layout): keep header/footer transparent and fixed in scrolled mode, closes #4157 (#4168)
Remove the redundant "Apply also in Scrolled Mode" options for bars and margins so scrolled mode renders the header/footer consistently with paginated mode: transparent, fixed in position, and not obscuring content. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
16ffc17507 | fix(eink): fixed sync toggle styles in eink mode, closes #4155 (#4163) | ||
|
|
772bb73b46 |
ui/ux: codify design system and migrate settings to shared primitives (#4116)
* ui/ux: codify design system and migrate settings to shared primitives Document Readest's design language in DESIGN.md (Adwaita-aligned, e-ink-first, RTL-correct) and migrate every settings panel onto a small set of primitives (BoxedList, SettingsRow, SettingsSwitchRow, SettingsSelect, SettingsInput, NavigationRow, Tips, SubPageHeader). AGENTS.md links to DESIGN.md so contributors land there before inventing new chassis classes. Replace the standalone KOReader/Readwise/Hardcover Config dialogs with a single Integrations panel (Reading Sync + Content Sources sub-pages). The reader's BookMenu now hides each provider until it's configured, and Hardcover's per-book "Enable for This Book" toggle is dropped — there's no auto-sync to gate, so the flag was just extra clicks. Refresh highlight colors (two-trigger swatch + label, translatable default names), background texture / theme color selectors (border-current keeps selection legible on any backdrop), CustomFonts/CustomDictionaries (quiet list-extension style + shared Tips primitive), the OPDS catalog manager (debounced auto-download, right-aligned Browse), Set PIN, and the KOSync conflict resolver. Translate the ~30 new strings across all 33 locales. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ui/ux: responsive typography, OPDS card polish, deep-link return paths Restore the .settings-content responsive cascade (14px desktop / 16px mobile) the legacy panels relied on by dropping hardcoded `text-sm`/`text-xs` from the new primitives. Secondary text moves to em-relative `text-[0.85em]` so it scales with the parent. Form controls (`<input>`, `<select>`) re-apply the cascade explicitly via the `settings-content` class since browsers don't inherit font-size onto form elements. Extract `<SectionTitle>` primitive (caseless-language aware via `isCaselessUILang`/`isCaselessLang`) and route every uppercase tag-style header through it: BoxedList groups, Reading Sync, Content Sources, Theme Color, Background Image, integration form labels, KOSyncResolver device labels, and the OPDS My Catalogs / Popular Catalogs sections. CJK / Arabic / Hebrew / Indic / Thai / Tibetan locales bump to `1em` since `uppercase` is a no-op on those scripts. Redesign the OPDS My Catalogs cards: whole card becomes the browse trigger (role='button'), edit/delete collapse into a 3-dot dropdown menu, and the sync-status moves to a sub-line under Auto-download so the card height stays constant whether the toggle is on/off or sync data has arrived. Plumb a `from=settings-integrations` URL marker through the OPDS browser so both manual close and auto-close-on-failure (preserved as `router.back()` for transient failures, paired with a new `stashOPDSReturnTarget` helper) return the user to Settings -> Integrations -> OPDS Catalogs sub-page rather than the dialog's top level. Backed by new `requestedSubPage` deep-link store field. Skip the OPDS catalog passphrase prompt when credentials sync is disabled -- `replicaPublish` already drops encrypted fields at the wire, so prompting was both pointless and confusing. Fix `SettingsDialog` calling `setRequestedPanel(null)` inside a `useState` lazy initializer (zustand setter during render -> React warning); move the clear into a one-shot `useEffect`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ui/ux: opt Settings into OverlayScrollbars + caseless typography polish Add an opt-in `useOverlayScroll` prop to `<Dialog>` that swaps the body's native `overflow-y-auto` for `<OverlayScrollbarsComponent>` (autohide, click-scroll, no native overlaid bars). SettingsDialog flips it on so the long Layout / Color panels keep a visible, theme-aware scroll track on Android / iOS webviews where native scrollbars auto-hide entirely. Other short-modal callers stay on the native scrollbar. Drop the `uppercase tracking-wider` SectionTitle styling for caseless scripts and pair it with body-weight `font-medium` instead — those typographic effects are no-ops on Han / Hangul / Devanagari / Thai etc., so a plain medium-weight body-size title reads more correctly than a shrunken pseudo-uppercase one. SettingsRow / NavigationRow primary labels follow the same rule (drop `font-medium` in caseless locales since the inherited body weight already carries; CJK fonts bold poorly at body size). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ui/ux: SettingLabel primitive + KOSyncForm select polish + Tips alignment Add `<SettingLabel>` primitive — caseless-aware row/field label that pairs with `<SectionTitle>` (groups) for per-item labels. Cased scripts get `font-medium`; caseless scripts (CJK / Arabic / Hebrew / Indic / Thai / Tibetan) drop the weight since Han / Hangul / Devanagari etc. bold poorly at body size. No font-size class so it inherits the `.settings-content` 14/16 cascade. Routed through `SettingsRow`, `NavigationRow`, and the ~12 ad-hoc inline `text-sm font-medium` callsites in AIPanel / FontPanel / ColorPanel / IntegrationsPanel / KOSync / Readwise / Hardcover forms. Refactor KOSyncForm's Sync Strategy + Checksum Method rows onto the shared `<SettingsSelect>` primitive — the inline 17-line div/select/ MdArrowDropDown chassis becomes a single SettingsSelect call with an options array. Drops the unused MdArrowDropDown import and ~25 lines. Fix Tips list-item alignment: callers traditionally pass `<li>` elements (semantic) but the primitive was double-wrapping into `<li><span><li>...</li></span></li>` — invalid HTML, and the inner `<li>`'s `display: list-item` broke line-wrap alignment on multi-line items. Unwrap caller `<li>` to its content; add `flex-1` on the text span so wrapped lines align under the first line instead of falling back to the bullet column. Bullet container switches to `h-[1.4em]` so it tracks the text line-height and pins to the first line's optical center via `items-center` regardless of how much the content wraps. DESIGN.md §5 typography updated to point primary-label callers at `<SettingLabel>`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |