Commit Graph

2256 Commits

Author SHA1 Message Date
Huang Xin 531f0b58ae feat(sync): stream Google Drive book uploads/downloads from disk (#4824)
Add uploadStream + downloadStream to the Google Drive provider so book
files sync straight from/to disk instead of buffering the whole file in
the JS heap. Marshaling a large book across the WebView<->Rust bridge as
a single Uint8Array crashes the renderer on mobile, so book sync over
Drive was effectively desktop-only; this unlocks it on Android/iOS and
keeps the heap flat for gigabyte-scale PDFs on desktop too.

- driveRest.ts: resumableCreateUrl / resumableUpdateUrl builders.
- GoogleDriveProvider: uploadStream opens a Drive resumable session
  (POST new / PATCH existing; metadata in the initiation, so no reparent
  follow-up), then PUTs the bytes to the one-time session URI via the
  native upload plugin (tauriUpload). downloadStream GETs alt=media to
  disk via tauriDownload with a bearer token. Attached on Tauri only;
  web keeps the buffered fallback. Both swallow to false per the provider
  contract (engine retries once).

Reuses @tauri-apps/plugin-upload already shipped for WebDAV streaming;
no new native code.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:59:35 +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 f8916e128e fix(reader): smooth pinch-zoom and pan for scrolled-mode PDF (#4817)
Bumps foliate-js to readest/foliate-js#43. In scrolled-mode PDF the page now zooms live under a pinch and commits without a layout shift (the inter-page gap scales with the zoom so the committed layout matches the transform-scaled preview, and the centre page is restored to its pre-commit on-screen rect), a page zoomed wider than the viewport is pannable horizontally, and the page iframes stay interactive when idle so native text selection keeps working. readest already drives the renderer's pinchZoom on a two-finger gesture, so the only reader-side change is the submodule bump plus a unit test for the new scroll pinch transform.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:15:33 +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
Luis Cortes 9496de301b fix(node-app-service): ensure correct cross-platform path resolution in NodeAppService (#4819)
* refactor(node): use path.join() in path resolver

* fix(node): use native path separators in resolveFilePath.
2026-06-27 08:19:21 +02:00
Huang Xin 348c85f648 fix(reader): cap auto page-turn corner zone size (#4812) (#4820)
The corner-dwell auto page-turn zone is a quarter-ellipse whose radius is
a fraction (0.15) of the reading area on each axis. On wide screens such
as desktop or multi-column pages, that fraction grows the zone until it
reaches deep into the text, so selecting in a column and resting the
pointer there turns the page unexpectedly.

Cap each axis of the corner radius at 50px so the engagement zone stays a
real corner regardless of page width, while preserving the existing feel
on phones.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 07:51:24 +02:00
Huang Xin 9e93445336 fix(sync): sync WebDAV credentials across devices (#4810) (#4818)
WebDAV connection settings were never part of the bundled settings
replica, so the "Credentials" sync toggle had no effect on them and
users had to re-enter their WebDAV server, username, and password on
every device.

Add webdav.serverUrl / username / password / rootPath to
SETTINGS_WHITELIST and gate username / password behind
SETTINGS_ENCRYPTED_FIELDS, matching how KOSync / Readwise / Hardcover
credentials are handled. Per-device bookkeeping (enabled, deviceId,
lastSyncedAt, sync sub-toggles) stays local, mirroring KOSync which
syncs credentials but not its enabled flag: a fresh device pre-fills
the connect form and the user clicks Connect.

Also add a webdav deep-merge case to mergeSettings. Without it the
top-level shallow merge on pull would replace the whole webdav object
with the four-field patch and wipe the local per-device fields.

Update the credentials category description to mention WebDAV and
migrate the i18n key across all locales.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:34:37 +02:00
Huang Xin 24370ca511 feat(reader): render Markdown (.md) files at runtime (#774) (#4816)
Open standalone .md files in the reader without converting to EPUB. A new
makeMarkdownBook (src/utils/md.ts) parses Markdown to sanitized HTML with
marked + DOMPurify, splits the document into sections at H1 boundaries, and
builds an in-memory foliate book (modeled on fb2.js) with a nested heading
TOC. DocumentLoader routes .md/.markdown before the TXT path so a Markdown
file served as text/plain is not converted to EPUB. Layout, font and theme
settings apply the same as for any other format.

Relative-image resolution and Markdown bundle/folder packages are left as
follow-ups (a standalone file has no sibling-asset access on the web).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 05:36:32 +02:00
Huang Xin 580c5e5deb fix(reader): eliminate PDF scrolled-mode rendering lag on mobile (#4795) (#4813)
PDF pages rendered blank while scrolling in scrolled mode (#4795,
resurfacing #4031). On-device profiling showed each page takes hundreds
of ms to render while the preload margin gave only about half a page of
lead, and loads were unbounded and unprioritized.

Bump the foliate-js submodule to widen the scrolled-mode preload margin
and drive page loading through a bounded, viewport-prioritized scheduler
(readest/foliate-js#40). Adds unit coverage for the new planScrollModePages
scheduler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 05:02:16 +02:00
Huang Xin a0227f98e2 perf(reader): stop per-frame background reflow on swipe page turns (#4785) (#4814)
Fixes readest/readest#4785

Swipe page turns dropped frames, worst when crossing .xhtml section
boundaries. The paginator's #replaceBackground rebuilt its whole paint
context every animation frame (a getComputedStyle plus one
getBoundingClientRect per rendered view), and that per-frame cost scales
with the number of loaded views, which peaks at a boundary where adjacent
sections are preloaded.

Bumps the foliate-js submodule to 15fc999 (readest/foliate-js#41) to
snapshot the paint context once per gesture and reuse it on every frame,
and to defer the heavy mid-drag section preload off the active drag. The
bump also advances foliate-js to current main, picking up the
gpu-composite page-turn opt-in (readest/foliate-js#39).

Adds a real-browser test that drives an animated turn and a synthetic drag
and asserts the section <html> computed style is read a small constant
number of times instead of once per frame (snap 39 -> <=3, drag 7 -> <=1).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:26:37 +02:00
Huang Xin 97868f0486 fix(reader): keep negative table margins from clipping wrapped layout tables (#4439) (#4808)
A decorative table-of-contents page lays out as nested tables where the inner
table pulls itself up with a negative top margin and the CONTENTS heading uses
line-height:1em. Since #4400 wraps every table in a `.scroll-wrapper`
(overflow:auto), that negative margin bled the heading above the wrapper's clip
box and overflow cut off the top half of its glyphs.

Hoist any negative margins from the wrapped element onto the wrapper and zero
them on the element: the box stays in place, the element sits flush inside it so
overflow cannot clip it, and scrollWidth is no longer inflated by the margin so a
table that actually fits still gets marked fit. Positive and auto margins are
left alone, so an over-wide table still scrolls and a centered table stays
centered.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:25:21 +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 dced42912f feat(reader): filter exported annotations by color and style (#4801) (#4806)
Add a Filter section to the annotation export dialog so users who color-code
highlights (e.g. red for important, yellow for difficult words) can export only
selected colors and styles.

The selection is stored as exclusions in NoteExportConfig, so an empty filter
exports everything and any color or style added later is included by default.
A new pure helper filterExportGroups applies the filter to both the default
formatter and the custom-template paths, and only filters a dimension when at
least two distinct values are present so a hidden row never silently drops notes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:44:34 +02:00
Huang Xin 01a54238ae fix(annotator): clean up empty highlight on annotation cancel (#4791) (#4804)
Clicking "Annotate" on a selection eagerly creates a highlight (with an
empty note) as the anchor for the note being typed, so the selection stays
visible while the NoteEditor is open. Cancelling the note instead of saving
left that empty highlight behind: it leaked into the config DB, showed as a
stale card in the Booknotes list, and left a phantom yellow highlight.

handleHighlight now returns the created BookNote only when it pushes a new
record (null when it restyles an existing highlight, which predates the flow
and must survive a cancel). handleAnnotate tracks that id via the new
notebookNewHighlightId store field; cleanup is keyed on the id, not the cfi,
so a fresh selection that collides with an existing highlight's cfi can't
wrongly delete it.

removeEmptyAnnotationPlaceholder tombstones the tracked placeholder only when
it still has no note text, and the Notebook tears its overlay down. Cleanup is
presentation-driven: an effect removes the placeholder whenever the creation
editor stops being shown (Cancel, Escape, overlay, close, swipe, navigate),
plus a second effect for book-switch and reader-close. Save survives the guard
and clears the tracked id.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:51:36 +02:00
Huang Xin 1558078391 fix(settings): keep global settings in sync across windows (#4580) (#4803)
On desktop the app runs multiple windows (one library plus one per open
book), and each keeps its own in-memory settings loaded once at window
open. Global settings persist to a single shared settings.json, and every
window writes the whole object on save. A window opened before the user
customized a global view setting therefore clobbers that change with its
own stale (often default) value on its next save, most visibly a reader
window reverting Click to Paginate back to the default on close.

Broadcast the global view and read settings after every save and have all
other windows adopt them, preserving each window's device-local fields
(paths, lastOpenBooks, sync cursors, brightness). The receive path only
updates the in-memory store, so there is no save or broadcast loop. No-op
off Tauri.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:43:31 +02:00
Huang Xin 7544835fb8 chore(agent): update agent memories (#4802) 2026-06-26 07:55:36 +02:00
Huang Xin 4ba78490a7 fix(library): prevent series and description overlap in list view (#4796) (#4799)
The list-mode book item used a fixed h-28 height. When a book belongs to
a series, the title, authors, series, description, and progress row
together exceed 112px and overflow, so the series and summary lines
collide and get clipped. Larger system font scaling (such as the Android
accessibility font size setting) inflates line heights and makes the
overlap worse, which is what the reporter saw on a Pixel 10 Pro.

Use min-h-28 instead so the row grows to fit its content. Non-series rows
keep the same 112px height, and the list is virtualized with measured
heights so variable row heights are fine.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:19:04 +02:00
Huang Xin 0b4993407c feat(reader): add contrast option to PDF/CBZ view menu (#4800)
Add a Contrast stepper to the reader View menu for fixed-layout
(PDF/CBZ) documents. It increases and decreases page contrast via a
CSS filter on the rendered page images, applies to the whole book,
and is stored per-book (local to the current document).

The filter is built in applyFixedlayoutStyles by combining any
dark-mode invert with the contrast amount into a single filter
declaration. Persisted with skipGlobal so it never touches global
view settings, and added to FoliateViewer's effect dependencies so
the change re-applies across all rendered pages.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:16:36 +02:00
Huang Xin 370a516620 feat(reader): glue non-breaking spaces after short Russian words (#4769) (#4798)
Russian typography requires short function words (prepositions,
conjunctions, particles) to never hang at the end of a line. Add an
`nbsp` content transformer that inserts U+00A0 after such words so they
stick to the following word. The source file is never modified.

The transformer is language-driven via an NBSP_LANGUAGES registry keyed
by language code (only `ru` is configured today), so adding another
language is a single entry. It runs only for matching books and rewrites
text between tags with a regex, leaving tags, attributes, and the XML
declaration intact. Runs after whitespace normalization so the inserted
spaces are not stripped under the override-layout setting.

The space-to-NBSP swap is length-preserving (both are single UTF-16 code
units), so DOM character offsets and CFIs stay valid for every word
before and after the transform; tests enforce this invariant.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:52:32 +02:00
Huang Xin 4c39d769e6 fix(hardcover): never send a book id as edition_id (#4792) (#4794)
When a book was matched via Hardcover title search with no featured
edition and the user had not selected a specific edition, the sync
client fell back to using the Hardcover book id as the edition_id.
Hardcover's Action rejects that with a parse-failed error
("ActionWebhookErrorResponse ... key 'message' not found"), so progress
and note sync failed for those books.

Leave editionId null when no real edition is known, make the read and
journal mutations accept a nullable edition_id, and omit edition_id when
adding a book so Hardcover uses the book's default edition.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:41:00 +02:00
Huang Xin 58f84d18c1 fix(sync): keep WebDAV connection after restart when a pull overlaps it (#4793)
useSync.pullChanges already re-read the live store settings for its
in-try setSettings, but its catch and finally still wrote the stale
per-render hook closure. When a settings change lands during an
in-flight pull (most visibly a WebDAV connect), the pull's finally
overwrote settings.json with the pre-change snapshot, so the connection
read back as "Not connected" after the app was reopened.

WebDAV was the unique casualty because it is the only integration
credential not in the replica SETTINGS_WHITELIST, so unlike
kosync/readwise/hardcover it is never re-hydrated from the server on the
next launch. Android's slower network widens the pull window, which made
the overlap reliable there.

Read useSettingsStore.getState().settings in both the catch and the
finally, matching the in-try path. This is a general fix that preserves
any concurrent settings change, not just WebDAV. Adds a regression test
that drives the real hook with a connect landing mid-pull.

Fixes #4780

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:55:52 +02: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 fb943987eb fix(opds): hide popular catalog after adding it to My Catalogs (#4782) (#4787)
Adding a built-in popular catalog (e.g. Project Gutenberg) to My Catalogs
left it still rendering in the Popular Catalogs section, so it looked like a
duplicate. Only the card's Add button was hidden; the card itself stayed.

Filter added (and disabled) entries out of the Popular list entirely via a
new pure helper getUnaddedPopularCatalogs, which matches by normalized URL
(trim + lowercase) to mirror the store's findByUrl dedup. The section already
auto-hides when the list is empty, so it disappears once all are added.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:12:41 +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 79ae8a48ba feat(reader): sync per-book proofread rules across devices (#4781)
Per-book and selection-scope proofread (find/replace) rules were pushed in
the synced book config but dropped on pull (applyRemoteProgress only applied
location), so they never propagated across devices. Merge them by id on the
config pull, mirroring the booknote CRDT path. Library-scope rules keep
syncing via the settings replica.

- Add updatedAt/deletedAt to ProofreadRule. Delete is now a tombstone for
  book/selection scope so a removal is not resurrected by a peer's live copy;
  library-scope deletion keeps the hard splice (settings-replica whole-field
  LWW already handles it).
- Add mergeProofreadRules (by id, updatedAt/deletedAt last-write-wins) and
  merge into applyRemoteProgress; refresh the live view only when the merged
  rules actually changed.
- Backfill a content-derived id for id-less rules (legacy/foreign/hand-edited)
  via ensureRuleId, and seed book/library ids from content so the same rule
  created on two devices dedupes instead of duplicating. Selection rules keep
  a per-instance unique id. Without this, id-less rules collide on one Map key
  and clobber each other.
- Filter tombstoned rules from the transformer and the manager dialog list.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:49:30 +02:00
Huang Xin 0589cb4f4a fix(reader): stop a quick-deleted highlight from being re-drawn (#4773) (#4779)
The per-relocate re-apply effect reads a memoized annotation index. A
highlight deleted in place after the index was built still sits in its
bucket, and selectLocationAnnotations trusted the build-time deletedAt
filter, so the effect re-drew the just-deleted overlay and left it
orphaned on the page until the book was reopened.

Re-check deletedAt at the read site: in selectLocationAnnotations and in
the sibling globals re-apply loop in Annotator.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:33:55 +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 0c7ffa9799 fix(reader): stop iOS page-turn animation stutter (#4768) (#4772)
* fix(reader): stop iOS page-turn animation stutter (#4768)

iOS users saw occasional page-turn animation stutter that was not present
on earlier 0.11.x builds. It traces to foliate-js commit c1c7315 (first
shipped in 0.11.4): the large-section rafAnimateScroll fallback and the
removal of persistent compositor-layer hints, both added to fix a ~1s
Blink freeze on Android Chromium at high DPR.

Apple WebKit composites those layers fine, so on iOS (notably 120Hz
ProMotion devices) the changes only cost smoothness: large-section turns
animate scroll on the main thread, and every turn promotes a layer
on-demand instead of using a persistent one.

Opt the iOS renderer into foliate-js's new gpu-composite path, which
restores persistent compositor layers and skips the main-thread
rafAnimateScroll fallback. Other platforms keep the Android freeze fix.
Bumps the foliate-js submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(deps): repin foliate-js to merged gpu-composite commit (#4768)

readest/foliate-js#39 squash-merged to a new commit on main. Move the
submodule pin off the now-orphaned PR branch commit to the merged main
commit. No content change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 04:46:16 +02:00
Huang Xin 44a6900da0 feat(reader): extend selections and highlights across pages (#4741) (#4767)
* docs(plan): design for cross-page corner auto-turn (#4741)

Extract useAutoPageTurn so the corner-dwell page turn works for instant
highlight drags and for range-editor handle drags, not just native text
selection. Decouple the dwell liveness from the DOM selection and anchor
each range's non-dragged end to a DOM position so it survives the scroll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(plan): add keyboard turn-on-cross to cross-page design (#4741)

Shift+Arrow selection adjust extends into the off-screen next column
without turning the page. Fold it into the feature with an immediate
turn-on-cross (no dwell) in the keyboard path, reusing the page-edge
geometry from useAutoPageTurn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): extend selections and highlights across pages (#4741)

Extract the corner-dwell auto page-turn (#1354) into useAutoPageTurn,
decoupled from the DOM selection, so every selection gesture can drive
it in paginated mode, not just native text selection:

- Instant Highlight drag: feed the finger corner into the dwell machine
  and DOM-anchor the highlight start so it survives the page scroll.
- SelectionRangeEditor and AnnotationRangeEditor handle drags: feed the
  dragged-handle corner; anchor the non-dragged end to a DOM position so
  the edited range spans pages (the annotation editor previously resolved
  both ends from window coordinates and lost the previous page).
- Shift+Arrow keyboard selection adjust: turn the page immediately when
  the extended focus leaves the visible page, so the growing selection
  stays in view.

An after-turn re-emit rebuilds each gesture's range from the held
position so the selection extends onto the new page without waiting for
the next move.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:23:11 +02:00
Huang Xin d963b911c8 fix(reader): zoom linked images on single tap (#4757) (#4766)
A single tap on an image wrapped in an <a> element followed the link instead of opening the image viewer, because postSingleClick returned early for any element inside an anchor before reaching media detection.

Compute the media target up front and let it bypass the anchor guard, so a tapped image/table/svg-image opens the viewer just like long-press already does. Footnotes are excluded so footnote anchors keep their popup and navigation behavior.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:57:19 +02:00
Huang Xin e0b537bc16 feat(koplugin): bulk download all cloud books from Library view (#4751) (#4765)
Downloading a Readest cloud library into KOReader previously required
tapping each book one at a time. Add a "Download all books" action to
the Library view menu that pulls every cloud-only book to the device
in one pass.

- LibraryStore:listCloudOnlyBooks() returns the downloadable
  cloud-present, not-local books for the current user (whole library,
  independent of the active search/group view).
- librarywidget.downloadAll() streams them sequentially via the
  existing syncbooks.downloadBook path inside a Trapper coroutine:
  cancellable progress (Trapper:info Abort/Continue), per-book
  failures skipped and counted, summary toast at the end. Bridges
  downloadBook's sync-or-async callback with a coroutine suspended
  check so it serializes correctly either way.
- Wire the action into the view-menu Actions section.
- Add the new UI strings and translate them across all 33 locales.

Closes #4751

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:42:31 +02:00
Huang Xin 163487b5e3 feat(reader): add regex and nearby-words search modes (#4560) (#4764)
Add Calibre-parity search modes to the reader's full-text search. The
"Match Whole Words" toggle becomes a single-select mode group: Contains,
Whole Words, Regular Expression, Nearby Words.

- Regex and nearby-words matching live in the foliate-js submodule
  (bumped here); the sidebar threads `mode` and `nearbyWords` through.
- Nearby distance is chosen with a "within N words" control (5/10/20/50,
  default 10), not parsed from the query, so trailing numbers stay
  literal search words.
- Per-mode modifiers: Match Diacritics is greyed out for regex (no-op).
- Calm inline error for invalid regex / too-few nearby words, a
  no-results state, and a results-count footer.
- Nearby matches render a segmented excerpt emphasizing each matched
  word and highlight every word in the book.
- BookConfig schema v2 -> v3 migrates the deprecated `matchWholeWords`
  boolean to `mode` (still written for sync back-compat).

Also fix two search interactions:
- option changes (e.g. within-N-words) now take effect immediately by
  reading the latest config at search time instead of a stale closure.
- closing search from the results nav bar now exits the sidebar search
  mode, not just the results (search-bar visibility lifted to the store).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:23:09 +02:00
Huang Xin f7124cbeea fix(css): multiply mix blend for images in dark override color mode (#4763) 2026-06-24 16:12:10 +02:00
Huang Xin 005aa2d615 fix(security): iframe srcdoc atrribute can lead to arbitrary code execution (#4762) 2026-06-24 16:01:18 +02:00
Huang Xin 7da5f83213 fix(reader): make annotation toolbar customization apply to all books (#4760)
The customized annotation toolbar only took effect in the book where it
was changed, instead of applying to every book.

Root cause: serializeConfig decided which per-book view settings to
persist as overrides using a reference check (globalViewSettings[key]
!== value). It deep-clones the config first, so array-valued settings
like annotationToolbarItems are always a fresh reference and were stored
as a per-book override on every save (each progress autosave). On reopen
the per-book override shadowed the global value, so a global toolbar
change never reached already-opened books.

Compare view-setting values by content, not reference, so array/object
settings equal to the global value are no longer persisted per-book. This
also fixes the same latent issue for paragraphMode, proofreadRules,
ttsHighlightOptions and noteExportConfig.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:48:03 +02:00
Huang Xin ac6249cbc3 feat(opds): show groups as horizontal carousels when 2+ groups (#4750) (#4755)
Feeds that list several groups previously rendered each group as a full
grid, which made scrolling past many groups tedious. When a feed has two
or more groups, render each group's items in a compact horizontal
carousel, matching what Thorium does.

Each carousel is a horizontally virtualized react-virtuoso list, so only
the covers in view are mounted and fetched; off-screen covers load lazily
as the row is scrolled. Scroll arrows page through by index and stay
centered on the cover artwork.

Book items also get rounded covers (matching the library bookshelf) and
drop the inline acquisition badge, which remains on the publication
detail page.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:28:38 +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 e2f65278ec fix(opds): dereference publication self link for full metadata (#4749) (#4753)
OPDS 2.0 feeds may list a publication in summary form (title, cover, and
only a rel="self" link of type application/opds-publication+json), serving
the full record (acquisition links, description, publisher, subjects) only
when the client follows that link on click, as Thorium does. Readest
ignored it, so such books showed no download option and no description.

Add opdsPublication.ts with getPublicationDetailHref and
parsePublicationDocument (OPDS 2.0 JSON and Atom entry, absolutizing
link/image hrefs), and dereference the link in the OPDS page when a
feed-selected summary advertises one, upgrading the detail view in place
once it loads.

Also render an OPDS 2.0 JSON HTML description as sanitized markup instead
of literal tags by falling back from the typed content to the plain
description string in getOPDSDescriptionHtml.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:16:59 +02:00
Huang Xin 8810aa6db0 fix(reader): stop trackpad pinch-zoom flicker on image viewer (#4742) (#4748)
On macOS a trackpad pinch-to-zoom is delivered as a rapid stream of
ctrl+wheel events. The zoomed image kept its 0.05s transform transition
during that stream, so each event restarted the in-flight transition
from its interpolated mid-point and the image lagged and flickered. This
is the same root cause as the #4451 pan flicker, which was fixed by
dropping the transition during the gesture for the pan and touch-pinch
paths; the wheel-zoom path was the only continuous gesture left with the
transition on.

Suppress the transition while a wheel-zoom gesture is streaming, cleared
on a short debounce since wheel has no explicit gesture-end event.
Discrete zoom (buttons, double-click, keyboard) keeps its smoothing.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:28:42 +02:00
Huang Xin 6301c620a8 fix(library): import books opened via "Open with" by default on mobile (#4746) (#4747)
A recent change (#4407) made Android's "Open with Readest" (VIEW intent,
used by Telegram and similar apps) always open the file as a transient
book: into the reader but never written to the library, with its filePath
pointing at the original content:// URI. Once that temporary URI grant
dies the book can no longer be reopened, so the user has to re-share it
from the source app every time.

Make the transient behavior an opt-out gated by the existing
autoImportBooksOnOpen setting, and surface it on mobile:

- The VIEW handler now consults the setting via a new shouldOpenTransient
  predicate. When auto-import is on it falls through to the same library
  ingest path as a share-sheet SEND (full ingest plus cloud upload); when
  off it keeps the transient open.
- Read the setting from disk in the handler rather than the settings
  store, since on a cold-start "Open with" the store is not hydrated yet
  and would wrongly fall back to a transient open.
- Show the "Auto Import on File Open" toggle on mobile (was desktop only).
- Default autoImportBooksOnOpen to true on mobile so shared files persist
  and sync by default; the desktop default is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:28:29 +02:00
Huang Xin acd4a67dcf fix(reader): require a still-hold before instant-highlight on touch (#4745)
* fix(reader): require a still-hold before instant-highlight on touch

Instant Highlight (the highlighter quick action) engaged on every
pointer-down over text, calling preventDefault, which swallowed the
single tap / swipe that turns the page on Android. Tapping the side
margins still worked only because they are not selectable text; the
synthetic-click fallback was also dead on Android (native touchend
calls handlePointerUp with no event).

Gate engagement behind a 300ms still hold for touch/pen: a tap
releases first and a swipe moves first, so both fall through to
pagination, and only a deliberate still hold starts drag-to-highlight.
Mouse input keeps engaging immediately (click vs. press-drag is
already unambiguous).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(agent): update agent memories

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:32:41 +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 428168ac91 fix(reader): show the centred section's chapter title in scrolled mode (#4739)
In scrolled mode the header chapter title was wrong while transitioning
between sections, while paginated mode was correct (#4436). foliate-js
#getVisibleRange returned the first overlapping view (topmost in scroll
order), so when the tail of one section was a thin sliver at the top of
the viewport and the next section occupied the centre and most of the
screen, the relocate event reported the sliver's section — and its title
lagged behind what the reader was reading.

Bump foliate-js to prefer the view covering the viewport centre
(readest/foliate-js#37) and add a browser-lane regression test that
scrolls a sliver of section K to the top with K+1 across the centre and
asserts the relocate index is K+1.

Fixes #4436

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:56:10 +02:00
Huang Xin e982af1725 feat(reader): adjust text selection with Shift/Ctrl/Opt+Arrow keys (#4728) (#4738)
Support standard desktop shortcuts for refining an active text selection:
Shift+Left/Right by character, Ctrl/Option+Shift+Left/Right by word. Only
active while text is selected; otherwise the keys fall through to page
navigation as before.

Root cause: after a selection the reader container (not the book iframe)
holds focus, so Shift+Left/Right keystrokes reach the parent shortcut
handler and matched the page-turn shortcuts, turning the page instead of
refining the selection.

The new onAdjustTextSelection action runs before the navigation actions:
when a selection is active it extends the iframe selection via
Selection.modify() and suppresses the page turn; an iframe-forwarded key
(already extended natively) just suppresses navigation. handleSelectionchange
now refreshes the popup/range for keyboard-driven changes (no pointer drag)
so the selection toolbar follows the refined selection.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:32:13 +02:00
Huang Xin 787641b5b1 chore(agent): update agent memories (#4737) 2026-06-22 18:52:39 +02:00
Huang Xin 664b6125a2 feat(android): add monochrome themed launcher icon (#4733) (#4736)
Android 13+ recolors the adaptive icon's `<monochrome>` layer with a
wallpaper-derived tint when the user enables themed icons. Support was added
in #2122/#2153 (the `ic_launcher_monochrome.png` assets) but #2353 ("fixed
launcher icon size") rewrote the committed adaptive icon to inset the
foreground 22% and silently dropped the `<monochrome>` layer, so themed icons
stopped working in shipped builds.

Restore it by re-adding a `<monochrome>` layer (same 22% inset as the
foreground) and shipping the monochrome mipmaps. The CI/release flow
regenerates `gen/android` (`tauri android init` + `tauri icon`) then
`git checkout .` to restore tracked customizations; `tauri icon` does not emit
a monochrome layer, so the mipmaps are force-committed under `gen/` like the
other customized resources.

The monochrome artwork is redesigned: Android tints the layer via SRC_IN
(alpha only), which flattened the old desaturated-logo asset into a solid
blob. A narrow vertical center gap now splits the open book into two pages
with a visible spine while keeping the bookmark, so the mark keeps its
character when themed.

Verified on a Pixel 9 Pro emulator (Android 36) with Themed Icons enabled, and
guarded by src/__tests__/android/themed-icon.test.ts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:44:57 +02:00
Huang Xin bc9b8b23e6 fix(reader): stop per-chapter listener leak that degrades paragraph mode (#4735)
The annotator's foliate `load` handler (onLoad) attached a renderer
`scroll` listener and, on Android, a global `native-touch` dispatcher
listener on every section load. Both the renderer and the eventDispatcher
outlive individual sections — and foliate fires `load` for preloaded
neighbour sections too — so these listeners accumulated without bound, one
set per chapter. Each renderer `scroll` (fired on every paragraph-mode
`goTo`) then ran all of them, and on Android the scroll/native-touch
handlers do real work. Reading a long book (e.g. a 3000-chapter web novel)
in paragraph mode slowed down steadily after a few chapters and only an
app restart cleared it.

Register these listeners once per view via a new `useRendererInputListeners`
hook with cleanup, instead of once per section load. The native-touch
handler now resolves the CURRENT primary section's doc/index at fire time
rather than capturing a (possibly off-screen, preloaded) section's. The
redundant `scroll` → `repositionPopups` listener is dropped — a dedicated
effect already repositions popups on scroll. Doc-scoped listeners stay in
onLoad, since they die with the section's iframe.

Add useRendererInputListeners unit tests covering register-once-per-view,
no-accumulation-across-re-renders, latest-handler routing, Android gating,
and unmount cleanup.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:12:39 +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 082edc204b fix(sync): sync updated book covers across devices (#4544) (#4731)
* docs: design for syncing updated book data (cover + file) (#4544)

Cover-change sync via a content hash (coverHash = partial MD5 of cover.png)
plus a cover_updated_at field-level merge timestamp; file updates ride the
existing re-import / metaHash dedupe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): sync updated book covers across devices (#4544)

Editing a book's cover wrote cover.png locally but changed no hash (the
cover is keyed by the file hash), so peers had no signal to re-download it
and the change never propagated.

Give the cover its own content-addressed version:
- coverHash = partial MD5 of cover.png; a peer re-downloads the cover iff
  the synced hash differs from the local one (idempotent, no churn on
  identical/re-extracted covers — compatible with the metaHash dedupe).
- coverUpdatedAt = field-level LWW timestamp so a page-turn that wins
  whole-row LWW on updated_at can't clobber a cover edit (mirrors the
  reading_status_updated_at fix for #4634).

Editing a cover recomputes the hash, bumps coverUpdatedAt, and re-uploads
only the cover; the server merges cover fields independently; peers
re-download on a hash diff. File updates continue to ride the existing
re-import / metaHash dedupe (changed file -> changed hash -> re-key).

Migration 016 adds cover_hash / cover_updated_at to books.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:31:03 +02:00