Commit Graph

126 Commits

Author SHA1 Message Date
Huang Xin 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>
2026-06-27 16:59:55 +02:00
Huang Xin 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>
2026-06-27 11:18:11 +02:00
Huang Xin 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>
2026-06-27 10:28:08 +02:00
Huang Xin 4874eb9ae7 feat(reader): add TTS highlight granularity setting (word or sentence) (#4807) 2026-06-26 18:48:57 +08:00
Huang Xin 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>
2026-06-25 10:43:57 +02:00
Huang Xin 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>
2026-06-25 09:57:52 +02:00
Huang Xin 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>
2026-06-25 06:50:34 +02:00
Huang Xin 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>
2026-06-25 06:10:20 +02:00
Huang Xin 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>
2026-06-24 07:24:30 +02:00
Huang Xin 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>
2026-06-23 11:15:34 +02:00
Huang Xin 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>
2026-06-22 17:51:23 +02:00
Huang Xin 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>
2026-06-21 19:03:24 +02:00
Huang Xin 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>
2026-06-20 01:41:16 +02:00
Huang Xin 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>
2026-06-19 02:30:24 +02:00
Huang Xin 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>
2026-06-18 18:39:03 +02:00
Huang Xin 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>
2026-06-17 19:09:29 +02:00
Huang Xin 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>
2026-06-16 19:25:01 +02:00
Huang Xin 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>
2026-06-15 09:52:28 +02:00
Huang Xin 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>
2026-06-15 07:56:00 +02:00
Huang Xin 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>
2026-06-14 09:46:18 +02:00
Huang Xin 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>
2026-06-13 18:07:28 +02:00
Huang Xin 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>
2026-06-13 14:11:59 +02:00
Huang Xin 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>
2026-06-13 04:36:31 +02:00
Huang Xin 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>
2026-06-12 12:01:29 +02:00
Huang Xin 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>
2026-06-12 08:44:18 +02:00
Huang Xin 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>
2026-06-04 18:28:30 +02:00
Huang Xin 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>
2026-05-30 18:07:29 +02:00
Huang Xin 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>
2026-05-30 09:51:22 +02:00
Huang Xin 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>
2026-05-29 15:28:42 +02:00
loveheaven 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>
2026-05-28 18:37:04 +02:00
Huang Xin 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>
2026-05-28 08:38:38 +02:00
Huang Xin 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>
2026-05-28 07:48:16 +02:00
loveheaven 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>
2026-05-27 12:08:36 +02:00
Huang Xin 4a5674ef4f chore: bump turso to version 0.6.1 (#4312) 2026-05-26 18:25:28 +02:00
Huang Xin e0ce6c8c22 feat(reedy): Appendix A · Phase 4 — custom thread UI on AgentRuntime (#4308) 2026-05-26 14:55:20 +08:00
Huang Xin 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>
2026-05-25 20:41:21 +02:00
loveheaven 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.
2026-05-25 18:32:07 +02:00
loveheaven 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.
2026-05-25 16:00:33 +02:00
Huang Xin 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>
2026-05-23 15:59:29 +02:00
Huang Xin 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>
2026-05-23 15:30:32 +02:00
loveheaven 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>
2026-05-22 18:55:12 +02:00
Huang Xin 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>
2026-05-20 05:32:53 +02:00
Huang Xin 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>
2026-05-19 20:06:52 +02:00
loveheaven 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.
2026-05-19 07:03:52 +02:00
Huang Xin 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>
2026-05-16 21:45:38 +02:00
Huang Xin 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
2026-05-16 19:22:18 +02:00
Huang Xin 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>
2026-05-15 19:57:33 +02:00
Huang Xin 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>
2026-05-15 10:30:43 +02:00
Huang Xin 16ffc17507 fix(eink): fixed sync toggle styles in eink mode, closes #4155 (#4163) 2026-05-14 19:57:23 +02:00
Huang Xin 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>
2026-05-10 17:46:25 +02:00