446c2c72de198de2dfcd63589f549ca517961d15
2352 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
446c2c72de |
fix(security): unblock app-dir downloads broken by transfer_file fs-scope guard (#4651)
#4639 added a strict `app.fs_scope().is_allowed()` check to download_file/ upload_file. On Android that returns false for the app's own storage, so every download into the app dir (book covers, dictionaries, books, gloss packs, OPDS books in the cache dir) failed with "permission denied: path not in filesystem scope". Root cause: download_file/upload_file are plain app commands using raw tokio::fs, so Tauri does not scope file_path. The capability scope patterns that cover the app's storage ($APPDATA/Readest/**, $APPCACHE/**, **/Readest/**/*) are command-scoped and absent from the global fs_scope() FsExt exposes (it is initialized FsScope::default() and only ever gains runtime dialog/persisted-scope grants), so is_allowed() returns false for the app's own files. Interim fix mirroring dir_scanner::read_dir: keep rejecting relative and `..` paths, then accept the path if the fs scope allows it (persisted dialog grants for custom/external roots) OR it lives inside the app's own storage — matched by the `Readest` data folder or the app's bundle identifier (app.config(). identifier), which the Android sandbox (/data/user/0/<id>/…, cache dir included) and the desktop identifier dirs always carry. The `..` rejection keeps the GHSA-55vr-pvq5-6fmg hardening: foreign targets like ~/.ssh/id_rsa carry neither segment and stay blocked. Follow-up (tracked separately): replace the substring fallback with a BaseDirectory + relative path resolved via app.path(), so targets are in-scope by construction with no string markers. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6c7c86f346 |
feat(applock): biometric unlock (fingerprint / Face ID) at startup on mobile (#4650)
* feat(applock): wire up biometric plugin + biometricUnlockEnabled setting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(applock): add guarded biometric service wrapper * feat(applock): auto-prompt biometrics on the lock screen with PIN fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(applock): guard concurrent biometric prompts + tighten lock-screen tests - Add biometricInFlightRef to prevent concurrent authenticateWithBiometrics calls - Assert PIN input still rendered after biometric failure (test 2) - Replace flaky waitFor negative assertion with a 50ms flush in test 3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(applock): mobile biometric toggle + default-on at PIN setup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(i18n): add biometric app-lock strings * fix(applock): seed biometricUnlockEnabled via app-lock store init; close test gaps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * build(applock): pin tauri-plugin-biometric in Cargo.lock Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
af587b1a41 |
fix(metadata): parse FB2 series from title-info sequence (#4646) (#4649)
FB2 stores series info as `<sequence name="…" number="…"/>` inside `<title-info>`, but the foliate-js FB2 parser never read it, so `belongsTo.series` was always empty and the series name/index never surfaced in the library or book details. Refresh-metadata and re-import didn't help since they share the same parser path. Bumps the foliate-js submodule to pull in the `<sequence>` parsing (readest/foliate-js#28) and adds a regression test + fixture. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
be17654fc0 |
fix(rsvp): render RTL words whole so Arabic shapes correctly (#4630) (#4648)
The RSVP word window split each word into before/orp/after spans at the ORP index and laid them out left-to-right. Slicing an Arabic/Hebrew word by character index breaks letter shaping (letters stop connecting, some slices render as notdef boxes) and the LTR layout reverses the visual order, so e.g. علم showed as disconnected, out-of-order letters. Detect RTL text and render the word as a single centered span — reusing the existing CJK Highlight Word path — with dir="rtl" so the browser shapes and orders it correctly, matching the context panel. ORP anchoring is meaningless for unsplittable shaped scripts, so RTL always renders whole; no new toggle. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ff96c6d3f7 |
feat(annotations): unify highlights and annotations into one record (#3870, #4511) (#4647)
A highlight and its note are now a single BookNote. Adding a note attaches it to the highlight at that CFI (or creates one with the current global style) instead of creating a second record, and a unified record renders as both a highlight overlay and a note bubble. - onDrawAnnotation chooses the draw kind from the overlay value prefix (cfi -> highlight, NOTE_PREFIX -> bubble) instead of annotation.note, so a record with both a style and a note draws both. Fixes notes synced from KOReader losing their highlight (#4511). - handleSaveNote updates the existing annotation at the CFI rather than pushing a new record (#3870); re-styling preserves the note. - unifyAnnotations migration (book config schema v1 -> v2, run in deserializeConfig) collapses existing split highlight+note records into one survivor and tombstones the redundant record (deletedAt) so the merge syncs to the cloud and KOReader. - Sidebar: a note's quoted highlight text uses the theme foreground so it stays legible on the highlight background. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
38a6d3d9ba |
fix(dict): stop iOS instant system dictionary popping multiple times (#4644)
On iOS a single long-press emits several selectionchange events, so the instant quick action fired the system dictionary 2-3 times, stacking UIReferenceLibraryViewController sheets. Add a once-per-gesture latch to deferredAction (re-armed by beginGesture on touchstart/pointerdown) so the action runs at most once per gesture, mirroring the Android defer-to-touchend coalescing. Also fix two related cases: - Tapping outside to deselect after dismissing the dictionary occasionally re-opened it (~1/3): the deselect tap re-armed the latch and a racy lingering selectionchange re-fired. Gate the instant action on a long-press hold (isLongPressHold, 300ms, touch only) so a quick tap can't trigger it. - A Word Lens gloss tap ignored the system-dictionary setting (always opened the in-app popup); route it through handleDictionary so it honors the system dictionary like the toolbar and instant-quick-action paths. Verified on Android (Xiaomi) that the instant dictionary still fires once per long-press and re-arms for the next gesture; iOS double-popup and tap-to-deselect re-fire confirmed fixed. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6626db967c |
fix(reader): keep last paragraph's line spacing by making the section skip link a <span> (#4642)
The next-section accessibility skip link is injected nested inside each section's last content element (findSectionEndHost in a11y.ts, added for #4126). The paragraph-layout rule in getParagraphLayoutStyles() targets `div:not(:has(*:not(b,a,em,i,strong,u,span)))`, so nesting a <div> made the enclosing paragraph fail the :has() test and silently lose its line-spacing, word/letter-spacing, text-indent, and hyphenation overrides — but only for the last paragraph of every section, and only in <div>-based EPUBs (common in Chinese-source books). <p>-based books were unaffected because the bare `p` clause matches regardless of children. Create the next-section skip link as a <span> instead. <span> is in the selector's allow-list, so the enclosing paragraph keeps matching. The link is still position:absolute (an out-of-flow 1x1px box) and focusable, so layout and NVDA focus behavior are unchanged. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
bcd9ed724b |
fix(reader): paginate inline-block-wrapped chapters instead of clipping them (#4641)
* fix(reader): paginate inline-block-wrapped chapters instead of clipping them (readest/foliate-js#27) Some EPUBs wrap a large chunk of chapter content in a div the stylesheet declares as `display: inline-block`. Atomic inline-level boxes can't fragment across CSS columns, so in paginated mode the tall box overflows the page vertically and every column past the first is clipped — the chapter jumps straight to its "Reference materials", silently skipping a large middle section, while the counter reads "1 page left in chapter". Bumps the foliate-js submodule with #demoteUnfragmentableBoxes (demotes over-tall atomic-inline boxes to their fragmentable block equivalents in column mode) and adds a browser test + repro EPUB fixture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(deps): bump foliate-js submodule to merged main (#27) Re-point packages/foliate-js from the PR-branch commit to the squash-merged main SHA now that readest/foliate-js#27 has landed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
403be32d5a |
fix(epub): import books whose OPF has an unescaped ampersand (#4640)
Some EPUBs ship an OPF that isn't well-formed XML — a bare, unescaped `&` in a hand-built manifest id, e.g.: <item id="Chapter_1213_Search_&_Rescue_153" .../> A strict XML parser rejects it (`EntityRef: expecting ';'`) and the book fails to import on every platform: the web/desktop/Android reader-open path (foliate `EPUB.#loadXML`) and the Android/desktop native-import bridge (`parseEpubMetadataFromXML`, which parsed unsanitized). Bump foliate-js to escape any `&` that doesn't begin a valid character or entity reference, applied at both parse sites (readest/foliate-js#26). Valid and numeric references are preserved. Verified end-to-end on the real "Shadow Slave - Vol. 6" EPUB: imports in the web app (Chrome) and on a physical Xiaomi device via the native path, both of which previously failed. New unit test covers both `parseEpubMetadataFromXML` cases. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4025c4d7b5 |
fix(security): scope Tauri download_file/upload_file to fs_scope (#4639)
`download_file` and `upload_file` passed a webview-supplied `file_path` straight to `File::create`/`File::open` with no validation, so any JS in the privileged Tauri origin could write or read arbitrary local paths (e.g. ~/.ssh/id_rsa, shell rc files, autostart entries). (GHSA-55vr-pvq5-6fmg) Validate the path before any file open/create: reject relative paths and `..` traversal, then require it to be inside the app's filesystem scope (`fs_scope().is_allowed`), the same mechanism `dir_scanner::read_dir` uses. Legitimate destinations stay covered — the static capability globs ($APPDATA /Readest, $APPCACHE, $TEMP) plus persisted dialog grants for custom roots and external library folders. AppHandle is injected by Tauri, so the JS invoke surface is unchanged. Adds a unit test for the traversal/relative-path rejection. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
495783d045 |
fix(security): harden OPDS proxy SSRF, storage key validation, Stripe check (#4638)
Server-side hardening for three reported web advisories: - OPDS proxy (/api/opds/proxy): add http(s) scheme allowlist, internal/loopback/ link-local host blocklist, and manual per-hop redirect re-validation so a public URL can't redirect into an internal address. Move isBlockedHost into the shared src/utils/network.ts as the canonical blocklist and reimplement isLanAddress to delegate to it (also tightens the /api/kosync LAN check); fetch-url.ts re-exports it. (GHSA-c7mm-g2j2-98cx, GHSA-5g3f-mq2c-j65v) - Storage upload (/api/storage/upload): validate the client-supplied fileName with a new isSafeObjectKeyName helper before building the object key, so a name can't escape the caller's own prefix. (GHSA-mfmj-2frf-vhgw) - Stripe (/api/stripe/check): bind the entitlement to the session owner — reject a Checkout Session whose metadata.userId differs from the authenticated caller. (GHSA-pv88-3727-j7v8) Unit tests added for each path; full suite + lint green. The Tauri-native advisory (GHSA-55vr-pvq5-6fmg) is handled in a separate change. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2f810a70b3 |
chore(deps): bump the github-actions group with 3 updates (#4637)
Bumps the github-actions group with 3 updates: [pnpm/action-setup](https://github.com/pnpm/action-setup), [actions/setup-java](https://github.com/actions/setup-java) and [actions/cache](https://github.com/actions/cache). Updates `pnpm/action-setup` from 6.0.8 to 6.0.9 - [Release notes](https://github.com/pnpm/action-setup/releases) - [Commits](https://github.com/pnpm/action-setup/compare/0e279bb959325dab635dd2c09392533439d90093...0ebf47130e4866e96fce0953f49152a61190b271) Updates `actions/setup-java` from 5.2.0 to 5.3.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/be666c2fcd27ec809703dec50e508c2fdc7f6654...ad2b38190b15e4d6bdf0c97fb4fca8412226d287) Updates `actions/cache` from 4.3.0 to 5.0.5 - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4.3.0...27d5ce7f107fe9357f9df03efb73ab90386fccae) --- updated-dependencies: - dependency-name: pnpm/action-setup dependency-version: 6.0.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: actions/setup-java dependency-version: 5.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: actions/cache dependency-version: 5.0.5 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
1ea607829c |
fix(share): load cover under COEP, keep share links out of the clipper, fix in-app import (#4636)
Three issues found while debugging shared-book links: - /s cover was a broken <img>: the page runs under COEP: require-corp (for Turso SharedArrayBuffer), and the cover redirects to a cross-origin R2 presigned URL that can't carry a Cross-Origin-Resource-Policy header, so the browser blocked it. R2 already has CORS, but that's a different header — a plain no-cors <img> needs CORP, which presigned URLs can't set. Serve /s with COEP: credentialless, which keeps the page cross-origin isolated (the Turso replica still boots there) while allowing the image. Scoped to /s; every other route keeps require-corp. - Android share links (https://web.readest.com/s/{token}) were run through the article clipper: useClipUrlIngress excluded annotation links but not share links, so they fell through to clip_url/readability. Skip parseShareDeepLink URLs — useOpenShareLink owns that path. - In-app book import failed with "Origin null is not allowed": the importer fetched /share/{token}/download with the renderer's fetch, and on the app (tauri.localhost -> web -> R2) the second cross-origin redirect nulls the request Origin, which R2's CORS rejects. Use the native HTTP client (tauriFetch) on the app — it follows the redirect and ignores CORS, needs no server change, and works against the deployed server. Web is unaffected: its fetch's redirect to R2 is the first cross-origin hop, so the Origin is preserved and R2 allows it. Adds unit tests (middleware COEP per route, clipper skips share links, download route 302, importer uses native HTTP on app and the renderer fetch on web). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8bcb9f9b2a |
feat(wordlens): trim hints to first sense + suppress known derivations (#4635)
* refactor(wordlens): rename ww-gloss CSS class to wl-gloss Completes the Word Wise → Word Lens rename (#4633) for the gloss ruby class — the 'ww' shorthand was missed. Renamed consistently across the apply site (GLOSS_CLASS), the CSS rules in style.ts, the tap hit-test in iframeEventHandlers, and the browser tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(wordlens): trim hints to first sense + suppress known derivations Runtime, best-effort gloss-quality pass over the shipped en-zh pack (no regeneration): - cleanGloss: strip leading POS tags (now incl. 6-letter `interj.`) and keep only the first sense, so hints stay short — "Ahem" shows 呃哼, not "interj. 呃哼"; multi-sense entries collapse to their first sense. - Derivational reduction (English source only): a would-be-glossed word inherits a known base form's lower rank when the base exists in the pack AND their glosses share meaning, so lazily/shyly/sorrowful/downwards/inwards stop being hinted once lazy/shy/sorrow/… are known. Drifted forms keep their own rank because their gloss doesn't overlap the base (hardly≠hard, lately≠late). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(wordlens): note hint-quality layer + wl-gloss in agent memory Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c2ac207945 |
refactor(wordlens): rename "Word Wise" to "Word Lens" (#4633)
"Word Wise" is a Kindle trademark, so rename the inline-gloss feature to
"Word Lens" throughout the product.
- User-facing strings → "Word Lens" across all 34 locales; brand translated
for Chinese (zh-CN 单词透镜, zh-TW 單詞透鏡) and German (Word-Lens-Daten).
- Code identifiers: WordWise→WordLens, wordWise→wordLens, WORD_WISE→WORD_LENS.
- Files/dirs: src/services/wordwise→wordlens, WordWisePanel→WordLensPanel,
wordwise{Ruby,Section}.ts, build/sync scripts, test dirs/fixtures,
data/wordwise→data/wordlens.
- Storage paths: CDN base, R2 key, on-device cache dir, WORDLENS_R2_BUCKET env,
pnpm wordlens:{manifest,sync}. manifest.json is path-agnostic so its
sha256/bytes stay valid (verified).
- biome.json: point the formatter-ignore at data/wordlens so the generated
one-line gloss packs aren't pretty-printed on commit.
Migration notes:
- Re-run `pnpm wordlens:sync` to upload packs to cdn.readest.com/wordlens/.
- Persisted view-settings keys renamed (wordWiseEnabled/Level/HintLang and
wordWiseAutoDownload) — saved values reset to defaults once on upgrade.
- Cached packs under the old Data/wordwise/ orphan (harmless re-download).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
9d0c5dc524 |
fix(koplugin): sync note deletions to Readest via tombstones (#4632)
Deleting a highlight or bookmark in the koplugin never reached the server. SyncAnnotations:push builds its payload from getAnnotations, which walks only the live ui.annotation.annotations list, so a deleted note can never appear in a push — the server kept the row and the next pull resurrected it. The pull direction (server deletions → koplugin) was already handled by removeDeletedAnnotations (#4119); this is the missing push direction. Capture a tombstone at deletion time instead. onAnnotationsModified detects a removal (negative index_modified, with the deleted item at items[1]) and records a deletedAt-stamped descriptor in the per-book sidecar (readest_sync.deleted_notes). push folds those tombstones into the payload and clears them only once the server accepts them, so a failed push retries. Extracted buildNoteDescriptor so the deletion path derives a note's id identically to the push walk. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5e5564ef3f |
fix(search): show context for matches in italicized text (#4594) (#4631)
Fulltext search showed only the matched word with no surrounding context when the match fell inside inline-styled text (e.g. <i>/<em>). The root cause and fix live in the foliate-js submodule's makeExcerpt (readest/foliate-js#25); bump the submodule pointer to pick it up and add a regression test covering both the simpleSearch and segmenterSearch paths. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b31699d052 |
fix(library): restore back button from series/author folders (#4437) (#4629)
Inside a Series/Author library folder, the back arrow was a no-op after a
cold start. `GroupHeader.handleBack` deleted the `group` query param, leaving
an empty search string; `router.replace('/library')` with an empty search
silently no-ops under the Next.js 16.2 static export (every non-web build).
This is the same root cause as #3782, which was fixed for the breadcrumb
"All" button in #3832 — but the series/author back button never got the
workaround.
It only reproduces after a cold start, when `groupBy` comes from settings
(not the URL) and sort/order/view are at defaults, so `group` is the only
query param; that is why it could not be reproduced within a session.
Fix: set `group=''` instead of deleting it (mirroring
`handleLibraryNavigation`). The resulting `/library?group=` commits, and the
existing cleanup effect in page.tsx strips the trailing empty `group=`.
Verified on-device (Android, WebView 148, static export): tapping back inside
an author folder now returns to the main list.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
5861aead4b |
fix(android): move plugin @Command I/O off the main thread; fix WebView leak & #3297 blank screen (#4628)
Backports the Android ANR/stability fixes from the julianshen fork, adapted to upstream — notably preserving the cold-start shared-intent replay queue and the #4559 dictionary-dispatch logic, neither of which the fork kept. - NativeBridgePlugin: run blocking @Command I/O (copy_uri_to_path, install_package, get_sys_fonts_list, and show_lookup_popover's queryIntentActivities) on Dispatchers.IO via a Main-dispatched pluginScope; startActivity hops back to Main; resolves are isActive-guarded; onDestroy cancels the scope and clears the static instance; the system-font scan is cached (@Volatile). The cold-start shared-intent queue (emitOrQueue / registerListener) is left intact. - MediaPlaybackService: unmarshal the artwork Bitmap off the main thread (serviceScope + Dispatchers.Default), isActive-guarded; cancel the scope in onDestroy. - ClipUrlController: hold the Activity via WeakReference and check isFinishing/isDestroyed before presenting, to avoid leaking the Activity/WebView during the up-to-30s clip window. - MainActivity (#3297): on Android 14+ the window can gain focus before the WebView paints its first frame, leaving a blank screen. Force one repaint when both the window has focus and the WebView exists (whichever happens last). Kotlin-only; not exercised by the JS/Rust test suites. Verified via ktlint parse + a release `tauri android build` and on-device smoke test (Xiaomi). The touch-event throttle and intent-handling rewrite from the fork are intentionally NOT backported (they dropped touchmove forwarding and the cold-start queue). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
fa120081a1 |
fix(library): never let a routine save shrink library.json (cold-start "Open with" wipe) (#4627)
Opening a book via Android "Open with" on a cold start could clear the entire library. `openTransient` built an ephemeral entry on top of the not-yet-loaded (empty) store; the library page's `length > 0` cached-skip then skipped loading the real library from disk; and a later `saveLibraryBooks` persisted the empty/partial set — overwriting both library.json and its .bak. Introduced by #4407 (transient "Open with"), made reliably reproducible by #4527 (reliable cold-start delivery) — so released v0.11.4, which lacks #4527, does not reproduce it. Two layers of defense: - saveLibraryBooks now merges with the on-disk library (union by hash, incoming wins), so a routine save is monotonic: it can add or modify rows (including `deletedAt` tombstones) but can never drop a book. Deliberate, authoritative rewrites (restore, tombstone GC, account reset) opt in via the new `{ replace: true }`. This layer alone makes the wipe impossible. - openTransient loads the real library from disk before importing a transient book — also fixing the cold-start hash-match miss that re-imported already-imported books — and the library page's load-skip now gates on the store's `libraryLoaded` flag instead of `length > 0`. Tests cover the merge floor (no-drop / no-wipe-on-empty / tombstone preserved / incoming-wins) and both `{ replace: true }` paths. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d5c02e6253 |
feat(library): add Purge Data and fold detail actions into a More menu (#4615) (#4626)
Resolves #4615. Re-importing updated serials left the app-generated Books/<hash>/ folder (config.json reading progress/notes, nav.json, cover) on disk after a normal delete, forcing a manual cleanup. "Purge Data" now does a Cloud & Device delete AND wipes the whole directory in one action. The book detail action row is redesigned to Edit · Download · Upload · Delete · More (hamburger): - Goodreads, Share, and Export move into the hamburger "More" menu. - Share is enabled only when signed in and the local file exists; Export is enabled when the local file exists (kept on every platform since the bottom-bar Send is mobile/macOS-only). - Purge Data is the red entry in the Delete menu, behind a strong confirm. Implementation: - DeleteAction gains 'purge'; cloudService.deleteBook('purge') removes the in-place source file and removeDir's the whole Books/<hash>/ folder, clearing downloadedAt and leaving the tombstone + queued cloud delete to the page (mirrors 'both'/'local'). - The library page wires handleBookDelete('purge'); BookDetailModal adds the purge confirm config + share/export handlers and gates Share on auth. Tests: cloud-service purge cases, BookDetailView More-menu + Purge tests. i18n: 9 new keys translated across all 33 locales. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5e217544f2 | chore(config): add disableIncrementalCache to skip populating remote R2 incremental cache (#4623) | ||
|
|
6514d4aa58 |
build(web): standalone Docker image + drop Turbopack build cache (#4619)
The Docker production-stage opts into Next.js `output: 'standalone'` via a BUILD_STANDALONE env flag, so it ships only the traced runtime (server.js + hoisted node_modules + static/public) and runs `node server.js` instead of pnpm over the full source tree. The flag — and `outputFileTracingRoot`, which traces from the monorepo root so workspace packages are included — is set only in the Dockerfile build stage. Every other path keeps its original output: Tauri `export`, local `build-web`, dev, and the Cloudflare/OpenNext deploy (which forces standalone itself via NEXT_PRIVATE_STANDALONE). Disable the experimental `turbopackFileSystemCacheForBuild`: a build interrupted mid-compile leaves a partial cache that the next build mishandles, fanning out workers until it exhausts host RAM. Remove the pull-request CI step that cached `.next/cache` for it, now unused. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f6fbbf59f2 |
chore(deps): bump transitive deps for security advisories (batch) (#4620)
Resolve a batch of transitive Dependabot alerts on the web lockfile via pnpm-workspace overrides. Bumped existing overrides: - vite >=7.3.5 (#244 high, #245 med; GHSA path within 7.3.x) <- was 7.3.2 - dompurify >=3.4.9 (#249-#255; clears #252 <=3.4.6 by leaving the range) - protobufjs >=7.6.3 <8 (#233, #247, #248); bounded <8 to stay on the patched 7.x line (a bare floor let pnpm jump to the 8.x major) - ws >=8.21.0 (#241 high) <- was pinned 8.20.1 Added overrides: - form-data >=4.0.6 (#246 high) - js-yaml >=4.2.0 (#243 med) - '@babel/core' >=7.29.6 (#242 low) - '@opentelemetry/core' >=2.8.0 (#256 med) Not auto-fixable here: #236 @ai-sdk/provider-utils (<=3.0.97, no recorded fix; the installed 3.0.25 is pinned via a local patchedDependencies patch). Verified: pnpm test (5685 passed), pnpm lint, pnpm build-web (exit 0). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d6e59cedd7 |
chore(deps): bump esbuild to 0.28.1 and vitest to 4.1.x for security advisories (#4618)
Resolve transitive Dependabot alerts on the web lockfile. esbuild >= 0.28.1 (GHSA-gv7w-rqvm-qjhr #239, GHSA-g7r4-m6w7-qqqr #238): Deno-module RCE via NPM_CONFIG_REGISTRY and Windows dev-server arbitrary file read. Forced via a pnpm-workspace override (esbuild is a regular dep of vite, so the override applies cleanly); bounded to <0.29 to stay on the verified line. vite 7.3.3 declares ^0.27.0, but the 0.28 JS API is unchanged for vite's usage -- verified by the full test run and web build. @vitest/browser >= 4.1.8 (GHSA-g8mr-85jm-7xhm #240): Browser Mode CDP bridge bypasses allowWrite/allowExec, enabling config overwrite -> RCE. Bumped the vitest devDep family (vitest, @vitest/browser-*, coverage-v8) from ^4.0.18 to ^4.1.8; resolves to 4.1.9. Verified: pnpm test (5685 passed), pnpm lint, pnpm build-web (exit 0). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d202d7a61e |
feat(library): add Clear Pending action to transfer queue (#4617)
* feat(library): add Clear Pending action to transfer queue Adds a "Clear Pending" button to the transfer queue panel that removes only pending (including retry-pending) transfers, leaving in-progress, completed, failed, and cancelled items intact. Wired through the store, manager (with queue persistence), and useTransferQueue hook. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n: translate Clear Pending and reading-statistics strings across locales Adds translations for "Clear Pending" (transfer queue) and the two reading-statistics sync-category strings ("Reading statistics" and its description) across all 33 locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
757ed8066b |
feat(library): show series and number in list view (#4593) (#4612)
In the library list view, surface each book's series and series number on
their own line, in addition to the description. Previously series info was
only visible by grouping by series or opening a book's details.
- Add `formatSeries(series, seriesIndex)` helper ("Series #N", trims the
name, omits a zero/NaN/negative index) with unit tests.
- In list mode, render a dedicated single-line "Series #N" line above the
description when the book has series metadata.
- Clamp every list line (incl. title) to one line and tighten the row gap
to `gap-1` so the extra line fits the fixed-height row without clipping.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
a30a310a17 |
fix(opds): handle entries with no downloadable format (#4599) (#4611)
An OPDS entry with full metadata and a cover image but no acquisition link — e.g. a Calibre book whose file was removed but kept for tracking borrowed/loaned titles — was classified by foliate-js as a navigation item whose href fell back to the cover image link. Tapping it loaded the image, which is neither XML nor JSON, so the OPDS browser crashed with a JSON parse error. - Bump foliate-js to include the getFeed fix that classifies such metadata-only entries as publications instead of navigation. - PublicationView: show "No downloadable format available" when an entry has no acquisition or stream links. - loadOPDS: defense-in-depth — surface a clear message instead of a raw JSON.parse SyntaxError when a response is neither XML nor JSON. - Add tests covering the Calibre no-format entry and a regression guard that a true navigation entry still classifies as navigation; add the two new UI strings across all locales. Closes #4599 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f950685f22 | chore(agent): update agent memories (#4610) | ||
|
|
35b02c4efc |
feat(statistics): KOReader-compatible reading stats with cross-device sync (#4606)
Supersedes #3156. Adds a reading-statistics system whose canonical data model is KOReader's own (book + page_stat_data), so stats round-trip losslessly between Readest and KOReader. - Storage: a cross-platform Turso statistics.db in KOReader's exact schema (web/Workers, desktop, iOS, Android) — replacing #3156's Node-only better-sqlite3 + statistics.json. - Tracking: per-page reading events (time-on-page, idle-capped) flushed on page-change/idle/hide/close — the KOReader model — not session aggregates. - Sync: legacy /api/sync extended with a stats type backed by self-contained Supabase tables (stat_books, stat_pages); union/longer-duration-wins merge keyed on book_hash. apps/readest.koplugin syncs through the same endpoint. - Scale & robustness: per-tab singleton connection (avoids OPFS lock conflicts) + explicit WAL checkpoint; transactional bulk apply; chunked resumable push; client-driven paged pull with trailing-ms completion; paginated/scoped server merge. Verified: 5668 unit tests, 155 koplugin busted tests, biome+tsgo + luacheck all green; web OPFS DB verified live. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
359e406e51 |
docs(readme): make license badge static and modernize release badge (#4603)
The license, release, and last-commit badges all query GitHub's API through shields.io's shared instance, which intermittently fails with "Unable to select next GitHub token from pool" when shields.io's own token pool is rate-limited. A README author can't supply a token to the hosted badges. - License is fixed at AGPL-3.0, so use a static badge that makes no GitHub API call and can never hit the token-pool error. - Switch the release badge from the deprecated `github/release` endpoint (which 301-redirects) to `github/v/release`. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f3c92f80d9 |
docs(readme): remove the Sponsors / TestMu AI section (#4604)
Drop the Sponsors subsection and its lone TestMu AI logo from the Support section. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e145eb835a |
feat(reader): open image gallery & table zoom on single tap (#4600)
* feat(reader): open image gallery & table zoom on single tap In reflowable EPUBs, a single tap on an image or table now opens the same viewer a long-press opens, so the image gallery / table zoom is reachable by both gestures. Fixed-layout books (PDF/comics/manga) keep tap-to-turn, and long-press is unchanged everywhere. Reuses the existing iframe-long-press -> handleImagePress/handleTablePress flow via a new shared detectMediaTarget() helper (also adopted by the long-press path so the two entry points can't drift). handleClick now takes an isFixedLayout flag; the tap branch sits after the link/footnote/drag/ long-hold/Word-Wise guards so linked images still follow links and a long-hold or double-tap won't double-trigger. Context: #4584 (single taps stop registering after picture zoom on some WebView builds) - this adds a second, independent way into the viewer rather than fixing that root cause. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(reader): rename iframe-long-press message to iframe-open-media The message is now posted for both a long-press (any book) and a single tap on an image/table (reflowable books), so the long-press-specific name was misleading. Rename the message type to `iframe-open-media` and the consumer hook `useLongPressEvent` -> `useOpenMediaEvent`. The long-press detector (`addLongPressListeners`/`handleLongPress`) keeps its name since it still detects a long-press specifically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
675ee78bc9 |
perf(library): in-place re-import is a no-op on the same file path (#4597)
Re-importing a folder via the in-place option used to reopen, parse,
and partial-MD5 every file before its byHash entry could short-circuit
the import. Worse, the byHash short-circuit treated every hit as "user
dropped a fresh file matching a known book", so it refreshed
createdAt/updatedAt/downloadedAt and cleared filePath/deletedAt. For a
user re-scanning the same external folder, that quietly rewrote sort
order and wiped soft-delete state. And once the import returned, the
ingest pipeline still ran group / tag / upload work — including a
path-derived empty groupId that silently clobbered manual
GroupingModal assignments on every re-scan.
This change adds an explicit byFilePath fast path at the top of
`ingestFile` so a re-scan returns the existing library entry verbatim,
before any I/O AND before any downstream side effect:
byFilePath hit -> the same on-disk source is being re-scanned in
place; the right answer is no-op. Don't open the
file, don't touch any timestamps, don't re-cover,
don't run the group / tag / upload steps.
byHash hit -> a different source path resolves to a known book
(e.g. the user dropped a copy from elsewhere, or
a soft-deleted book is being revived); the
existing "refresh timestamps + clear deletedAt"
behavior in importBook is correct here and is
left intact.
Implementation:
- BookLookupIndex carries byHash / byMetaKey / byFilePath, with
byFilePath built only from non-deleted books that have an absolute
filePath. normalizeFilePathForIndex is the shared key function so
shouldImportInPlace and the index agree on case-insensitive
filesystems (macOS / iOS / Windows). osPlatform threads through
buildBookLookupIndex and BaseAppService.importBook so the renderer
and the importer compute the same key for the same file.
- ingestService.ingestFile gains a byFilePath fast path before its
importBook call: when `inPlace` was decided positive, the source
is a real on-disk path (not a PSE stream / URL / content URI),
and lookupIndex.byFilePath has a hit, return the existing Book
directly. Returning here — rather than inside importBook — is
deliberate: it skips the downstream group / tag / upload steps so
a re-scan can never silently overwrite a manual GroupingModal
assignment via a path-derived empty groupId.
- ingest-service.test.ts covers both halves: an in-place re-import
short-circuits importBook entirely (no call, existing object
returned, createdAt / updatedAt / groupId / groupName all
untouched); a copy-mode import (no external library folders) with
the same byFilePath entry still goes through importBook so dedup
falls back to byHash. import-metahash.test.ts retains the
BookLookupIndex builder test that deleted and url-backed books
are excluded from byFilePath.
Net effect: re-importing a folder of N already-imported books does
zero file opens, zero parses, zero MD5 passes, and leaves every book's
groupId / createdAt / deletedAt / cover untouched.
|
||
|
|
bdfb595950 |
docs(readme): point donations to the unified donate.readest.com hub (#4601)
donate.readest.com now lists every donation method, so the Support section links there instead of enumerating GitHub Sponsors, Stripe, and crypto separately. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
79496f88d7 |
feat(settings): move update & telemetry controls into Settings → Behavior (#4592)
Relocate the update and telemetry toggles out of the library settings menu into the Behavior (Control) panel, where global app settings live: - New "Update" boxed-list (gated on hasUpdater): Check Updates on Start + Nightly Builds. - New "Privacy" boxed-list: Help improve Readest (telemetry). - Behavior section order: Update → Security → Privacy. - Rename "Nightly Builds (Unstable)" → "Nightly Builds" and drop the "; may be unstable" note (the channel stays off by default). - Updater dialog now shows the full version name (e.g. 0.11.4-2026061506) instead of a parsed date. - Extract + translate the new strings (Update, Privacy, Nightly Builds, Early daily builds) across all 33 locales. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4908245042 |
feat(reader): Word Wise inline vocabulary hints (#4589)
* feat(reader): Word Wise — inline native-language vocabulary hints Kindle-style Word Wise: a short native-language gloss renders above difficult words as you read (always-on ruby), gated by a CEFR vocabulary-level slider (A1–C2); tapping a glossed word opens the existing dictionary. - Pipeline: CEFR→frequency-rank difficulty, inflection-aware gloss index, pure offset-aware planner (EN regex + jieba for CJK). - Rendering: <ruby cfi-skip>…<rt cfi-inert> injected per occurrence — CFI-transparent (verified), so highlights/bookmarks/progress are unaffected; kept out of TTS word offsets and find-in-book. - Delivery: gloss packs are version-controlled in data/wordwise/, mirrored to R2, and downloaded on demand into local storage (sha-verified, single-flight) when enabled. - Settings: a Word Wise sub-page under Settings → Language (enable, level, hint language, per-pack download/manage, auto-download toggle). - Build tooling: scripts/build-wordwise-data.mjs (ECDICT / CC-CEDICT+HSK / WikDict + FrequencyWords, with lemmatization) and scripts/sync-wordwise-r2.mjs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * data(wordwise): bundled gloss packs + manifest + attribution 13 frequency-trimmed gloss packs (en↔中文 + es/fr/de/pt/it/ru↔en, ~19 MB) generated by build-wordwise-data.mjs from ECDICT (MIT), CC-CEDICT + HSK, and WikDict + FrequencyWords (CC-BY-SA). Source of truth, mirrored to the CDN via `pnpm wordwise:sync`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
51fede1a0d |
fix(rsvp): keep the audio toggle from overlapping transport on mobile (#4585)
The read-along audio toggle + settings gear sat in an `absolute end-0` cluster overlaid on the centered transport row. After the #3235 read-along feature grew that cluster from a single gear (~36px) to ~81px (audio + divider + gear), it covered the right end of the transport on narrow phones, hiding the audio button behind the "skip forward 15" control. Lay the playback controls out as a single full-width flex row: the audio toggle moves to the far left and the settings gear stays far right, symmetrically flanking the centered play button (justify-between on mobile, justify-center on md+). Tighten the secondary buttons on mobile (h-8, px-1.5) and add shrink-0 so the row fits without overlap; the symmetry keeps the play button centered. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b76e3a3718 |
fix(nightly): publish latest.json via directory rclone copy (#4588)
The assemble-manifest job promoted the manifest with single-file `rclone copyto` + `moveto`. Before a single-file upload rclone issues a CreateBucket probe (PUT /<bucket>), which the object-scoped RELEASE_R2_* token can't satisfy -> 403 AccessDenied, so nightly/latest.json was never published (the build legs and the stable release flow were fine because they use a directory `rclone copy`, which PUTs the object directly without that probe). Mirror the release flow (upload-to-r2.yml): copy a one-file directory into nightly/. R2 PutObject is atomic, so the .tmp + server-side move added nothing. Verified against the live bucket with the current token: directory copy -> 200 OK; single-file copyto -> CreateBucket 403. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
aab721b219 |
feat(dictionary): lemmatize inflected words before lookup (#4574) (#4582)
Dictionaries that store only base headwords (e.g. Oxford Dictionary of English) miss inflected selections like `ran`, `mice`, `children`, or `analyses` even though the lemma (`run`, `mouse`, `child`, `analysis`) is present. Add a language-aware lemmatizer whose base-form candidates are appended to the existing lookup candidate chain, after the exact/case variants, so an exact/case match always wins and the lemma is only tried once those miss. - New pluggable `lemmatize/` registry keyed by primary language subtag; add a language by registering one lemmatizer, no caller changes. - English lemmatizer: irregular-form table (suppletive verbs, irregular plurals/comparatives) + regular suffix rules (plural/past/gerund/ comparative/possessive). Over-generates on purpose — the dictionary lookup is the validator, so bogus stems simply miss. - Unknown/missing book language defaults to English (no-op on non-ASCII); an explicit non-English language with no registered lemmatizer is a no-op. - Applies centrally to all definition providers (mdict/stardict/dict/slob and the online builtins) via `buildLookupCandidates`. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
131f83e15b |
fix(ci): correct nightly Linux AppImage collect path (#4581)
The nightly Linux legs build with `cargo tauri build` WITHOUT `--target`
(no matrix `args`), so cargo emits bundles under `target/release/bundle/`
(host-target default) rather than `target/<triple>/release/bundle/`. The
macOS/Windows legs DO pass `--target`, so they legitimately get the triple
subdir — but the "collect artifacts" step reused `${rust_target}` for the
Linux AppImage path too, looking under
`target/x86_64-unknown-linux-gnu/release/bundle/appimage/` where nothing
exists. The build succeeded; only the collect step failed with
"missing artifact or signature for linux-x86_64-appimage".
Drop the `${rust_target}` subdir from the Linux AppImage path so it points
at the host-target default location where the bundle actually lands.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
0f0b4279a7 |
perf(reader): memoize global-annotation fan-out per section (#4575) (#4579)
Highlighting recurring text (e.g. main-character names) as global annotations made page turning very laggy. The `progress` effect re-fans-out every global annotation across every rendered section on EVERY page turn, and each pass re-walks the section DOM, recomputes `view.getCFI()` for every occurrence, and tears down + recreates an SVG overlay per match. The overlays already exist after the first pass, so this is pure wasted work — profiled at ~25–45ms of synchronous main-thread time per page turn for 6 names / 226 occurrences across 2 rendered chapters, multiplied on slower mobile hardware. Memoize, per live section `Document`, which global notes have been expanded (signature embeds `updatedAt`/style/color/text). Subsequent page turns short-circuit to ~0ms. Keying on the `Document` makes invalidation automatic: a re-rendered section gets a fresh document (and fresh overlayer) so its overlays are rebuilt, while edits/recolors bump `updatedAt` and toggling global off clears the memo. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
57501cc520 | feat(updater): nightly update channel (Android/Windows/macOS/Linux) (#4577) | ||
|
|
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> |
||
|
|
cc618b8739 |
test(tts): add browser e2e for auto-advance across a chapter boundary (#4573)
Mounts the real foliate <foliate-view> with sample-alice.epub, renders the real useTTSControl hook with the real stores, and mocks only the speech client. Starts TTS at the last paragraph of chapter 4 and verifies the reading auto-advances into chapter 5, the page turns, and the "Back to TTS Location" badge never appears (the TTS location stays in view). The mock client's speak() only needs to yield `end` — the real TTSController drives forward() and the real view.tts walks the document across the section boundary, so the cross-chapter navigation and badge suppression are genuinely exercised rather than re-implemented in the test. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5a8f0873fa |
fix(library): refresh book cover after editing metadata (#4572)
* fix(library): refresh book cover after editing metadata Editing a book's cover in Book Details and saving showed the old cover until a full reload, in two render paths: - Library grid: handleUpdateMetadata mutated the book object in place, so the memoized <BookCover> compared fields off the same (mutated) reference and skipped re-rendering. Build a new book object via the new getBookWithUpdatedMetadata helper instead of mutating. - Book Details view: BookDetailView renders cover/title/author from the modal's `book` prop, which the parent never re-passed after save. BookDetailModal now tracks the saved book locally (displayBook) and renders the view from it. Adds a unit test for the immutable helper, a BookDetailModal regression test (edit cover -> save -> view reflects it), and a sample-alice.txt fixture for TXT import testing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(agent): add cover-refresh stale-render memory Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4b0bbc77b0 |
fix(reader): open TXT files shared via "Open with" (#4571)
* fix(reader): open TXT files shared via "Open with" by converting to EPUB
The Android "Open with Readest" (VIEW intent) transient path hands the
reader the original .txt file (its filePath points at the content:// URI),
unlike the managed library which stores the already-converted EPUB. The
DocumentLoader had no branch for a raw .txt, so open() returned
{ book: null } and initViewState crashed with
"TypeError: Cannot read properties of null (reading 'metadata')",
leaving the user stuck on the library splash.
Add an isTxt() check that converts the raw .txt to EPUB in-memory (the
same TxtToEpubConverter the import path runs) and parses that. The
converter emits a .epub-named file, so the importer's own
DocumentLoader.open() on the converted file is unaffected.
Verified on-device (emulator, warm + cold start): the TXT now opens and
renders in the reader instead of crashing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): allow adjusting highlight opacity in e-ink mode
Drop the isEink prop that disabled the highlight Opacity slider under
e-ink. Opacity is still meaningful on e-ink, so let users change it.
Removes the prop from HighlightColorsEditor, its ColorPanel call site,
and the test render helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(send): mock fetch to fix flaky article conversion test
The article/page conversion paths fetch a favicon + author image for the
synthetic cover via globalThis.fetch. In jsdom that hit the real network:
a live fetch to the sample URL can hang up to faviconFetcher's 6s timeout,
exceeding the 5s test timeout and intermittently failing the suite. Stub
fetch so the cover falls back to its initial-letter tile (the pattern other
tests in this suite already use). Article test: ~5003ms hang -> ~80ms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(agent): update annotation-share-toolbar memory
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
67c22c770b |
feat(reader): Share intent + customizable annotation toolbar (#4014) (#4570)
* docs(spec): annotation Share tool + customizable toolbar (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(plan): implementation plan for Share tool + customizable toolbar (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(annotator): add 'share' annotation tool type and button (#4014) * feat(annotator): add pure toolbar order/visibility helpers (#4014) * feat(annotator): add annotationToolbarItems view setting (#4014) * feat(annotator): add shareSelectedText ladder helper (#4014) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(annotator): render Share tool and honor toolbar order in selection popup (#4014) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(settings): add drag-and-drop annotation toolbar customizer (#4014) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(settings): open the toolbar customizer from the Behavior panel (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(i18n): extract and translate annotation share/toolbar strings (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(annotator): extract canShareText helper, preserve hidden Share on cross-platform edit (#4014) Addresses final-review findings: de-duplicate the triplicated canShare definition into share.ts::canShareText, trim ShareCapableService to the fields actually read, and stop the toolbar customizer from dropping a synced 'share' tool when edited on a non-share-capable device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(settings): WYSIWYG drag-and-drop toolbar customizer (#4014) Rework the customizer per live testing: - Render 'In toolbar' as a faithful, content-width, start-aligned preview of the real selection popup (gray bar, icon-only buttons); 'Available' tools show as labeled chips. - Multi-container dnd-kit pattern: in-place dragging (no DragOverlay, which a transformed modal offsets), pointerWithin collision so empty zones accept drops, live onDragOver reparent, itemsRef to dodge dnd-kit's drag-start handler-capture stale closure. - Add 'Add all' (canonical predefined order) and 'Clear all' shortcuts. - Align zone labels with the SubPageHeader breadcrumb. - Empty toolbar now suppresses the selection popup entirely (no empty bar), while still allowing highlight-edit/notes popups. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(i18n): translate Add all / Clear all toolbar shortcuts (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(annotator): size selection popup to visible tool count (#4014) With the customizable toolbar a fixed-width popup looked sparse for a 2-3 tool toolbar (buttons spread to the corners). Size the popup to the number of visible tools (responsive) capped at the previous max; annotated selections keep the max width since they show highlight options / notes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): reword empty-toolbar hint to 'No tools, drag one here' (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(annotator): render default tools (not Share) in popup layout screenshot (#4014) The visual regression test rendered every annotationToolButtons entry, so adding the Share tool shifted the toolbar to 9 buttons and broke the baselines. Share is hidden by default (added via Customize Toolbar), so the popup screenshot should mirror the default-enabled set — filter to DEFAULT_ANNOTATION_TOOLBAR_ITEMS, keeping the existing baselines valid. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b6937f43f1 | chore(agent): stage memories (#4569) |