Translate 4 new keys across all 33 locales:
- Send
- Book file is not available locally
- Failed to send book
- Highlight Current Sentence
Also commit pending .claude/memory bug-fix notes.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a multi-column spread a coloured page's background bled into the outer margin gutter (the --_outer-min track) while an adjacent transparent/image page did not, shifting cover/title spreads off-centre. Bump foliate-js to clamp each background segment to its column instead of stretching into the gutter, keeping the symmetric margins intact. Update the computeBackgroundSegments regression tests.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Virtualizing BooknoteView (#4352) dropped the auto-scroll that centers the
note nearest the current reading position, leaving the list stranded at the
top. The single scrollToIndex that replaced the per-item useScrollToItem was
missing the machinery TOCView already uses for virtualized auto-scroll:
- Re-apply the scroll inside the OverlayScrollbars `initialized` callback
(read via a ref): its deferred init resets the viewport scrollTop to 0, and
the lastScrolledCfiRef guard otherwise blocked any retry (reload case).
- Mount Virtuoso natively centered via initialTopMostItemIndex with a
skip-gate, so opening the panel while reading doesn't fire a scrollToIndex
that races and wedges the freshly mounted, unmeasured list (tab-switch case).
- Jump instantly (behavior 'auto') for far moves and on eink, animating
'smooth' only for short in-session updates — mirroring TOCView.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dark-mode `table *` color-mix tint in getColorStyles was applied
unconditionally since #4055, so plain tables — and the invisible spacer
cells some books use for vertical TOC layout — rendered a few shades off
the page background, and the spacing between words appeared to change.
Restore the `overrideColor` gate that #2377 originally added. Illegible
light/zebra table backgrounds (the #4028 case #4055 targeted) are now
handled separately by the dark-mode light-background rewriters from
#4392, so the blanket tint is no longer needed by default. The
standalone blockquote tint stays unconditional in dark mode.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Long-pressing an image in the zoom viewer on Android triggers the
WebView's native image callout (context menu / drag / magnifier) at the
same time as the viewer's own pinch/pan/zoom touch handlers, locking up
the whole app until restart. Same root cause as the book-cover freeze
(PR #4345): `-webkit-touch-callout: none` doesn't inherit, so the class
must sit on an ancestor of the `<img>`.
Apply the existing `.no-context-menu` class to the viewer container so
the `.no-context-menu img` rule reaches the zoomed image and disables
the native callout. Harmless on desktop (the property is a no-op there
and right-click-save still works).
Closes#4420
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The book "Send" flow had to pass `new ArrayBuffer(0)` to saveFile purely to
satisfy the type-checker: the content arg is ignored on the native share
path when `options.filePath` points at an already-on-disk file. Widen
saveFile's content parameter to `string | ArrayBuffer | null` across the
AppService contract so callers can hand off a file by path without buffering
it into memory, and pass `null` from the Send flow instead of a throwaway
empty buffer.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chinese web-novel TXT files are commonly named 【书名】1-129 作者:起落.txt and
carry a noisy metadata block at the top of the file. Two author-recognition
failures resulted after importing them:
1. Author missing — extractTxtFilenameMetadata only pulled an author from
《》-wrapped names, so 【】-style names yielded no filename author, and when
the file header had no clean "作者:X" line the author came out empty.
2. Irrelevant content as author — the greedy file-header capture
(/作者…(.+)\r?\n/) grabbed a publication blob like
"2024/08/01发表于:是否首发:是 字数1023150字…" and surfaced it as the author.
Fix:
- extractTxtFilenameMetadata now extracts the labeled "作者:X" form from any
filename (title stays the full name; only the labeled form is safe so a
leading 【title】 isn't mistaken for the author).
- Validate the header-matched author (isPlausibleAuthorName) and fall back to
the filename author when it looks like a metadata blob — embedded field
separator, long digit run, or excessive length. Applied to both the small-
and large-file conversion paths.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bookshelf right-click menu built itself with un-awaited
`Menu.append()` calls. Each append is an async IPC round-trip to the
Tauri backend, so the concurrent fire-and-forget requests resolved in
non-deterministic order on the Rust side and the native menu items
landed shuffled on every open (only reproducible on the native app,
invisible in jsdom).
Build the items in order and create the menu in a single
`await Menu.new({ items })` call for both the book and group handlers.
Order and conditional inclusion are unchanged.
Extract the order/inclusion logic into a pure `getBookContextMenuItemIds`
helper in `libraryUtils.ts` so the deterministic ordering is unit-tested
without mounting the component or mocking Tauri.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Change Data Location dialog rendered its Cancel/Close (secondary)
buttons with `btn-outline`. Under `[data-eink='true']`, globals.css
inverts both `.btn-outline` and `.btn-primary` to the same base-content
fill + base-100 text, so Cancel and Start Migration collapsed into two
identical black buttons and became indistinguishable on e-ink screens.
Switch the secondary buttons to `btn-ghost`, matching the design-system
rule (DESIGN.md): the primary CTA keeps its solid fill while the ghost
cancel reads as borderless next to it, restoring the hierarchy.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(library): send book file from bookshelf selection popup
Adds a Send button to the bottom popup that appears when one or more
books are selected on the bookshelf. Hands the actual book file
(epub/pdf/...) to the OS share sheet via tauri-plugin-sharekit
(UIActivityViewController on iOS, Intent.ACTION_SEND on Android,
NSSharingServicePicker on macOS), so users can fire the file off to
Mail / Messages / WeChat / AirDrop / etc.
This is intentionally distinct from the per-item context-menu "Share
Book", which uploads the book to the readest backend and generates a
public link. "Send" is offline file egress; "Share Book" is remote
collaboration. They share zero infra.
Resolution rules mirror bookContent.resolveBookContentSource: managed
copy under Books/<hash>/ first, then the device-local in-place import
path. Cloud-only books warn rather than silently no-op.
Path is handed to shareFile via options.filePath. Without that,
saveFile() falls back to writing a temp copy under BaseDirectory.Temp,
which on Android resolves to /data/local/tmp/ — the app sandbox has
no write permission there and the call fails with EACCES ("failed to
open file at path: /data/local/tmp/...epub Permission denied (os error
13)"). Passing the absolute path also avoids re-buffering the entire
epub/pdf into memory.
On macOS the NSSharingServicePicker is anchored to the selected
book's cover rather than to the Send button — the user's visual
focus is on the cover they just tapped, not on the bottom toolbar.
BookshelfItem stamps a data-book-hash attribute on its root div so
the Send handler can locate the cell via querySelector and pass its
rect through saveFile's sharePosition option. preferredEdge='bottom'
maps to NSMinYEdge, so the popover renders above the cover (and only
auto-flips below when there's no room above). iOS / Android share
sheets are modal and ignore sharePosition, so the same code is a
no-op there.
The button is hidden on Linux (no system share sheet), Windows
(WebView2 share UI deadlocks the main thread, see #4343), and web
browsers (no "send file to <app>" affordance for arbitrary downloads).
* fix(share): don't fall back to saveDialog when shareFile is cancelled
The native saveFile({ share: true }) path used to swallow any error
from sharekit's shareFile() and fall through to saveDialog. The plugin
treats user cancellation the same as a failure (it rejects with
'Share cancelled' on Android when the user dismisses the share sheet),
so cancelling a share popped up an unwanted 'Save As...' dialog right
after the user explicitly chose not to share.
Mirror what webAppService already does for the navigator.share()
AbortError path: once we entered the share branch, return true
regardless of whether the share completed or was cancelled. The
saveDialog path is now reserved for Linux/Windows desktop, which never
hit the share branch in the first place (wantShare gates them out).
If a future caller wants 'try share, fall back to save on hard
failure', that decision belongs to the caller — saveFile shouldn't
silently override an explicit share intent.
The always-on page-info footer (`.progressinfo`) is a decorative
`role='presentation'` element, but it carried `tabIndex={-1}`, which made
it focusable. On Android, long-pressing the footer focused the div and the
WebView painted its default focus ring (`outline: auto`). Because the
element is pinned `absolute bottom-0` at book-view width, the ring rendered
as a content-column-wide line across the bottom of every page and persisted
until focus cleared.
Remove the `tabIndex` so the decorative element can no longer receive
focus. The `onClick` (tap-to-cycle progress mode / dismiss popup) still
fires regardless of tabindex, nothing focuses it programmatically, and the
translated `aria-label` keeps it exposed to screen readers unchanged.
Closes#4397
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On Android, tapping an EPUB/MOBI/AZW3 in the system file browser and choosing Readest only opens the library — the book itself never opens in the reader. Now Readest can open the book now.
navigator.clipboard.writeText is unreliable inside the Tauri Android
WebView, so tapping Copy in the Reader's selection popup silently
no-ops. Route the write through @tauri-apps/plugin-clipboard-manager
on Tauri targets (Android, iOS, macOS, Windows, Linux), with a
graceful navigator.clipboard / execCommand fallback for the web
build and older WebViews.
- Add tauri-plugin-clipboard-manager (Rust + JS)
- Register the plugin in lib.rs
- Grant clipboard-manager:allow-write-text / allow-read-text
- New utils/clipboard.ts wrapper with platform-aware fallback chain
- Annotator handleCopy and handleConfirmExport use the wrapper
Switch ReadwiseClient to @tauri-apps/plugin-http when running in
Tauri desktop mode, matching the pattern already used by WebDAV,
Hardcover, AI providers, translators, OPDS, and KOReader sync.
In a Tauri webview context the standard window.fetch is still subject
to CORS preflight rules and—on Android—the platform's cleartext-traffic
policy. @tauri-apps/plugin-http sends the request from the Rust side
via reqwest, bypassing the renderer entirely (no Origin header, no
preflight, no cleartext block). ReadwiseClient was one of the few
remaining services that had not yet adopted this transport, so users
on the desktop build could hit CORS or network errors when validating
tokens or pushing highlights.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Custom fonts (and textures) disappeared a few seconds after opening a book
when logged into cloud sync. Under the replica layer's CRDT remove-wins
semantics, deleting a font writes a server-side tombstone that a plain
field upsert cannot revive — only a reincarnation token whose HLC beats
the tombstone does. Re-uploading the same file (same contentId) cleared
`deletedAt` locally but published the upsert with no token, so the tombstone
survived and the next pull (boot/periodic/book-open/visibility) re-applied
the soft-delete via softDeleteByContentId, making the font vanish. Logging
out stopped the pull, which is why it only reproduced while signed in.
addFont/addTexture now mint a reincarnation token when re-adding an existing
entry that is either soft-deleted or still-live with the same contentId (and
has no token yet), preserving any existing token. This mirrors the
dictionary's reincarnation handling (dictionaryService) and OPDS's token
style, fixing both the local re-import-after-delete case and the multi-device
stale-local race. The token is inert when there is no tombstone, so live
re-imports remain safe.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The background texture (mounted on the reader container as
`.foliate-viewer::before`) showed in scrolled mode but was absent in
paginated mode: the paginator painted an opaque #background container
over it. Bump foliate-js to leave that container transparent under a
texture, and add a regression test for the shared textureAwareBackground
helper.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wide or tall tables, code blocks and display equations overflowed the reading
column and a scroll gesture over them turned the page instead of scrolling the
content (#4400).
- Wrap tables and display equations in a horizontally/vertically scrollable
container; route touch + wheel along the box's scrollable axis so it scrolls
the box and never turns the page, even at the edge (both axes).
- A box that fits its column is marked fit (overflow:visible) so it never clips
or captures gestures; the fit decision is measured once after layout via a
self-disconnecting ResizeObserver, so it never relayerizes during a page turn.
- The scroll wrapper carries a new cfi-skip attribute that makes it transparent
to CFI: epubcfi.js hoists a cfi-skip node's children into its parent (unlike
cfi-inert which drops the subtree), and xcfi.ts mirrors this for CFI<->XPointer
so existing highlights, bookmarks and KOSync positions inside a wrapped table
or equation still resolve. The sanitizer whitelists cfi-skip.
- Bump foliate-js submodule (cfi-skip support + raf fallback for large sections).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Why:
- Dark mode sets theme foreground on html/body and rewrites black text,
but EPUB callout boxes often keep white/light backgrounds from inline
styles or publisher CSS unless override book color is enabled.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(reader): scroll wide tables horizontally instead of scaling
Why:
- Wide EPUB tables (e.g. many columns without cell widths) overflowed the
page because CSS scale only applied when widths were known.
- Paginated mode stole horizontal swipes for page turns over table content.
Refs:
- Replaces transform-based applyTableStyle scaling with a scroll wrapper
and capture-phase touch routing (same pattern as gesture brightness).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(reader): keep wheel/trackpad table scrolling from turning the page
Horizontal scrolling of a wide table in paginated mode also turned the page:
- applyTableTouchScroll only routed touch events. Trackpad/mouse wheel
(readest forwards iframe wheel -> 'iframe-wheel' -> pagination) was
never intercepted, so a horizontal wheel both scrolled the table and
flipped the page.
- findWrapper used `instanceof Element`, which is always false for iframe
event targets because this module runs in the top-window realm. The
touch routing therefore never fired either.
Add a capture-phase wheel handler that consumes horizontal wheels over a
scrollable table -- including at the scroll edge, so the gesture (and
trackpad momentum) never chains into a page turn -- and make findWrapper
cross-realm safe via duck-typing on `closest`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): don't show a spurious scrollbar on layout tables
Wrapping every table for horizontal scrolling made tables that fit the
column (e.g. character/glossary layout tables) show a spurious horizontal
scrollbar, because the wrapper was always scrollable.
Now the wrapper clips (no scrollbar) for a table that fits within a few px
of the column, and a ResizeObserver re-evaluates this as the column width
settles. A table genuinely wider than the column always scrolls and is
never clipped; one that wraps to fit shows no scrollbar. Touch/wheel
routing engages only scrollable wrappers.
Add a Chromium browser test over sample-table-layout.epub (layout tables
must not scroll) and sample-table-wide.epub (a too-wide table must scroll,
not clip).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A `.window-state.json` containing the Windows minimized sentinel
(x/y = -32000) or a 0×0 size makes WebView2 reject the restored bounds
with 0x80070057 ("The parameter is incorrect"), so the app fails to
launch until the file is deleted by hand.
Add a small `window-state-sanitizer` plugin, registered before
tauri-plugin-window-state, that strips window entries with invalid
geometry (non-positive size, or a position past the -16000 off-screen
cutoff) from the state file before the plugin loads it. Affected windows
fall back to default geometry instead of crashing.
Defense-in-depth: the bundled plugin (2.4.1) already guards against
writing these values, so a bad file is almost certainly stale from an
older build; this self-heals it on next launch.
Refs #4398
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The AppStream metainfo had no <requires>/<supports> relations, so software
stores such as Flathub listed Readest as "Desktop only". Readest is adaptive
and runs on desktop (keyboard/mouse) as well as phones and tablets (touch).
Declare a 360px display_length baseline plus keyboard, pointing and touch
controls so stores surface both Desktop and Mobile.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In scrolled mode the ruler's geometry-cache effect re-ran on every
relocate — including those fired continuously while scrolling — and
re-snapped the band to the next line forward from a screen-fixed
anchor, so the band crept down the page as the reader scrolled.
Place the band once on mount (and after a viewport-dimension change),
but never re-snap on a plain scroll relocate. Click-driven snapping
(the reading-ruler-move handler + pendingScrollAlign realign) is
unchanged, and paginated mode is unaffected.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
uploadBook only attached a cover.png when one was already cached under
readest_covers/<hash>.png from a prior cloud download. Books that
originated locally in KOReader were never downloaded, so the cover step
was silently skipped and they synced to Readest with no cover.
Add extractLocalCover, which renders the book's embedded cover via
coverbrowser's FileManagerBookInfo:getCoverImage(nil, file_path) and
writes it as PNG. uploadBook now falls back to it when no cached cover
exists, caching the result under covers_dir so the Library view reuses
it like a downloaded cover.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): inline custom @font-face rules in iframe stylesheet
The reader iframe's first paint resolved with the default serif/sans
fallback and only swapped to the user's configured custom font a moment
later, producing a visible font flash when opening a book.
Custom font @font-face rules were registered via mountCustomFont on the
host document only, while paginator's setStyles writes its CSS into
the iframe synchronously before the first 'load' event. The iframe had
no knowledge of the user's custom fonts at that point, so font-family
declarations in the stylesheet matched nothing and fell back.
Inline the @font-face rules for every loaded custom font (blob URLs
already in memory, no network round-trip) at the front of getStyles
output, so paginator delivers them to the iframe atomically with the
rest of the stylesheet. Defensive try/catch around createFontCSS keeps
a single bad font from breaking the whole stylesheet.
* refactor(reader): pass custom fonts into getStyles instead of reading the store
getStyles lives in src/utils, where every other file is a pure function;
it was the only one importing a store (useCustomFontStore). Keep the
util pure: accept the loaded custom fonts as a parameter and let the
reader components — which already own the font store — supply them.
- style.ts drops the useCustomFontStore import and the SSR/store-error
guards in getCustomFontFaces; the helper is now a pure CustomFont[] ->
CSS transform.
- getStyles(viewSettings, themeCode?, customFonts = []) inlines the
@font-face rules for the passed fonts.
- The first-paint call sites (FoliateViewer, FootnotePopup) pass
getLoadedFonts(); settings-panel re-styles keep the default [] since
custom fonts are already mounted as persistent <style> elements there.
- Add tests that exercise the font-face inlining path (the existing
suite never did, since the store is empty under jsdom).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pdfjs-dist 5.7.x (bumped 5.4.530 -> 5.7.284 in #4143) moved the JBIG2
image decoder -- the codec used by virtually every black-and-white
*scanned* PDF -- from pure JS into a WebAssembly module the worker
fetches at runtime from `wasmUrl` (/vendor/pdfjs/). The `copy-pdfjs-wasm`
script only copied an explicit allow-list ({openjpeg.wasm,qcms_bg.wasm})
and silently dropped jbig2.wasm. cpx does not error on an empty glob, so
the missing decoder went unnoticed: scanned PDFs rendered blank (pages
still turned) in CI builds, while local `tauri build` reused a stale
pre-bump public/vendor copy and worked. Regression between 0.11.1 and
0.11.2.
Copy the whole wasm/ dir (mirrors the {cmaps,standard_fonts}/* fonts
pattern) so future decoders moving to wasm can't be dropped again, and
ship the *_nowasm_fallback.js files for graceful degradation.
Adds a regression test asserting every .wasm the bundled pdf.js
references is covered by copy-pdfjs-wasm.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A cover with `data-duokan-page-fullscreen` on <html> renders in
paginated mode but is blank in scrolled mode; only the first cover is
affected, other images are fine in both modes.
The paginator's fullscreen branch pins such images with
position:absolute and height:100% and forces their ancestors to
height:100%. That fills the fixed-height page when columnized, but in
scrolled mode the container height is `auto`, so height:100% resolves
to 0 and the cover collapses out of view.
Bump foliate-js to gate the fullscreen treatment on column mode and
reset any stale absolute pinning when a fullscreen-cover doc is laid
out scrolled (so toggling paginated -> scrolled also recovers). Add a
browser regression test + repro-4379.epub fixture.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Android and Windows-portable jobs fetched the existing updater
manifest with `curl -sL .../latest.json -o latest.json`. Without `-f`,
curl writes the server's 404 body ("Not Found") into the file and exits
0, after which `gh release upload --clobber` uploads that invalid JSON as
the release's latest.json.
tauri-action's updater step then downloads the existing latest.json and
runs `JSON.parse(...)` to merge new platforms in. Parsing "Not Found"
throws `Unexpected token 'N', "Not Found" is not valid JSON`, failing
every build-tauri matrix leg after its bundles were already uploaded.
Use `curl -fsSL` so an HTTP error fails the step instead of writing the
error body, and validate the download with `jq empty` before merging, so
a corrupt manifest can never be clobbered onto the release again.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sync: empty-start/end range CFIs left by the cfi-inert skip-link bug (e.g.
epubcfi(/6/24!/4,,/20/1:58)) resolve to a section-spanning range and navigate
to the wrong end of the section. Add isMalformedLocationCfi and discard such
locations on the cloud-sync receive path (useProgressSync) and the kosync push
path (useKOSync) so they can't move the reader or propagate to other devices.
foliate 569cc06 stops generating them but does not repair already-synced values.
reader: bump foliate-js to 167757a to fix the white<->black background flash
when swiping between differently-colored pages; add a regression test for the
sliding per-view background segments.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Highlight Current Sentence" keyboard action (default Shift+M, in the
Text to Speech shortcut section) that persists the sentence TTS is reading
aloud as a normal highlight using the user's default style/color — no text
selection, eyes-off, silent, and idempotent (a repeat press on the same
sentence is a no-op rather than a duplicate).
Flow: the shortcut handler in useBookShortcuts dispatches tts-highlight-sentence
→ useTTSControl (which owns the TTSController) resolves the current sentence via
the new TTSController.getSpokenSentence() and relays create-tts-highlight
→ Annotator builds the BookNote with the pure, unit-tested buildTTSSentenceHighlight
helper and persists/renders it like any other highlight.
Closes#4085
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(kosync): compare reflowable conflicts via locally-resolved CFI percentage
KOReader reports progress as a percentage from its own pagination, which isn't directly comparable to Readest's progress. For reflowable books, resolve the remote XPointer to a local CFI and compute the equivalent fraction (getRemoteLocalFraction), comparing that against the local percentage and falling back to the reported percentage only when it can't be resolved locally (non-XPointer progress or a missing section). The resolved fraction also drives the conflict-dialog remote preview so the shown value matches what was compared.
Loosen the conflict threshold to 0.01 when the remote progress was last pushed from this same device (remote.device_id === local deviceId), so sub-page drift between a push and the next pull doesn't prompt. Render sync percentages with 2 decimals via formatProgressPercentage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): correct scrolled-mode reopen drift over background-image sections
Bump the foliate-js submodule to include the scrolled-mode reopen drift fix for sections with background images, and add a browser regression test plus its EPUB fixture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): redirect to login on pull-to-refresh when signed out
Guard the pull-to-refresh handlers so an unauthenticated user is sent to the login screen instead of attempting a library pull and OPDS subscription check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(memory): add kosync conflict + toc/scrolled-restore notes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): prevent CFI crash on inert-only section bodies
Reopening/paginating across a background-image or otherwise content-less section could crash with "Cannot destructure property 'nodeType' of 'param' as it is undefined" in foliate's fromRange, aborting the relocate so the reading position was never saved. Bumps the foliate-js submodule to 569cc06 (visible-range walker skips cfi-inert skip-links; isTextNode/isElementNode are null-safe) and adds a regression test reproducing the exact crash.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(kosync): keep auto-push working when a pull finds no real conflict
In the 'prompt' strategy, pullProgress set syncState to 'conflict' unconditionally on every pull that returned remote progress, even when promptedSync found no actual difference. Since auto-push only runs while 'synced', and a pull fires on every book-open and window re-activation, progress stopped being pushed. promptedSync now returns whether a real conflict was surfaced, and pullProgress only stays in 'conflict' for genuine conflicts (otherwise 'synced').
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): show most recent sync time and reorder settings tabs
Library settings menu now reports the latest of the book/config/note sync timestamps as "Synced …" instead of only the books timestamp. Reorder the settings tabs so Integrations precedes TTS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
generateKOProgress built its XCFI converter from the paginator's
primaryIndex and the rendered primary document, then converted
progress.location. Because #primaryIndex can lag behind the viewport
during scrolling, the CFI's spine section could differ from the
converter's, tripping XCFI's guard ("CFI spine index N does not match
converter spine index M") and silently dropping the progress push.
Route through getXPointerFromCFI, which keys off the CFI's own spine
index and loads the correct section's document from the book when the
rendered index doesn't match. Fall back to the cached config.xpointer
on failure.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In scrolled mode, SectionInfo paints a solid `bg-base-100` `notch-area`
mask over the top safe-area strip at z-10. The Ribbon was also z-10 but
rendered earlier in the DOM, so the equal-z mask painted over the
ribbon's upper (unsafe-area) half — only the lower 44px showed. In
paginated mode the mask has no background, so the ribbon showed fully.
Raise the ribbon to z-20 so the whole ribbon stays visible above the
mask, and mark it pointer-events-none so taps still fall through to the
notch mask's scroll-to-top handler.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single MDX commonly ships its resources across several MDD files sharing the MDX's filename prefix (e.g. `Name.mdd` for images, `Name-02.mdd` for scripts, `Name-03.mdd` for audio). The importer only grouped the exact-stem `Name.mdd`, so the other MDDs were dropped as orphans and their resources (notably audio) could never be loaded.
`groupBundlesByStem` now attaches every `.mdd` whose stem starts with an `.mdx` stem at a separator boundary to that MDX bundle (longest prefix wins on overlap); the boundary check prevents false merges like `dict.mdx` claiming `dictionary-words.mdd`. The runtime provider, contentId, and replica sync (binary upload + manifest apply) already treat `files.mdd` as a list, so multi-MDD bundles sync across devices with no further changes.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(dictionary): keep other dictionaries usable when System Dictionary syncs to an unsupported platform
`dictionarySettings.providerEnabled` is whole-field synced across devices, so enabling System Dictionary on macOS/iOS sets the flag on web/Linux/Windows too. There the row is hidden and the feature is a no-op, but the settings UI read the raw flag and locked every other dictionary's toggle read-only. Gate the lock on `isSystemDictionaryEnabled(settings)` — the same platform-aware check the annotator uses — so it matches real lookup behavior and never triggers where the system dictionary can't run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(dictionary): dispatch system dictionary handoff by native OS (fixes iPad)
iPadOS sends a desktop "Macintosh" user agent, so the UA-based `getOSPlatform()` reported iPad as 'macos' and the handoff invoked the macOS-only `show_lookup_popover` Rust command that iOS never registers ("Command show_lookup_popover not found"). Derive the OS from the app service's `is*App` capability flags (sourced from the Tauri OS plugin, correct on iPad) via a new synchronous `getInitializedAppService()` accessor, so iPad routes to the iOS plugin command path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): constrain reader View Options dropdown to h-8
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(agent): note System Dictionary platform-detection and synced-flag patterns
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(agent): add agent notes for cache, reading-ruler, foliate touch
Add project-memory notes and index entries:
- manage-cache-ios-layout: iOS container layout and what Manage Cache clears
- reading-ruler-line-aware: line/column-aware reading ruler internals
- foliate-touch-listener-capture-phase: capture-phase gesture suppression
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): pad sidebar and notebook for the device status bar (#4089)
Top-anchored slide-in panels (sidebar, notebook) only applied status-bar
top padding when isFullHeightInMobile was true. On a tablet/desktop
(isMobile === false) that gate collapsed the padding to 0, so a visible
system status bar overlapped the panel's top toolbar and made its icons
inaccessible.
Extract the inset math into getPanelTopInset() and gate it on
(!isMobile || isFullHeightInMobile) so non-mobile panels clear the status
bar like the reader header, while a partial-height mobile bottom sheet
(which doesn't reach the top of the screen) stays flush.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): keep footer bar clear of the pinned sidebar
On a mobile tablet in portrait, forceMobileLayout renders the footer bar
with position: fixed, anchored to the viewport, so left-0 w-full spans
the whole window and slides under a pinned sidebar — the progress / font
/ TTS controls end up obscured.
Anchor the footer inside the book's grid cell (position: absolute) when
the sidebar is pinned, mirroring the header bar. The flex layout already
offsets the grid cell by the sidebar's real rendered width, which honors
the sidebar's min-w-60 floor and 45% cap that a stored-width offset would
miss. The slide-up panels are absolute within the footer container, so
they shift and narrow with it and their animation is unchanged. The
switch only happens when the sidebar is pinned, so phone (< 640px) and
unpinned tablet-portrait class names stay identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): refine reader side panels and their empty states
Closes#4089
Add a shared EmptyState component (large muted icon, title, and an
optional hint or action) and use it for the empty annotations, bookmarks,
and notes panels in the sidebar and notebook, replacing the ad-hoc
"No … yet" placeholders.
Polish the surrounding chrome: switch the bookmark toggler to the Ri icon
set with responsive sizing, crop the HighlighterIcon viewBox to its
artwork to remove the asymmetric bottom padding, and tune mobile sizing
and spacing across the panel headers, tab navigation, and footer nav bar.
Translate the new empty-state strings (No Notes, No Annotations, No
Bookmarks, and their hints/action) across all 33 locales and drop the
obsolete "No … yet" keys.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Manage Cache" item to the library Advanced Settings menu (native
mobile apps only) that opens a modern dialog showing the combined size and
file count of the app's reclaimable storage, with a confirm-gated clear that
reports per-file progress.
- iOS clears Cache + Temp + Documents/Inbox; Android clears Cache + Temp.
- Multi-source helper (getCacheEntries/getCacheStats/clearCacheEntries) with
unit tests; per-file failures are counted, never abort the run.
- Dialog uses the centered-hero + btn-contrast design language, theme-neutral
progress, and is e-ink correct.
- i18n: new strings translated across all locales (+ en plural forms).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Snap the reading ruler to real rendered text lines instead of stepping by a
fixed arithmetic height, so the band always frames whole lines.
- Snap to actual line geometry from the relocate range; the band is sized
dynamically to the text block plus symmetric padding (round(fontSize *
lineHeight * 0.3)), capped at (lines + 1) line heights so a tall image inside
a block can't expand it to cover the whole figure.
- Drop block/container rects (Range.getClientRects aggregates multi-line element
borders) so paragraphs aren't merged into one giant line and skipped.
- Column-aware in multi-column layouts: the band spans one column at a time and
advances column by column.
- Confine the band to lines at least half visible within the viewport.
- Scrolled mode: snap to lines, and at a view edge scroll the view and realign
the band to the start/end of the new view (works for vertical-rl too, which
scrolls horizontally); paging snaps the view edge between lines so text isn't
cut or repeated.
- Vertical writing mode: correct band centering and drag direction; Up/Down keys
move the ruler while Left/Right turn pages (taps always move the ruler).
- Page turns keep the first/last line: forward lands on the first line of the new
page, backward on the last line; the relayout re-snap anchors on the band's
leading edge so it never skips a line.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: design spec for gesture-based brightness control (#3021)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: revise brightness-gesture spec per /autoplan review (#3021)
CEO+Design+Eng dual-voice review. Key fixes: capture-phase listener
(bubble-phase could not suppress foliate paginator), opt-out toggle,
18px threshold, selection guard, brightness seed race, rAF teardown,
e-ink stepped overlay, contrast capsule, perceptual curve reuse,
listener-level test harness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): swipe-to-adjust brightness gesture on mobile (#3021)
Left-edge vertical swipe adjusts screen brightness on iOS/Android, with a
Sun-icon progress overlay. Capture-phase non-passive listener suppresses the
foliate paginator / page-flip / UI-toggle handlers; selection guard, strip
reservation in scrolled mode, eager brightness seed, rAF throttle + teardown.
Opt-out toggle in Settings > Behavior > Device (default on). Perceptual curve
shared with the menu slider. Pure-helper + listener-level tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): detect brightness-swipe edge by screenX; i18n + shorter label (#3021)
On-device fix: paginated mode lays the iframe doc out as wide side-by-side
columns, so clientX/documentElement.clientWidth are document coordinates and a
left-edge touch on a later page never fell inside the strip (armed stayed false).
Detect with screenX against the parent window width, matching usePagination.
Also: translate the two new setting strings across all locales and shorten the
toggle description to 'Slide along the left edge'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rsvp-controller.test.ts calls controller.start() in ~20 tests but never stops
the controller. start() schedules a countdown (setInterval, 500ms x3) that then
schedules the recurring word-advance (setTimeout). On the real clock those fire
~1.5s later — after the test file's jsdom env is torn down — and
emitStateChange's dispatchEvent(new CustomEvent(...)) runs against a stale realm
and throws. Vitest reports it as an unhandled error and fails the whole run
intermittently on CI:
TypeError: Failed to execute 'dispatchEvent' on 'EventTarget': parameter 1 is
not of type 'Event'.
at RSVPController.advanceToNextWord -> emitStateChange (Timeout._onTimeout)
Fake only the timer functions (setTimeout/setInterval + their clears) for this
suite via vi.useFakeTimers({ toFake: [...] }); useRealTimers in afterEach
discards any still-pending fakes. The tests assert synchronously and never
advance playback, so faking the timers changes no assertion; Date/performance
stay real so CFI/position checks are unaffected. Test-only; production behaviour
is unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switching the annotation/bookmark sidebar to a flat virtualized list eliminates
the per-item layout reads that caused multi-second jank when toggling tabs on
books with hundreds of notes.
A. Virtualize the list with react-virtuoso
- Flatten group headers + notes into a single FlatBooknoteRow array.
- Embed an OverlayScrollbars instance inside the tab so scrollbar styling
is preserved while Virtuoso owns the viewport (same nested pattern as
TOCView).
- Track the parent scroll-container's height with ResizeObserver to give
Virtuoso a bounded viewport.
- Replace the per-item useScrollToItem (which called getBoundingClientRect
and closest() on every BooknoteItem on every progress tick — O(n) sync
reflow on 1000+ items) with a single virtuosoRef.scrollToIndex driven by
nearestCfi.
B. Stabilize derivations with useMemo / useCallback
- filteredNotes, sortedGroups, flatItems, nearestCfi all useMemo so an
unrelated config change (e.g. viewSettings autosave) no longer triggers
a full sort + group rebuild.
- handleBrowseBookNotes is now useCallback so BooknoteItem's React.memo
can hit on prop equality.
C. Memoize BooknoteItem
- Wrap the component in React.memo. With stable item / onClick references
from the parent, re-renders triggered by sibling progress updates no
longer cascade across every visible row.
- Cache marked.parse(item.note) and dayjs(item.createdAt).fromNow() in
useMemo. marked is the dominant per-render cost for note rows.
- isCurrent moves to a useMemo over isCfiInLocation; the per-item
scrollIntoView is removed since BooknoteView now drives scrolling.
useScrollToItem is intentionally left intact — SearchResults still uses it
and its smaller list does not exhibit the same jank.
The two #4112 scrolled-mode preload browser tests asserted that the
previous-previous section (F-1) was not loaded after navigating to a section,
checking it after `waitForFillComplete()`.
The eager backward buffer (minPages) is suppressed while `#display` is
stabilizing, but pulls F-1 (and beyond) in on the first scroll events once the
fill settles. So the post-fill assertion raced the buffer: it passed locally but
failed intermittently on CI (loaded set [1,2,3,4,5]).
Assert the precondition the instant `#display` stabilizes — where the buffer is
provably suppressed and only the immediate-previous section is loaded — before
the fill settles. The eager buffer loading F-1 later is exactly what the second
test wants once it navigates back. Test-only; production behaviour is unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a hard refresh the TOC sidebar occasionally (~1 in 10) scrolled to and
highlighted the current chapter, then rewound to the very top of the list.
It is a scroll-position race in TOCView, not a progress/sectionHref reset (the
reading position stays correct throughout). OverlayScrollbars resets the wrapped
Virtuoso viewport's scrollTop to 0 when it initializes (deferred). Its
`initialized` callback re-scrolled only to `initialScrollTarget.index`, captured
at mount — and on a fresh refresh `progress` is not available yet, so that index
is 0 and the reset is never corrected. Whether OverlayScrollbars initializes
before or after the auto-scroll to the reading position is the timing race that
made it intermittent.
Re-apply the scroll to the current active item (via refs mirroring the live
flatItems/activeHref) in the `initialized` callback, falling back to the
mount-time index. Refs are used because OverlayScrollbars binds the callback at
mount and fires it later, so it must read the latest active item.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract and translate the new strings introduced by the Moon+ Reader
annotation import flow (#4350, #4174) and OPDS facet/catalog navigation
(#4348): import dialogs, link-type labels, catalog actions, empty states,
and the count-pluralized "Failed to import {{count}} books" title.
- 21 singular keys + plural forms translated across all 32 non-en locales
- en/translation.json gains the hand-authored _one/_other plural variants
for "Failed to import {{count}} books"
- plural forms follow each locale's CLDR categories (ar 6, ru/pl/uk/sl 4,
romance 3, etc.); placeholders, "Moon+ Reader" brand, and ".mrexpt" preserved
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(opds): add facet navigation and quick catalog registration to header
- Add an options dropdown in the header to navigate OPDS feed facets on compact viewports. - Implement an "Add to My Catalogs" dialog to save the current feed, inheriting credentials, headers, and config. - Render a standalone shortcut button in the header when no facets are present, hiding the dropdown.
* fix(opds): scope window rounding to full route + guard duplicate add
Reviewing the facet-navigation feature surfaced three issues in the
quick-add flow and an unrelated window-rounding change:
- Restore the standard full-screen-route rounding pattern on the OPDS
browser. The header change had dropped the `isRoundedWindow` guard
(rounding maximized/fullscreen windows leaves gaps at the edges) and
switched to left-only corners (a docked-sidebar pattern). Match the
library/auth/user/reader pages: `isRoundedWindow && window-border
rounded-window`.
- Guard "Add to My Catalogs" against re-adding a catalog whose URL is
already saved. `addCatalog` dedups by contentId and would silently
overwrite the existing entry while toasting "added successfully"; now
it detects the duplicate via `findByUrl` and shows an info toast.
- Replace `feed!.facets!` non-null assertions with optional chaining.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(export): make annotation export link type configurable
Add an Annotation Link selector (App / Web) to the Export Annotations
dialog. Defaults to the app deeplink in the native app and the universal
web link on the web, so web exports no longer emit readest:// links that
only the desktop/mobile app can open. The default markdown template now
uses the configurable annotation.link variable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(annotations): move Moon+ Reader import into a dedicated Import Annotations modal
Replace the single 'Import from Moon+ Reader' menu item with an 'Import
Annotations' entry (below 'Export Annotations') that opens a dedicated
modal listing import sources. Currently lists Moon+ Reader; the boxed-list
layout makes adding future providers a one-row 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>
Bump foliate-js: compensate the scroll position when a previous section is prepended above the viewport, so the browser's scroll-anchoring suppression at scrollTop 0 no longer drifts the view into chapter n-1; preload the previous section on backward navigation and eagerly while scrolling toward the top; and stop the blank-screen flash on adjacent navigation in continuous scrolled mode.
Split paginator-multiview.browser.test.ts into paginator-scrolled and paginator-paginated, adding regression tests for the drift, previous-section preloading, the eager backward buffer, and the flash-free transition.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a primary/secondary sort pair so users can group by author and have
each author's books drilled-in list sort by series, without touching the
sort menu each time. Closes#4307.
- New "Then by..." picker in the library view menu (None + same keys as
primary). Secondary acts as tiebreaker for the global sort, and as the
in-group ordering when the user drills into a non-series group.
- Smart defaults derived from groupBy, surfaced as "(Auto)" in the menu
and resolved at sort time so user picks are never overwritten:
- groupBy=Author + secondary=none -> Series
- groupBy=Series + librarySortByAuto -> primary becomes Series
- librarySortByAuto flips off as soon as the user makes any explicit
primary pick; subsequent groupBy changes then respect that choice.
Settings: librarySortBy2, librarySortByAuto. URL: ?sort2 syncs the
secondary; auto is settings-only (no URL representation).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Windows WebView2's native share UI, invoked via tauri-plugin-sharekit,
synchronously blocks the Tauri command thread waiting on
ShareCompleted/ShareCanceled callbacks. When the user dismisses the
picker without those callbacks firing, the app freezes and has to be
killed from Task Manager. Skip the native share path on Windows and
fall through to the save dialog, mirroring Linux.
* fix(library): suppress Android image callout on book covers
Long-pressing a cover on Android could trigger the WebView's native
image callout at the same time as the bookshelf's own 500ms long-press
handler for multi-select, causing apparent freezes. `-webkit-touch-
callout: none` doesn't inherit, so the existing `.no-context-menu`
rule on the item container never reached the cover `<img>`. Apply the
callout suppression to descendant images/anchors and disable native
drag on the cover.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(library): show modal for multi-file import failures
When a batch import yields more than one failure, the previous toast
crammed every filename onto a single line that often overflowed and
truncated. Add a dialog that lists each failed filename with its
error reason, dedupes the message into a header banner when every
file failed for the same reason, and falls back to the existing toast
for single-file failures.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(library): sort manage-group modal by most recent activity
The Group Books modal listed groups in store-insertion order, which made
recently-active groups hard to find in libraries with many groups. Sort
each level desc by the newest `updatedAt` across the group's books,
propagating up the path so a recently-touched book in
`Literature/Fiction` keeps `Literature` fresh too. Extract the index as
`buildGroupNameUpdatedAt` in libraryUtils for reuse and unit testing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(library): tighten select-mode action bar and header polish
- SelectModeActions: switch the narrow-viewport grid from 3 columns to
4 (with the delete action explicitly placed in column 2) so the icon
set stops wrapping awkwardly on phones below ~500px.
- LibraryHeader: keep the "Select All" / "Deselect" label on a single
line so it doesn't wrap and shove the underlying button taller.
- SetStatusAlert: drop the hover bg on the small-screen cancel button
and rely on text-color contrast so it stops flashing a tinted disc
on mobile taps.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat: add empty state hints and loading indicators for annotations, bookmarks, notes, font import, and Moon+ Reader import
- BooknoteView: show 'No annotation yet' / 'No bookmark yet' when empty
- Notebook: show 'No note yet' when no notes/excerpts exist
- Annotator: add loading overlay with spinner during Moon+ Reader import
- mrexpt: yield to event loop every 5 entries to keep spinner animating
- CustomFonts: show in-place loading card during font import, spinner transitions to font name without layout jump
* fix(ui): respect e-ink overlay styling and drop dead className branch
- Annotator: use modal-box on the mrexpt import overlay so eink picks up
the no-shadow + 1px border override automatically.
- CustomFonts: collapse importing-card clsx ternary whose branches were
identical into a flat className.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Android-only workaround that pinned the container scroll while text was
selected saved `renderer.start` (section-relative) and restored it as
`renderer.containerPosition` (absolute). On later sections those two
diverge — restoring the small `start` value as `containerPosition` snapped
the multi-view scroll back to the first rendered section whenever the OS
selection handle drag triggered a scroll. Save and restore the same
`containerPosition` value instead.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#4222.
Three changes that together stabilize Readest sync across devices:
**Stop the artificial updatedAt bump in useProgressAutoSave**
saveConfig unconditionally bumps config.updatedAt = Date.now(), and
useProgressAutoSave used to fire saveConfig on the very first relocate
after book open — even though that relocate just reflects the position
loaded from disk, not user action. The stale local config then looked
"newer" than a fresher server-side push, so the next auto-push
overwrote the other device's real progress via last-writer-wins. The
hook now snapshots the loaded location and skips saveConfig when the
in-memory location still matches it.
**Retry the first config pull with backoff + release the gate**
useProgressSync gates pushes behind a successful pull (so a brand-new
import can't clobber the server's real progress). But handleAutoSync
only re-arms on progress.location changes, so a single failed pull
(Android cold-start contention, Wi-Fi/LTE handoff, captive portal)
used to block every push for the whole reader session. The new
pullWithRetry retries on backoff (1500/4000/10000 ms) and releases
the gate after exhaustion — server-side last-writer-wins still
protects the cross-device case (a stale local push with an older
updated_at loses to a fresher server record). sync-book-progress
events reset the chain so manual pull-to-refresh recovers cleanly.
Fetch timeout bumped from 8s to 15s to better tolerate slow networks
in that cold-start window.
**Server piggybacks books.progress off configs push**
/api/sync POST now updates books.progress + books.updated_at for each
upserted config, gated by .lt('updated_at') so a concurrent newer
books push is never downgraded and a missing row is a silent no-op
(useBooksSync still seeds new rows from the library page). The
in-reader syncBooks round-trip is dropped — the reader now sends one
POST per auto-save instead of two, and the books row stays consistent
with config pushes even while a reader stays open (#4198).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes#4339. PostHog telemetry was previously enabled by default for every
install, which surprised privacy-conscious users on self-hosted setups.
Behavior for new users only (existing users are migrated to a decision that
preserves their current `telemetryEnabled` setting):
- 90% are silently opted out on first launch.
- 10% see a one-time consent prompt; accepting opts in, declining opts out.
Decision is persisted via a new `readest-telemetry-decision` localStorage key
so subsequent boots don't re-roll. PostHog now inits with
`opt_out_capturing_by_default` so brand-new users never ping before the
decision is finalized.
New `TelemetryConsentDialog` uses the project's `btn-contrast` (theme-neutral)
CTA and `eink-bordered` chassis so it renders correctly under `[data-eink]`
without color-mode-only assumptions. Strings translated across all 33 locales.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(dict): restore MDD eager init; CSP-safe Vocabulary.com audio handler
Two MDict fixes:
1. **Regression**: #4334 turned on `MDD.create(..., { lazy: true })` so
the audio / resource side of every MDict would skip the upfront
key-block decode + sort. That's the right trade-off on the MDX side
(millions of headwords, ~80 s init saving), but it doesn't transfer
to MDDs: js-mdict's lazy lookup binary-searches `keyInfoList`
envelopes with `comp` (localeCompare), and MDDs store keys in
byte-sorted order with `\` prefixes and case folding. The two orders
disagree just often enough that `mdd.locateBytes('sound.png')`
misses on resource paths that do exist — the icon's CSS
`background-image: url(sound.png)` then stays unresolved and the
speaker glyph disappears. Bisected to 93abca89 (#4334). Drop the
flag for MDDs; MDXs keep the lazy win.
2. **New**: a CSP-safe replacement for the `onclick="v0r.v(this,'KEY')"`
audio handler used by Vocabulary.com-derived MDicts. The dict's
`j.js` is never loaded inside our shadow root (we don't execute
MDX-supplied JavaScript), so without intervention the inline
handler would throw `ReferenceError: v0r is not defined` on every
click. The new helper parses the audio key from any matching
onclick, strips that one attribute, and binds our own listener that
probes the companion MDD for `<KEY>.mp3` / `<KEY>.m4a` / etc. and
plays the bytes through an `<Audio>` element. Other unrelated
inline handlers are deliberately left alone — they were no-ops
already (their helper JS isn't loaded), and CSS rules sometimes
match on `[onclick]` attribute presence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sync): skip binary upload when sync category is disabled
`publishReplicaUpsert` already gates the metadata row on
`isSyncCategoryEnabled(kind)` so a user who turned dictionary / font /
texture sync off in Manage Sync never publishes a row for that kind.
`queueReplicaBinaryUpload` had no such gate, so the binary side still
fired: a 250 MB MDict bundle or a few hundred MB of font files would
still upload to cloud storage with no replica row to reference them.
Mirror the gate here. The `kind` parameter (`dictionary`, `font`,
`texture`) lines up with `SyncCategory` names verbatim, so a single
`isSyncCategoryEnabled(kind)` check at the top of the function is all
it takes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue #4288: users with hardware page-turn buttons (e.g. e-ink readers)
want to disable swipe-to-paginate so accidental finger drags during
highlight selection don't flip the page mid-annotation. The existing
"Tap to Paginate" toggle only covers taps; swipe was always on.
- New `disableSwipe: boolean` on `BookLayout` (default `false`,
declared right after `disableClick`).
- foliate-js submodule bump: paginator gates `#onTouchMove` and
`#onTouchEnd`'s snap-to-page on a new `no-swipe` attribute, so
native touch behaviour (text selection) stays intact.
- `FoliateViewer` sets/removes the `no-swipe` attribute alongside
`animated`, and the `ControlPanel` toggle pushes the change to the
live renderer so it takes effect without a viewer reset.
- The fixed-layout swipe interceptor in `usePagination` also bails
when `disableSwipe` is on, covering both reflowable and fixed-
layout books.
- New "Swipe to Paginate" UI row directly below "Tap to Paginate";
both can be off simultaneously.
- i18n: 33 locales translated.
Also polish: rephrase the two helper texts under "Read books in
place" in `ImportFromFolderDialog`. The previous copy ("Copy no book
into the library to save space.") used an awkward double-negative;
the new wording is clearer and the locked variant drops "registered
as" / em-dash for a plain two-sentence form. i18n updated.
Closes#4288
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Make the dictionary import path usable on large bundles and bring the
multi-device flow up to par.
Import perf
- Skip `MDX.create()` at import time. The factory triggers full init —
decompresses every key block and sorts millions of keys with
localeCompare just to expose the header. Replace with a tiny
`readMdxHeader()` that only reads the small XML header for Title /
Encoding / Encrypted (saves ~17 s on a 250 MB MDX on web).
- `partialMD5`: read the 9 sample slices in parallel rather than
sequentially. Each freshly-picked-File slice round-trip on Chrome
costs ~100 ms cold; parallelisation collapses 9 of them into one.
- Native fast-path in `nativeAppService.writeFile`: when the source is
a `NativeFile`, delegate to Tauri's `copyFile` rather than streaming
the file through `NativeFile.stream()`. Streaming a 250 MB body
through 1 MB IPC chunks on Android took ~100 s; native copy is bound
by disk throughput instead. Exposes `NativeFile.getNativeLocation()`
for the FS layer to use the underlying path + baseDir directly.
Lazy lookup
- Pass `lazy: true` to `MDX.create` / `MDD.create`. The js-mdict change
in this PR skips the upfront decompress-every-block + sort during
init (~80 s on the same 250 MB bundle) and decodes only the relevant
key block on demand per lookup. First-lookup main-thread block
drops from ~81 s to ~230 ms. (Closes #4228.)
Raw .dict
- Drop the import-time gate that flagged non-gzip dict bodies as
`unsupported`. The runtime body loader (`loadDictBody`) already
probes the gzip header and falls through to a passthrough buffer
for raw files, so the gate was the only thing preventing raw
`.dict` bundles from importing on devices that received them via
cloud sync. (Closes#4179, partially addresses #4248.)
Import-flow UX
- `handleImport` now always surfaces a toast for every non-cancelled
attempt: picker errors, missing app service, no-op imports, and
unsupported-but-imported bundles each get their own message instead
of failing silently.
- Call `markAvailableByContentId(newDict.contentId)` after
add/replace so the "Bundle is missing on this device" warning
clears immediately — no need to close-and-reopen the panel.
System Dictionary
- Drop the cascading toggle behavior in `setEnabled`. Each provider's
enabled flag persists independently; exclusivity is enforced at
lookup time. Toggling System on/off no longer wipes the user's
preferred set of in-app providers.
- Render non-system rows as read-only when System is on (toggle still
shows what's queued to restore; tooltip explains the lock).
- `isSystemDictionaryEnabled` short-circuits to `false` on platforms
where the handoff isn't implemented. `providerEnabled` is whole-
field synced across devices, so a flag set on macOS would otherwise
leak to a Windows device with no way to look up a word.
js-mdict
- Submodule bump to e6dbc99 which adds the opt-in `lazy: true`
`MDictOptions` flag (skip `_readKeyBlocks` + post-init sort; new
`lookupKeyBlockByWordLazy` path on `MDX` and `MDD`). Eager mode is
unchanged and every existing js-mdict test still passes.
i18n
- 208 new translations across 33 locales for the new UX strings.
Closes#4228Closes#4248Closes#4179
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Tauri build uses `output: 'export'`, so the dynamic `/runtime-config.js`
route handler is never emitted. Requesting it returns the SPA fallback HTML
and crashes with `Unexpected token '<'`. Gate the script tag on
`NEXT_PUBLIC_APP_PLATFORM === 'web'`; Tauri consumers already fall back to
the `NEXT_PUBLIC_*` envs baked in at build time.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(library): make bookitem-main shrink to match cover in fit mode
Closes#4234. In fit mode the bookitem-main kept its 28/41 aspect
regardless of the cover image's natural aspect, leaving extra padding
beside (portrait covers) or above (landscape covers) the cover. The
select-mode overlay and icons drifted away from the cover edge.
BookCover now reports the loaded image's natural aspect ratio. BookItem
overrides the bookitem-main's aspect-ratio with the cover's aspect so
the box hugs the cover exactly, and proportionally shrinks book-item
width for portrait covers so the info row icons align with the cover's
right edge.
Also wrap the TTS "Back to TTS Location" pill with whitespace-nowrap so
long translations (e.g. German "Zurück zur TTS-Position") expand the
button width instead of overflowing the fixed height.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(library): scope cover shrink to bookitem-main, leave info row at cell width
Per review, the width shrink should only apply to the cover row so the
title and info icons keep their original cell-wide layout. Move the
width style from .book-item to .bookitem-main alongside its aspectRatio
override.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Long translations (e.g. German "Gruppieren", "Löschen") pushed the 6-button
action bar past the right edge on typical phones since the grid fallback
only triggered below 350px. Switch to a 3x2 grid below sm: and clamp the
container to the viewport width.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#3914. Switch the page-fullscreen image to object-fit: contain
(and SVG preserveAspectRatio meet) so cover images fit the page and
center without cropping. Also adds a duokan-image-gallery-cell layout
helper and drops the mobile marginBottomPx override.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prevents 'Please log in to continue' error when importing fonts or images
locally without logging in. The replica upload is now gated on
getAccessToken() so unauthenticated users are never enqueued.
Non-PDF fixed-layout EPUBs visually scale the iframe via CSS transform
while keeping native dimensions inside, so getClientRects() returns
unscaled positions. The SVG overlayer was sized in CSS pixels with no
viewBox, leaving annotations and TTS highlights drawn at scale-1
displacement from the actual text.
Bumps foliate-js to set a matching viewBox on the overlayer SVG, and
adds a regression test covering spread/scroll/PDF/empty frames.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Moon+ Reader import flow asks for .mrexpt files via the 'generic'
preset. On Android, Tauri routes that to the Storage Access Framework
picker, which filters by MIME type. Extensions without a registered MIME
(such as .mrexpt) end up greyed-out and unselectable.
Books and dictionaries already bypass this by using an unfiltered picker
and re-applying the extension whitelist client-side. Extend the same
treatment to the 'generic' preset so callers can request arbitrary
extensions reliably on Android. iOS already had no SAF restriction.
* feat(library): in-place import with cloud sync and symmetric local delete
Adds an `inPlace` option to importBook so a source file inside a
registered external library folder is referenced directly via
`book.filePath` instead of being copied into Books/<hash>/. Sidecars
(cover, config, nav) still live under Books/<hash>/.
ingestService routes through shouldImportInPlace, which marks an
import in-place when the absolute source path lives under any of
`settings.externalLibraryFolders` and is NOT inside a per-root
`Books/` subtree. The Readest data dir (`customRootDir`) is
intentionally excluded — that directory is Readest's home and
should freely hold hash copies; in-place is for user-registered
roots (Duokan, Calibre, Moon+ Reader, an iCloud mirror, …).
Cloud sync treats in-place books as first-class:
- uploadBook reads bytes from (book.filePath, 'None') when set.
The cloud key is unchanged, so a peer downloading the book
lands it under Books/<hash>/ as a normal hash copy.
- useBooksSync strips `book.filePath` before pushing — it is a
device-local path that is meaningless on any other device.
- ingestService no longer skips upload for in-place books;
autoUpload / forceUpload behave like any other book. Only
transient imports opt out.
- deleteBook 'local'/'both' now physically removes the source
file at book.filePath (base 'None'). Local-delete semantics
are symmetric with hash-copy books: the local copy is gone,
the cloud backup remains, a future pull restores under
Books/<hash>/. removeFile errors are swallowed.
New `SystemSettings.externalLibraryFolders?: string[]` (no UI yet;
registration entrypoint lands in a follow-up). Added to
BACKUP_SETTINGS_BLACKLIST alongside `localBooksDir` /
`customRootDir` so device-local paths don't ride cloud backups.
Tests: cloud-service, ingest-service, and backup-settings suites
cover in-place delete, multi-root matching, per-root `Books/`
guard, and the backup-strip.
* feat(library): one-tap "read in place" toggle in folder import
Surface the in-place / copy choice as a single "Read books in place (don't copy)" checkbox in the Import-from-Folder dialog. When the user opts in, the chosen directory is registered in `settings.externalLibraryFolders` and ingestService's `shouldImportInPlace` will route the books straight to importBook with `inPlace: true` — no copy into Books/<hash>/, sync still works, local delete still removes the source file (the symmetry was set up in the previous in-place commit).
User experience:
- First-time users hit the toggle once per library folder. The choice is also persisted to localStorage so subsequent dialog opens default to whatever they picked last.
- Repeat imports from a folder that's already registered as an external library folder force the toggle ON and disable it, with a help line explaining that imports from this folder are always in-place. The check is exact-string (after path normalization) so registering /Users/me/Duokan only locks the toggle for that exact path — picking /Users/me/Downloads after Duokan still shows the toggle in its normal state.
- URL-ingress / drag-drop replays go through `runFolderImport` without the dialog and default `readInPlace: false`. They still benefit from in-place automatically when the dropped path lives under an already-registered root, because that decision is made by `shouldImportInPlace` based on settings, not by the dialog flag.
Mechanics:
- ImportFromFolderResult gains `readInPlace: boolean`. ImportFromFolderDialog gains an `initialReadInPlace` prop (seeded from the new `readest:lastImportFolderReadInPlace` localStorage key) and an `isRegisteredExternalRoot` predicate it uses to render the locked / unlocked toggle.
- runFolderImport calls a new `registerExternalLibraryFolder` helper that appends the chosen directory to `settings.externalLibraryFolders` and persists settings, but only when `result.readInPlace` is true. `isRegisteredExternalRoot` does the inverse lookup the dialog needs. Both helpers normalize paths the same way `shouldImportInPlace` does so the predicate matches the ingest layer.
- The new feature has no effect for users who never flip the toggle: `externalLibraryFolders` stays empty, the path-prefix check in `shouldImportInPlace` returns false for every import, and books continue to be copied into Books/<hash>/ exactly as before.
Self-healing for externally-removed in-place books:
Once the dialog lets users opt their library into in-place mode, the source file becomes a piece of state Readest doesn't control — another app may rewrite it (e.g. Duokan persisting reading progress into the epub), the user may move it in Finder, or an external drive may unmount between sessions. Previously, clicking such a book would navigate into the reader, fail inside loadBookContent's `fs.openFile(book.filePath, 'None')` with a low-level IO error, flash an "Unable to open book" toast, and auto-bounce back to the library — leaving the stale library record in place so the next tap reproduces the same dance.
BookshelfItem.handleBookClick now probes availability before navigating, but only for purely-local in-place books (`book.filePath && !book.uploadedAt && !book.deletedAt`). If `appService.isBookAvailable` returns false — which for in-place books means the recorded `book.filePath` no longer exists at the OS level — we dispatch `delete-books` for that hash and show an info toast explaining the removal, instead of opening the reader.
Scope is intentionally narrow:
- Cloud-synced books still flow through `makeBookAvailable`'s on-demand download path; missing local copies trigger a re-download, not a deletion.
- Hash-copy books (no `filePath` set) are not probed: a missing Books/<hash>/ file under normal use signals a bug or filesystem corruption, not user intent, and silently dropping the record would hide the real problem.
- The dispatched delete-books event reuses the existing Bookshelf deletion path, so sidecar metadata and selection state are cleaned up the same way as a user-initiated delete. For in-place books that path doesn't touch any file outside Books/<hash>/, so the now-missing source location (or whatever the user did with it externally) is left alone — symmetric with 165f15a6.
* fix(library): centralize book content resolution
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Some EPUBs (e.g. downloaded from Baidu Netdisk) have their first few bytes
corrupted while the rest of the archive is still well-formed. The zip spec
locates archives via the End-of-Central-Directory record at the file tail,
so a corrupted local file header signature does not actually invalidate the
file. When the leading magic check fails, fall back to scanning the last
~64 KiB for the EOCD signature (PK\x05\x06); if present, treat the file
as a zip and let zip.js read it normally.
* feat(import): support folder picker on iOS via native-bridge
Tauri's dialog plugin rejects folder picks on both mobile platforms with FolderPickerNotImplemented, so previously only Android could pick an import directory (it already routed through the native-bridge plugin's ACTION_OPEN_DOCUMENT_TREE). iOS users had no working folder-import entry point at all.
Add an iOS implementation of the native-bridge select_directory command using UIDocumentPickerViewController(forOpeningContentTypes: [.folder], asCopy: false), with a dedicated FolderPickerDelegate that:
- holds a strong reference until the picker dismisses (UIKit keeps the delegate weak), and
- calls startAccessingSecurityScopedResource on the picked URL and retains it for the app's lifetime so plain Foundation/POSIX reads against url.path work for the rest of the session.
Route NativeAppService.selectDirectory through the bridge for both iOS and Android, then call allowPathsInScopes so the picked directory is reachable via fs_scope and the asset protocol. The library page's pickImportDirectory entry point now also takes the mobile branch on iOS, while keeping the Android-only MANAGE_EXTERNAL_STORAGE prompt gated behind isAndroidApp.
* feat(ios): persist security-scoped bookmarks for picked folders
iOS hands the folder picker back a security-scoped URL whose access
right is granted only to the running process. The previous
implementation kept the URL alive for the lifetime of the process via a
static `urlsToKeepAlive` array, which worked for the current session
but forced the user to re-pick the same folder after every relaunch.
Add a `FolderBookmarkStore` that:
- Right after the picker returns, calls
`URL.bookmarkData(.minimalBookmark)` and stashes the bytes in
`UserDefaults` keyed by the POSIX path.
- On every `NativeBridgePlugin.load(webview:)`, walks every persisted
bookmark, resolves it back into a URL, and calls
`startAccessingSecurityScopedResource`. Holds the URL alive in a
process-scoped dictionary so subsequent Foundation / POSIX reads
against `url.path` succeed.
- Handles `isStale` by re-encoding the bookmark against the resolved
URL, and drops permanently unresolvable bookmarks (folder gone,
provider uninstalled) from `UserDefaults` so the next launch
doesn't re-attempt them.
Pair this with a Tauri-side change so the same paths are reachable
through both `dir_scanner::read_dir` and the fs plugin's `readDir`:
- `allow_paths_in_scopes` now has an iOS branch that widens
`fs_scope` / `asset_protocol_scope` for any path the frontend hands
it, intentionally without the desktop-side "must already be in
fs_scope" gate. The OS sandbox + bookmark store is the real
access-control boundary on iOS; widening Tauri's in-memory scope
set cannot escalate access beyond what the OS already grants. The
security comment on the command was rewritten to spell this
contract out.
- `allow_file_in_scopes` is now compiled for iOS too (previously
desktop-only) so the file-grant path is available when needed.
* fix(opds): show 'Open & Read' for publications already in the library
PublicationView only flipped the acquisition button to 'Open & Read'
when the user downloaded the book within the current component
lifecycle — the downloadedBook state started as null on every mount
and was never reconciled against the actual library. So reopening the
detail page (after navigating away in the OPDS browser, or coming back
from the reader) always showed 'Download' / 'Open Access' again and
asked the user to re-download a book they already had.
Two real-world feeds (m.gutenberg.org and ManyBooks) exposed two
distinct failure modes that made naive title/author equality unusable:
(1) Gutenberg's OPDS entries never carry <dc:identifier>. The only work
identifier lives in <atom:id>urn:gutenberg:1342:2</atom:id>, which
foliate-js's opds.js parses into metadata.id — a field
getMetadataHashInfo never reads. So the identifier candidate list
was empty and identifier-overlap matching silently skipped every
book.
(2) Author strings disagree across the boundary: the feed emits
'<author><name>Austen, Jane</name>' (Lastname-first) while the EPUB
inside the same feed ships <dc:creator>Jane Austen</dc:creator>.
Plain string equality (even after normalization) never matched.
Add findExistingBookForPublication in app/opds/utils/findExistingBook.ts
with a layered matcher:
- Pass 1: full metaHash equality (the strongest signal — same fingerprint
the bookService uses internally).
- Pass 2: identifier overlap. collectPublicationIdentifiers() splices
metadata.id into the candidate list alongside metadata.identifier, so
Gutenberg's 'urn:gutenberg:1342:2' feeds into identifierKeys(), which
extracts the '1342' digit-tail (>=3 digits, so the trailing ':2'
version suffix is ignored) and matches the EPUB's
'http://www.gutenberg.org/1342' → '1342'.
- Pass 3: tolerant title + author match. hasAuthorOverlap() does a
token-set fallback: each name is split on whitespace/comma/semicolon,
single-letter tokens (initials) and year-range tokens ('1775-1817')
are dropped, and we require >=2 shared tokens by default. So
'Austen, Jane' ↔ 'Jane Austen' match via {austen, jane} but
'Author A' ↔ 'Author B' don't (both collapse to {author} after
dropping single-letter tokens — we track raw token count to refuse
the single-token shortcut when discriminative parts were filtered).
Genuine mononyms still match on a single shared token because raw
count is 1 on both sides.
OPDSPerson[] is fed through the library's own formatAuthors so the
produced author string matches Book.author byte-for-byte (Intl.ListFormat
output, locale-aware). Soft-deleted books are skipped so removing a copy
locally reverts the button.
Wire it in opds/page.tsx by subscribing to useLibraryStore.library
(rather than the snapshot reads inside handleDownload) and memoizing
existingBookForPublication. Pass it to PublicationView, which now
seeds downloadedBook from the prop and resyncs when the prop changes
(switching publications inside the browser) — but does not clobber a
download success it observed locally before the parent recomputed.
Covered by unit tests for the helper, including the real Gutenberg
URN-in-atom-id case, ':version' suffix on the URN, 'Lastname, Firstname'
vs 'Firstname Lastname', year-range stripping, and the 'Author A' vs
'Author B' non-match.
* fix(opds): dedupe downloads by source url
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
* feat(reedy): Appendix A · Phase 2.1 — ChatModel + model registry
First piece of the deferred agent runtime, building alongside the shipped
Phase 1 MVP per the locked decision (no rip-and-replace; legacy + Reedy
MVP + agent paths coexist).
ChatModel is a thin interface over Vercel SDK's LanguageModel that
surfaces contextWindow / reservedOutput / supportsTools — the metadata
the M2.5 PromptContextBuilder and M2.6 AgentRuntime need for prompt
budgeting and tool-calling decisions.
createReedyModels(settings) returns the active (chat, embedding) pair from
the user's currently configured AIProvider, rewrapped in the Reedy
interfaces without touching the legacy AIProvider. CONTEXT_WINDOW_TABLE
hardcodes per-model metadata (Gemini 2.5 → 2M, GPT-5/Llama-4 → 128K,
local Ollama llama → 4K with tools off, etc.) with a conservative 8K
fallback for unknown ids.
No runtime wired yet — Phase 2.6 consumes this.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reedy): Appendix A · Phases 2.2–2.3 — runtime event/error taxonomy + ToolRegistry
Phase 2.2 — pure types and factories for everything AgentRuntime.runTurn()
will yield:
turn_start | text_delta | tool_call | tool_result(ok/err)
| citation | memory_write | step_finish | usage | error | abort | done
ReedyError covers turn-level failures (context_overflow, turn_timeout,
model_error, provider_unavailable, stream_parse, abort, ...) with default
retryability per kind so the M2.6 streamLoop's retry-with-backoff can
decide without string-matching. ReedyToolError covers tool-execution
failures with a narrower kind union so the streamLoop can re-emit them as
{ tool_result, ok: false, error } events without losing structure.
Phase 2.3 — ToolRegistry that holds the runtime's tool catalog and adapts
each tool to the Vercel ai-SDK ToolSet streamText consumes. Every
registered ReedyTool gets wrapped with:
- Zod input validation → tool_invalid_args on mismatch
- Permission gate → tool_permission_denied (read auto-approves)
- Per-call wall-clock → tool_timeout (default 10s)
- AbortSignal propagation → tool_aborted on turn cancel
- Per-tool serialization → parallelSafe=false tools queue
The local AbortController per call composes the turn-level signal with
the per-tool timeout so we classify timeout-vs-abort correctly based on
which controller fired first, and listener cleanup avoids leaks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reedy): wire RetrievalBackend interface + metrics into the chat adapter
Phase 1B backend integration. Adds a RetrievalBackend interface so
TauriChatAdapter holds a uniform reference instead of branching across the
file, with two impls — LegacyIdbBackend (wraps the existing IDB ragService
unchanged) and ReedyBackend (lazy-opens reedy.db, adapts the active
provider's embedding model to Reedy's narrower shape, exposes a Vercel
`lookupPassage` tool). selectBackend() gates Reedy behind both
aiSettings.reedy.enabled AND isTauriAppPlatform() per plan D15 so the MVP
cohort is desktop-only.
The Reedy path streams via streamText({ tools: { lookupPassage }, stopWhen:
stepCountIs(3) }) with a status-aware system prompt that tells the model
how to phrase responses for each RetrieverStatus value.
Replaces the module-global `lastSources` + 500ms poll with a per-instance
ReedySourceStore keyed by a synthetic per-turn id the adapter generates,
so the Sources dropdown stops racing on global state. Both legacy and
Reedy backends now feed citations through the same store; the UI is
backend-agnostic via a shared SourceItem shape both ScoredChunk and
RetrievedChunk satisfy.
Adds the reedy_metrics table to the reedy migration (versioned with
app_version + session_id + turn_id per row) plus a ReedyMetrics writer
that ReedyBackend uses to record indexing-lifecycle and tool-use events.
Always-on local; no network egress. NoopReedyMetrics keeps construction
cheap before the DB opens.
Tests: retrievalBackend selectBackend gates, ReedySourceStore semantics
(append/replace/subscribe/clear), ReedyMetrics debounced batching +
exportBundle, and a TauriChatAdapter contract test that asserts the
Reedy/legacy code-paths pass the right args to streamText.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reedy): UI wiring — settings toggle, clickable sources, feedback bundle
Phase 1B UI integration. AIAssistant now constructs the active backend
(legacy or Reedy) via selectBackend with the platform gate from M1.7,
wires a ReedySourceStore for the chat adapter, and routes Sources-dropdown
clicks to `getView(bookKey)?.goTo(source.cfi)` when the source has a CFI.
Legacy-path sources still render as static rows because they have no CFI.
AIPanel grows a 'Reedy Retrieval (Beta)' BoxedList with the toggle and a
'Send Reedy feedback' button that calls exportReedyMetricsBundle and
triggers a JSON download of the last 90 days of events. The toggle is
disabled on web with an explanatory description per plan D15.
Thread accepts an onSourceClick callback and renders each source as a
button when its source carries a CFI, otherwise as a static div — so the
Sources dropdown is backend-agnostic via the shared SourceItem shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(db): add reedy schema migration with Tantivy FTS + lazy embeddings
Registers a new `reedy` migration set bound to reedy.db. Creates
reedy_book_meta + reedy_book_chunks with a Tantivy FTS index on
chunks.text (ngram tokenizer) and a per-book position index used by
BookRetriever. The vector embeddings table is intentionally NOT created
here — the indexer creates it lazily on first index so the vector32(<dim>)
column matches the active embedding model. Tests cover the migration
applies cleanly, is idempotent, and that the FTS index is queryable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reedy): retrieval primitives — DB, chunker, indexer, retriever, lookupPassage tool
Wires the MVP retrieval pipeline behind reedy.db, all under src/services/reedy/
and with no integration into the existing AI module yet (Phase 1B will do that).
- ReedyDb wrapper over DatabaseService: book-meta CRUD, lazy embeddings
table at the active model's dim, bulk chunk + embedding writes via batch(),
hybridSearch (brute-force cosine + Tantivy FTS + reciprocal-rank fusion
with 3× per-path over-fetch), per-book and global wipe. Internal write
queue serializes batch() calls so Turso's single-writer transaction guard
doesn't trip when BookIndexer runs across books in parallel.
- CfiChunker: TreeWalker over the section's DOM, ~maxChunkSize windows with
paragraph > sentence > word break-points, full epubcfi(/6/N!/…) anchors,
round-trip verified via CFI.toRange before each chunk lands.
- BookIndexer: per-book mutex, lazy embeddings-table creation, model.batchSize
embedding batches with dim assertion, terminal status transitions
(indexed | empty_index | failed). Re-indexing clears prior chunks via a new
ReedyDb.clearBookChunks helper.
- BookRetriever: status-typed results (ok | not_indexed | empty_index |
stale_index | degraded). Embedding has a 5s wall-clock budget; on timeout
it falls through to FTS-only with status=degraded.
- lookupPassage Vercel ai-SDK tool: Zod-validated query/topK, per-turn
composite-key dedupe, parallel-call serialization, 10s per-turn budget,
6000-char result clamp, status-with-hint passthrough, and a separate
serializeForModel that wraps each passage in <retrieved trust="untrusted">
with XML-escaped content so book text cannot escape the envelope.
59 unit tests; pnpm test + pnpm lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(db): forward DatabaseOpts to tauri-plugin-turso
NativeDatabaseService.open ignored its opts parameter, dropping any
experimental feature flags (e.g. 'index_method' needed for FTS / vector
indexes) and any encryption config before they could reach the Tauri
plugin. Translate DatabaseOpts to the plugin's LoadOptions shape and
forward as the single argument Database.load accepts. Skip translation
when no relevant opts are set so the existing path-string call shape is
preserved for callers without experimental/encryption needs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(db): cover Turso vector primitives + add benchmark harness
Verifies the Turso functions Reedy retrieval depends on, with a
brute-force per-book kNN test that runs against every DatabaseService
backend (node, native, WASM):
SELECT vector_distance_cos(embedding, vector32(?)) AS d
FROM book_chunks
WHERE book_hash = ?
ORDER BY d ASC LIMIT k
This is the path Turso's own founder recommended in
tursodatabase/turso#3778 ("First, focus on efficient SIMD-accelerated
brute-force search") and what shipped at commit 1aba105df4f. Native
vector index modules don't exist in this engine: `libsql_vector_idx`,
`vector_top_k`, and `USING vector/hnsw/diskann/ivfflat` all parse-error
against @tursodatabase/database@0.6.0-pre.28 (libsql_vector_idx is a
libSQL/sqld fork feature; DiskANN was closed not-planned upstream in
#832). The test asserts cross-book isolation and nearest-first ordering
using only `WHERE book_hash = ?` and `ORDER BY` — no DDL, no identifier
interpolation, no index plumbing.
Also adds bench/ harness for manual perf checks:
pnpm bench [name] run benchmarks (refuses in CI)
pnpm bench --list list available benchmarks
pnpm bench --no-record skip results.jsonl append
pnpm bench --force override the CI guard
Uses Node 24's --experimental-strip-types so no tsx devDep is needed.
Appends one JSON line per run to bench/results.jsonl (gitignored, local
history; share by pasting tabular stdout into PRs/issues). Explicitly
NOT in CI — shared-tenant variance makes synthetic-benchmark regression
detection unreliable; production telemetry (reedy_metrics, plan §M1.9)
is the right tool for that.
First benchmark: vector-retrieval. Measured on M1 Pro:
400 chunks × 384 dim → 0.35 ms / query
400 chunks × 768 dim → 0.45 ms / query
2000 chunks × 768 dim → 2.23 ms / query
10000 chunks × 768 dim → 14.00 ms / query
400 chunks × 1536 dim → 0.70 ms / query
Per-chunk cost ~1.1 µs at 768 dim = ~1.4 ns/dim. NEON-class on
Apple Silicon, ~50× faster than scalar — confirms SIMD acceleration is
active in 0.6.0-pre.28. Per-query latency stays sub-ms at Reedy MVP
corpus sizes; the ceiling is ~10K chunks per book before phone-class
hardware notices.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* OpenRouter: new OpenAI-compatible provider with chat + embedding model
routing, health check via /models, and a fetchOpenRouterModels() helper
for the settings UI. API key, base URL and model fields are persisted in
AISettings, surfaced in AIPanel, indexed by commandRegistry, and added
to backupService's credential allow-list so the key round-trips through
encrypted backups.
* utils/httpFetch: introduce getAIFetch() as the single decision point for
outbound AI traffic. In Tauri it returns @tauri-apps/plugin-http's fetch
(Rust/reqwest transport, no renderer CORS preflight, no Android cleartext
block); on the web build it falls back to window.fetch. OllamaProvider
is migrated end-to-end — both ai-sdk-ollama streaming and the /api/tags
health probe — and the new OpenRouterProvider uses the same path, so any
future provider only has to call getAIFetch().
* Tests: unit tests for OpenRouter provider behavior (model selection,
availability, health check) and a backup-settings round-trip test
ensuring openrouterApiKey is treated as a credential field.
`getStorefrontRegionCode` rejects when `Storefront.current` is nil
(unsigned simulator, signed-out device, StoreKit not yet ready,
parental controls, etc.). The call sits in NativeAppService startup
before `prepareBooksDir`, so an unhandled rejection here aborts the
rest of init and surfaces as a top-level unhandledRejection in the
webview console.
Wrap the call in try/catch and treat failure as 'unknown region',
leaving `storefrontRegionCode` null so downstream region-gated
features degrade gracefully. Also guard against an empty resolve
shape with optional chaining on `res?.regionCode`.
* fix(library): cancel Import-from-Folder dialog on Android Back / Esc
The dialog was previously relying on <Dialog>'s built-in
`native-key-down` listener to handle Back / Escape, but
`useKeyDownActions` (used here for the Enter-to-confirm shortcut)
registers its own sync listener that returns `true` on every Back
keypress, consuming the event before <Dialog> ever sees it. As a
result Android Back and Escape were silently swallowed inside this
dialog.
Wire `onCancel` so the hook actually performs the cancel itself, and
guard it (like Enter) while a folder pick is in flight to avoid
canceling mid-pick.
* fix(settings): step back to parent panel on Android Back / Esc inside sub-pages
Several settings panels render an in-place sub-view based on local
state (FontPanel -> Custom Fonts, LangPanel -> Custom Dictionaries,
IntegrationsPanel -> KOSync / WebDAV / Readwise / Hardcover / OPDS /
Send-to-Readest). Pressing Android Back (or Escape) while one of
these sub-pages was open used to close the entire Settings dialog
because only <Dialog>'s own `native-key-down` listener handled the
event.
Mount a `useKeyDownActions` hook at each parent panel, gated on the
sub-page being open, that calls the existing "go back" handler and
consumes the event. Because `dispatchSync` walks listeners LIFO, the
panel-level hook (registered after <Dialog>'s) claims Back first
while a sub-page is open; once the sub-page is closed the hook is
disabled and Back falls through to <Dialog> as before, closing the
whole Settings dialog.
This keeps all logic in the three parent panels — no changes needed
to the seven sub-page components — and a single hook in
IntegrationsPanel covers all six integrations sub-pages.
The floating 'New Chat' button in the chat history sidebar suffered from
two issues on mobile:
1. On Android (e.g. Pixel 9 with the gesture pill) the button rendered
underneath the system navigation indicator because its position used
plain bottom-4 with no safe-area inset.
2. With bg-base-300 / text-base-content the pill could collapse to a
nearly invisible solid black shape under some themes / contexts where
text-base-content was inherited as a near-background color, hiding
the icon and label entirely.
Fixes:
- Offset the wrapper by env(safe-area-inset-bottom) + 1rem so the button
sits above the Android gesture pill and iOS home indicator.
- Switch the button to bg-primary / text-primary-content with shadow-md
to guarantee strong contrast across all themes.
- Add pointer-events-none on the positioning wrapper and pointer-events-auto
on the button itself so the floating layer never blocks list interaction.
Fixes#4254. On app boot, Providers called applyBackgroundTexture
immediately after loadSettings() resolved, but the customTextureStore
was still empty — loadCustomTextures only ran later when ColorPanel
mounted or useReplicaPull seeded it during library render. The hook's
addTexture fallback re-derives the texture id from name and creates a
new entry with a different id whenever the saved id wasn't computed
from the current name (legacy imports, cross-device sync), so
applyTexture silently bailed out and no texture was mounted.
Seed customTextureStore.setTextures(settings.customTextures) in
Providers right after loadSettings() resolves — preserving the saved
ids — so applyTexture can resolve them at boot. Only custom textures
were affected; predefined textures (concrete, paper, etc.) worked
already because the lookup falls back to PREDEFINED_TEXTURES.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(reader): restore right-column clicks and selection in dual-page mode
Bumps foliate-js to readest/foliate-js@1ea2996, which stops marking
visible non-primary views as `inert` / `aria-hidden`. Adds a regression
test for the underlying visibility helper.
When a dual-page spread crosses a section boundary, the right column
lives in a non-primary view. The previous a11y sync set both `inert`
and `aria-hidden` on that view — `inert` blocked link clicks and text
selection in the visible column, and `aria-hidden` hid it from
assistive tech while sighted users could still read it. The fix drops
`inert` entirely (screen-reader swipe-next is already handled by
`aria-hidden`) and applies `aria-hidden` only to views whose bounding
rect lies outside the visible container.
Fixes#4243 (right-column links unclickable in dual-page mode).
Fixes#4259 (text selection fails when an image section is on the left).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(reader): update paginator-multiview a11y assertions for new visibility-based aria-hidden
Browser tests previously assumed every non-primary view carried both
`inert` and `aria-hidden`. The fix in the preceding commit drops `inert`
entirely and only `aria-hidden`s views that are off-screen. Update the
two assertions to:
- check that no wrapper ever has `inert`;
- require `aria-hidden="true"` only when the wrapper's bounding rect is
fully outside the visible container, and require its absence when the
wrapper overlaps the viewport (the regression case from #4243 / #4259).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cbz,i18n): ComicInfo metadata + CBZ page count + WebDAV i18n
Closes#4253 (ComicInfo.xml not read) and #4255 (CBZ shows "1 page left").
CBZ / ComicInfo (foliate-js submodule + Readest derivation):
- comic-book.js: find ComicInfo.xml in subdirectories too, parse
description / subject / identifier / published / series fields
beyond the prior name+position pair. Series Count populates the
canonical `belongsTo.series.total`; no top-level duplication.
- bookService.ts / readerStore.ts: derive `metadata.seriesTotal`
from `belongsTo.series.total` in parallel to the existing
series / seriesIndex derivation.
- ProgressBar / FooterBar / DesktopFooterBar: drop the hard-coded
`pagesLeft = 1` for fixed-layout books and compute it from
`section.total - section.current`. FooterBar uses
`FIXED_LAYOUT_FORMATS.has(bookFormat)` so CBZ picks `section`
(correct image count) instead of `pageinfo` (locations).
- ProgressBar: switch the remaining-pages text to "in book" for
fixed-layout titles (no chapter structure) and keep
"in chapter" for reflowable books.
WebDAV refactor for translation coverage:
- WebDAVBrowsePane / SyncHistoryPanel called `t(...)` (passed as a
prop) instead of `_(...)`. The i18next-scanner only looks for
`_`, so ~53 strings were unreachable and shipped in English to
every locale. Switched both components to call
`useTranslation()` themselves; helpers that aren't React FCs
take `_: TranslationFunc` so the scanner sees the literal calls.
- WebDAVClient.checkConnection now returns a `code` discriminator
(`SERVER_URL_REQUIRED` / `AUTH_FAILED` / `ROOT_NOT_FOUND` /
`UNEXPECTED_STATUS` / `NETWORK`); raw English `message` is
reserved for the dev console. New `formatConnectError` and
`formatSyncError` helpers in WebDAVForm translate via a switch
where each branch is a literal `_('...')`. Same treatment for
the sync-failure path that previously surfaced raw e.message.
- "Syncing 0 / {{total}}" is now parameterized as
"Syncing {{n}} / {{total}}" with n=0 at startup so the digit
formats naturally and the template can be reused mid-sync.
- "Cleanup · {{count}} book(s)" hard-coded options used unsupported
ternary; rewrote as plural-aware key.
i18n scanner fix (i18next-scanner.config.cjs):
- vinyl-fs walked into directories whose names end in source-file
extensions (Next.js route folder `runtime-config.js/`, Playwright
screenshot folder `*.test.tsx/`) and crashed with EISDIR.
Resolved by expanding globs via `fs.globSync` and filtering to
files only before handing to the scanner.
TypeScript-syntax sites that broke esprima during extraction:
- WebDAVBrowsePane / WebDAVForm: `(e as Error).message` and
`failed[0]!.title` inside `_(..., options)` arguments. Replaced
with `e instanceof Error ? e.message : String(e)` and
`failed[0]?.title ?? ''` — also runtime-safer.
User-facing em-dash cleanup:
- Removed em-dashes from translation keys across SyncHistoryPanel /
WebDAVForm / WebDAVBrowsePane / SyncPassphraseSection / send/page /
replicaCryptoMiddleware / AIPanel. Tagline in `layout.tsx` kept.
Locale translations:
- ~2400 translations applied across all 33 locales for the keys
that were either newly extractable, freshly worded, or
pre-existing but untranslated. Zero `__STRING_NOT_TRANSLATED__`
remain after the run.
Misc:
- next.config.mjs: drop `eslint.ignoreDuringBuilds: true` so build
runs the same lint as CI.
- Collection type: add `total?: string` for ComicInfo series count.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(test): fix vitest invocation, run with 4 workers
`pnpm test:pr:web` was chaining `pnpm test -- --watch=false`, which
pnpm expanded into:
dotenv -e .env -e .env.test.local -- vitest -- --watch=false
The second `--` made vitest treat `--watch=false` as a positional
file pattern, not a flag. Vitest then fell back to defaults (in CI's
non-TTY env that still meant a one-shot run, so the suite passed),
but the worker pool was effectively serialized for big chunks of the
243-file run — wall ~90 s on a 4-vCPU runner where the parallel-sum
of phases was ~236 s (≈2.6× effective parallelism).
Replace the chained pnpm invocation with a direct call to
`vitest run --maxWorkers=4`, matching the 4 vCPUs the GH Actions
ubuntu-latest runner provides.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Email-in (`<user>@readest.com`) is now a paid feature. The other
Send channels — in-app /send page, mobile share-sheet, browser
extension — stay open to free users.
Three enforcement layers:
- `pages/api/send/address.ts` and `pages/api/send/senders.ts` return
403 with `{ code: 'plan_required', plan, requiredPlans }` for free
users. No `send_addresses` row is allocated on the blocked path.
`pages/api/send/inbox.ts` and `pages/api/send/inbox/file.ts` are
deliberately left open — they're shared with the file-upload and
extension channels.
- `workers/send-email` looks up `plans.plan` after resolving the
recipient and bounces (not silently drops) inbound mail for free
users with a one-sentence message pointing to upgrade plus the free
clip channels. Bounce rather than drop so a downgraded user
understands why their mail stops landing.
- `components/settings/integrations/SendToReadestForm.tsx` reads the
user's plan from the JWT before any API call. Free users see one
friendly card — headline, value prop, "View plans" CTA → /user, and
a softer line about the free alternatives — instead of address /
senders / activity sections of disabled controls. The
IntegrationsPanel NavigationRow stays visible so users can discover
the feature.
Single source of truth for the entitled tier set: `EMAIL_IN_PLANS` +
`isEmailInPlan(plan)` in `src/utils/access.ts`. Mirror copies live in
the Worker (no shared import surface) — keep them in sync.
Edge cases:
- Downgraded user: existing `send_addresses` row stays. All three
layers block; re-upgrading silently restores the same address.
- Loading flicker: `userPlan` starts as `null` so the loading skeleton
stays up rather than briefly flashing the upgrade card for a paid
user on a slow client.
12 new unit tests cover the gate on `/api/send/address` and
`/api/send/senders` (GET + POST blocked for free users, no Supabase
access on the blocked path, allowed for plus / pro / purchase).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In #4230 the new .btn-contrast block was inserted into the middle of
the existing rule
[data-eink='true'] button,
[data-eink='true'] .btn {
color: theme('colors.base-content') !important;
}
which left [data-eink='true'] button grouped with .btn-contrast
(applying background-color/border/color: base-100) and orphaned
[data-eink='true'] .btn as its own rule. Net effect on any button
that also has class="btn" — toolbar icon buttons, popup actions, etc.:
[data-eink='true'] button bg=base-content, color=base-100 (specificity 0,1,1)
[data-eink='true'] .btn color=base-content (specificity 0,2,0)
The .btn rule wins on color, so the button ends up with a base-content
background AND base-content text color. react-icons children render
with fill: currentColor → invisible icon on solid background → every
toolbar/popup button becomes a solid black (light mode) or solid white
(dark mode) square in e-ink mode.
Fix is the minimal regroup that #4230 was apparently trying to make:
put the new .btn-contrast block AFTER the existing eink button/.btn
selector list rather than splitting it.
Tested locally: in e-ink mode, all annotation toolbar / quick-action
popup buttons recover their icon glyphs; .btn-contrast still renders
as the intended solid-CTA in both modes.
* feat(integrations): add WebDAV sync to Reading Sync settings
Adds a WebDAV entry under Settings -> Integrations -> Reading Sync with configure/browse UI, library-wide Sync now, and per-book sync of progress, annotations and (opt-in) book files + covers.
Reading progress and annotations are always synced when WebDAV is enabled; only Sync Book Files stays as a toggle since it's bandwidth-heavy.
* feat(webdav): add diagnostic sync history panel and document viewSettings invariant
Surface a per-run history for the WebDAV "Sync now" button so users can self-triage failures without rummaging through the dev console — a screenshot of the panel is now enough to file a useful bug report. The same change tightens the docs around viewSettings so the "device-local UI preferences" boundary is impossible to misread on the next refactor pass.
Sync history panel:
* New WebDAVSettings.syncLog ring buffer (cap 10), persisted alongside the rest of settings so a screenshot survives across app restarts. WebDAVSyncLogEntry captures startedAt, finishedAt, status (success / partial / failure), trigger, the eight counters from SyncLibraryResult, the toast text, and an optional per-book failure list with a phase tag (download / upload-config / upload-file).
* SyncLibraryResult gains a failedBooks: SyncFailureEntry[] field. The two existing failure points in syncLibrary (download catch, upload catch) now record per-book reason+phase via formatFailureReason(), which keeps the persisted blob small by stripping stacks/whitespace and capping length at 200 chars.
* WebDAVForm.handleSyncNow now timestamps the run, builds an entry from the result on success/partial paths and from the caught error on failure paths, and appends through a fresh-read appendSyncLogEntry() so concurrent toggle changes can't clobber the log.
* New SyncHistoryPanel + SyncStatusBadge + SyncHistoryDetails components render the log inline in the Settings page. The detail row groups counters into three semantic columns (activity, skipped, outcome) on a six-column grid so labels can wrap freely while numbers stay tabular and right-aligned. Per-book failures render as a separate stack below the counters.
viewSettings invariant:
* buildRemotePayload and pullBookConfig already implement the right thing — only progress/location/xpointer/booknotes travel; viewSettings stays device-local. Comments now spell out the contract on both sides so future contributors don't reintroduce viewSettings on the wire by mistake.
* fix(webdav): preserve prior state across reconnect, drop stale closure in ensureDeviceId
Two bugs in the WebDAV sync flow surfaced during review:
1. WebDAVForm.handleConnect rebuilt the entire `webdav` settings block
from the four credential fields the user just typed, dropping
`deviceId`, `syncBooks`, `strategy`, `syncProgress`, `syncNotes`,
`lastSyncedAt`, and `syncLog` on every reconnect. Most concerning is
the deviceId rotation: a disconnect + reconnect made the next sync
look like a brand-new device, defeating the cross-device clobber
detection encoded in `RemoteBookConfig.writerDeviceId`. Extract a
pure helper `buildWebDAVConnectSettings` that spreads the previous
webdav object first so reconnect is non-destructive, matching the
sibling pattern in KOSyncForm.
2. useWebDAVSync.ensureDeviceId merged the new deviceId into the closure
variable `settings`, which can be stale when `pullNow → pushNow`
fires back-to-back on book open or when the settings panel writes a
sibling field concurrently. Read latest settings via
`useSettingsStore.getState()` to match the pattern already used in
`updateLastSyncedAt` and `persistWebdav`.
Adds three unit tests for the new helper, including the reconnect
preservation invariant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(webdav): address review observations on encodePath, pull skip, and remote GC
Three follow-ups from the review pass on top of 3f721d04. Each one was
called out as a smaller observation the reviewer noted but did not push:
* WebDAVClient.encodePath silently re-escaped literal % characters
despite a comment claiming existing %-escapes are preserved. A caller
that pre-encoded a space as %20 would see %20 become %2520 in the
request URL, breaking any path that came in already escaped. Tokenise
each segment into already-escaped %XX runs and everything-else, and
only run encodeURIComponent on the latter. Add four unit tests
exercising pure-unicode, pure-pre-escaped, mixed, and root-slash
paths.
Implementation note: two regexes are needed because a /g RegExp.test
is stateful and would skip every other token in this map; the split
regex has /g for the iteration, the classifier regex is anchored
without /g for the per-token check.
* OPEN_PULL_SKIP_MS doc-comment claimed it catches the
close-then-reopen flow, but useWebDAVSync unmounts on reader close so
lastPulledAtRef resets to 0 — the new instance always passes the
cooldown check on remount. The guard actually only fires on
re-invocations of the open-book effect inside one hook lifetime
(book-to-book navigation, double-render before hasPulledOnce flips).
Rewrite both the constant's doc-comment and the call-site comment to
match the real semantics.
* WebDAVSync push path doesn't DELETE the per-hash directory of a
tombstoned book. The deletion *is* propagated through library.json
so other devices hide the book, but storage on the WebDAV server
grows monotonically. Add a TODO at the pushLibraryIndex call with a
sketch of what a future garbage-collection sweep would need (a per-
device acknowledgment field on RemoteLibraryIndex so we don't wipe
data a peer hasn't seen the deletion for yet).
* refactor(webdav): extract WebDAVBrowsePane and SyncHistoryPanel from WebDAVForm
The WebDAV settings form was nearing 1500 lines and hosted three
loosely related surfaces — credential entry, sync controls + manual
trigger, and the in-app file browser — that didn't share much state.
Reviewer flagged it as a refactor candidate; this commit does the
actual split.
* WebDAVBrowsePane (new, 534 lines): owns currentPath, the directory
listing, per-entry download status, the navigation handlers and the
per-file icon / filename helpers. Reads credentials from the
settings prop and otherwise reaches for envConfig / useLibraryStore
/ useAuth itself rather than threading them through props (matches
how the rest of the integrations panels are wired).
* SyncHistoryPanel (new, 293 lines): the diagnostic history surface
plus its three private helpers (SyncStatusBadge, formatSyncSummary
Line, formatSyncTimestamp, SyncHistoryDetails). Moved verbatim from
the inline definitions at the bottom of WebDAVForm — the component
was already presentation-only and accepting the translation fn as a
prop, so no API change.
* WebDAVForm (676 lines, down from 1456): keeps the mode switch
(configured vs. not), the credential form, the sync sub-controls
(Upload Book Files / Sync Strategy / Sync now button), and the
large handleSyncNow effect — those last two are intrinsically tied
to the settings store and would have just been pushed back up the
prop chain by any extraction. The standalone SyncHistoryPanel and
WebDAVBrowsePane are now mounted as siblings inside the configured
branch.
No behavioural change — both new files run the same effects, build
the same JSX, and read/write the same store fields as before. All
existing webdav-related unit tests still pass.
Resolves the last of the reviewer's smaller observations on
3f721d04 (file length).
* fix(webdav): stream book uploads to avoid renderer OOM on large files
Both syncLibrary (manual Sync now in WebDAVForm) and useWebDAVSync (per-book auto/manual sync triggered on book open) materialised the full book binary as an ArrayBuffer in the V8 heap before PUTting it. With multi-hundred-megabyte PDFs / scanned books, the renderer either accumulates buffers across sequential pushes (library sync) or blows its heap ceiling on a single book (per-book sync), surfacing as a blank white screen on desktop and a binder-OOM kill of the WebView on Android.
Add a BookFileStreamingLoader option to pushBookFile that, on Tauri targets, hands the file path off to tauriUpload's Rust-side streamer so bytes never enter JS. The HEAD short-circuit is shared across both paths, so steady-state syncs still cost a single round-trip per book. Web targets keep the buffered fallback (no streaming HTTP primitive available there).
Wire the streaming loader through SyncLibraryOptions.loadBookFileStreaming for the library Sync now path, and inline it in useWebDAVSync.pushBookFileNow for the per-book path. Covers stay on the buffered loader — they're capped at a few hundred KB and don't justify widening the API.
* fix(webdav): keep Sync now state alive across Settings navigation/close
WebDAVForm tracked the library-wide Sync now run in component state, so any navigation that unmounted the form (drilling back to the Integrations list, or closing the SettingsDialog entirely) destroyed the in-flight indicator while syncLibrary's promise kept running off-thread. On return the user saw a re-enabled button with no progress affordance, an empty Sync History (until the run finally finished), and could trigger a second concurrent syncLibrary against the server.
Hoist isSyncing / progressLabel into a process-local zustand store (webdavSyncStore) and consume it from WebDAVForm. The store outlives any single mount, so re-mounting the form picks up the running sync's state on first render — button stays disabled, progress label keeps ticking, and the re-entrancy gate (now reading the live store rather than a stale closure) blocks duplicate clicks. Also surface 'Syncing…' in the IntegrationsPanel row so users get the cue without drilling into the sub-page.
Not persisted: the store dies with the renderer, which is the right semantic — a sync killed by app exit shouldn't look like it's still going on next launch.
* feat(webdav): cleanup mode for orphan book directories on the server
WebDAV pushes set Book.deletedAt as a tombstone but never DELETE the per-hash directory on the server, so the remote Readest/books/ tree accumulates dead entries from books the user deleted long ago. Add a dedicated cleanup mode in the WebDAV browser to evict them in batch.
Cleanup mode is reached via a new sweep button next to Refresh. Entering it pins the listing to Readest/books/, filters down to directories whose local Book carries deletedAt, and replaces the per-row icon with a checkbox. The footer carries a single right-aligned Delete from server action; selecting one or more rows and clicking it sends a confirm dialog (appService.ask, so it actually blocks on Tauri) and then runs sequential DELETEs against the server. Each row splices out of the listing the moment its DELETE returns, so the listing itself is the progress indicator; the button keeps a stable width by always reserving space for the spinner via the invisible class.
The local library is left untouched. Book.deletedAt is the authoritative deletion signal in readest's sync model — clearing or rewriting it here would cause sibling devices to either resurrect the book or lose the deletion event. Restore is therefore not offered: the per-entry download button already provides full recovery (tauriDownload + ingestFile streams the file back, ingestFile clears deletedAt as a side-effect, and the next sync round-trip merges remote progress and notes), and a metadata-only restore would leave users staring at unopenable shelf rows whenever the bytes had been GCed off local disk.
Browse mode is friendlier too. Per-hash subdirectory rows under Readest/books/ resolve their hash to the local library's title and short-form hash for skimmability; soft-deleted entries get a folder-off icon plus a 60% dimmed title (a redundant signal for touch platforms where the desktop-only hover tooltip doesn't fire). Cleanup runs are persisted into the existing sync history with a kind: 'cleanup' discriminator and a booksDeleted counter, so destructive batch operations are auditable alongside regular Sync now runs without polluting the common case (the new counter is zero-suppressed on plain sync entries).
* test(webdav): cover deleteDirectory and deleteRemoteBookDir
Pin the contract of the cleanup-mode delete plumbing: HTTP method, Depth: infinity header, Authorization header and target URL on the low-level deleteDirectory; success/failure/auth-failure routing and per-hash path construction on the high-level deleteRemoteBookDir. Status-code semantics are exercised end to end (200/204 ok, 404 idempotent, 401/403 AUTH_FAILED, 5xx generic, network throw NETWORK), so a future refactor can't silently drop the explicit Depth header or merge the auth-failure path into the per-book result struct without tripping a regression.
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two layered additions to the clip-to-Readest pipeline:
* **Twitter / X article rule** — `[data-testid="twitterArticleReadView"]`
for the body, `User-Name`/`UserAvatar-Container-*` testids for byline
and avatar. Readability can't latch onto X's class-mangled CSS-in-JS,
so the testid hooks are the only reliable anchors.
* **Universal meta-tag fallback** — new `META_FALLBACK` constant in
`siteRules.ts` holding OpenGraph / Twitter Card selectors for title,
byline, and author image. Site rules are now hints, not contracts:
when their selectors miss (after a frontend redesign) the pipeline
drops down to meta tags, then to Readability for content, before
giving up. The fallback also fires when no site rule matched at all,
so previously-unruled sites pick up byline + cover image they used
to lack.
Wiring in `convertToEpub.ts`:
- `extractWithSiteRule` consults `META_FALLBACK.{title,byline}` when
rule selectors return empty.
- The Readability branch does the same so non-ruled sites get a real
byline/title instead of `''`.
- `buildArticleCover` tries `og:image` / `twitter:image` between the
rule's `authorImage` and the favicon fallback.
- `extractHtmlTitle` prepends `og:title` before `<title>` / `<h1>`.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: optimize build time for Docker and CI workflows
- Dockerfile: slim production-stage to copy only runtime artifacts
(.next, public, node_modules, package.json, next.config.mjs),
dropping src/, src-tauri/, patches/, packages source, etc.
- Dockerfile: add sharing=locked to pnpm store cache mount to prevent
concurrent-build cache corruption
- docker-image.yml: pin actions/checkout to SHA (consistent with other workflows)
- docker-image.yml: switch Buildx cache from type=gha (10 GB shared limit,
poor for multi-arch) to type=registry on GHCR (no size cap, already
authenticated, correct per-platform caching)
- pull-request.yml: use pnpm install --frozen-lockfile --prefer-offline
- release.yml: use pnpm install --frozen-lockfile --prefer-offline
- next.config.mjs: skip redundant eslint pass during next build
(lint already runs as a dedicated CI step)
* fix(docker): eliminate QEMU emulation, fix pnpm version mismatch, patch artifact write CVE (#4)
* fix(docker): eliminate QEMU arm64 emulation and fix pnpm version mismatch
- Fix pnpm version in Dockerfile: 10.29.3 → 11.1.1 (matches package.json)
Prevents corepack from re-downloading pnpm 11 on every build
- Replace single QEMU job with matrix build (ubuntu-latest for amd64,
ubuntu-24.04-arm for arm64) — eliminates ~21 min QEMU emulation overhead
- Use per-platform build cache tags (buildcache-linux-amd64 / buildcache-linux-arm64)
to avoid cache thrashing between architectures
- Add merge job that assembles multi-arch manifest from platform digests
Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/89c298b4-8a58-4517-ac7f-2f26c86bbcd6
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>
* fix(ci): upgrade artifact actions to v7 to patch arbitrary file write vulnerability
actions/download-artifact >= 4.0.0 < 4.1.3 allows arbitrary file write
via artifact extraction. Pin both upload-artifact and download-artifact to
v7 (SHA-pinned), consistent with the rest of the repo's workflows.
Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/89c298b4-8a58-4517-ac7f-2f26c86bbcd6
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>
* chore(docker): tighten build context
Exclude local env, build output, credential, and tooling state files from Docker build context to reduce registry cache exposure.
---------
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
migrate20251029 unconditionally calls copyFiles() and deleteDir() on the legacy Images/Readest/Images path. On a fresh install where that directory never existed, both calls bubble up an OS error ("os error 2" / ENOENT) which is then caught one frame up by runMigrations() and printed as an Error migrating to version 20251029 in the console. Functionally harmless (migrationVersion is still advanced afterwards), but a misleading red herring for new users and anyone debugging startup logs.
Check fs.exists(oldDir) up front and return early if absent; also guard the deleteDir call in case copyFiles partially completed and the cleanup target is gone. No behavior change on machines that actually have the legacy layout.
Add two complementary English documents under apps/readest-app/docs/ to help new contributors ramp up on the Readest codebase:
- code-layout.md: directory-level inventory of the monorepo, covering apps/readest-app, packages/, the Tauri native shell, workers, and the conventions used for stores, services, and components.
- architecture.md: high-level architecture with Mermaid diagrams. Maps the three runtimes (browser webview, Tauri Rust host, Next.js server), the dual API routers (pages/api legacy + app/api), Zustand stores, AppService / DatabaseService abstraction with its three backing implementations, foliate-js / pdfjs / simplecc / jieba integration, and cross-cutting subsystems (sync, cloud library, AI/RAG, translation, TTS, dictionaries, OPDS, Hardcover/Readwise, annotations, Send to Readest). Also documents the COOP/COEP middleware, allow_paths_in_scopes security gate, and the runtime re-config trick used for the single-image Docker deploy.
No code changes; documentation only.
* feat(send): iOS share-extension picker + App Group queue + reliable host launch
Rework the iOS Share Extension to a Zotero-style sheet: URL preview row +
library group picker + "Save & Open". Queues each save into the shared
App Group container and best-effort launches the host app via the
Chrome-style responder-chain trick (IMP cast against
`openURL:options:completionHandler:`). The host plugin drains the queue
on `applicationDidBecomeActive`, so if the launch ever fails the article
still ingests next time Readest is opened.
A `WKScriptMessageHandler` named `readestShareBridge` lets the JS hook
post `{type:'ready'}` on mount, fixing the cold-start race when the
extension wakes the app before the React side has loaded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(send): cover generator, favicon fetcher, share-extension polish, locale sync
Builds on the previous commit (iOS share-extension picker + App Group queue +
reliable host launch) with three further additions to the send-to-Readest
pipeline:
* **Cover generator** — `services/send/conversion/coverGenerator.ts` renders a
deterministic cover image into clipped EPUBs (favicon + page title + host).
Hooked into `buildEpub.ts` so every clip path (desktop, mobile, browser
extension) produces the same cover for the same source.
* **Favicon fetcher** — `services/send/conversion/faviconFetcher.ts` resolves
the best-available site icon (Open Graph image → apple-touch-icon →
/favicon.ico), with size + format normalization. Feeds the cover generator.
* **Unified page conversion** — `convertToEpub({kind:'page', ...})` replaces
the older `convertPageToEpub(html, url)` so the share extension, /send page,
and browser extension share one entry point. Test: `send-convert-page-unified`.
* **Share-extension project.yml comment** — clarifies why the ShareExtension
target carries no `.lproj` files (system bar buttons + JS-supplied "Default"
label, no per-locale strings to wire).
* **Locale sync** — 33 translation.json files updated with new "Default",
"Saving article…", and cover-generator strings extracted by i18next-scanner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the URL-only placeholder extension with a full MV3 page-clipper
that builds a self-contained EPUB on the user's machine and uploads it
to the inbox.
- Captures the rendered DOM in a content script, then runs Readability,
asset bundling, and EPUB build through the shared
`convertToEpub({kind: 'page'})` pipeline inside a Chrome offscreen
document (the SW lacks DOMParser).
- Uploads the resulting EPUB directly from the offscreen page to the
new `POST /api/send/inbox/file` endpoint — keeps the bytes in one
realm because `runtime.sendMessage` JSON-serialises ArrayBuffer to
`{}` between extension contexts.
- Adds a long-lived Port + ping handshake between SW, offscreen, and
the on-demand capture content script so neither idle-eviction nor
load-order races can hang the popup.
- Localised popup, badge feedback, key-as-content i18n (`_('English source')`)
with an extract script that seeds locale stubs from i18n-langs.json
and writes a static-imports map for the runtime. All 33 locales
fully translated.
- Server: `pages/api/send/inbox/file.ts` accepts a raw EPUB body
(Content-Type: application/epub+zip), enforces the inbox pending cap,
stores to the existing send-inbox R2 bucket as `kind='file'`.
`assetBundler` now sets `credentials: 'include'` in the non-Tauri
branch so the extension SW carries paywalled-CDN cookies.
- 47 vitest cases for the extension shell (upload, badge, auth, lazy,
popup state machine, auth-bridge token sync) + 8 cases for the new
server endpoint. CI's `test_web_app` invokes both via the extended
`test:pr:web` plus a `build-browser-ext` step that catches webpack
alias / Tauri-stub regressions.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces a 'global' annotation flag so a highlight/note created on one occurrence of a phrase is automatically applied to every matching occurrence in the book (and stays applied across reloads). Renders these expansions as transient overlays without creating duplicate persisted notes. This flag will not show when the book is fixed layout like PDF or CBZ.
- types: add 'global?: boolean' to BookNote and DBBookNote; transform layer round-trips the field, with regression coverage ensuring older clients do not clobber it on write-back.
- db: new migration 013_add_book_notes_global.sql adds nullable 'global' column to public.book_notes; init schema.sql updated to match.
- annotator: new utils/globalAnnotations.ts handles cfi expansion / text-match search across the spine and overlay synthesis. Annotator.tsx fans out global notes on load and on overlay creation; AnnotationPopup and HighlightOptions expose a toggle to mark a highlight as global.
- sync path is transparent: a global note created on another device is fanned out locally on next render with no extra UI required.
Users can now tap "Share → Readest" in Safari, Chrome, or any other
browser on iOS / Android and the article URL flows through the same
clip-and-import pipeline the in-app "From Web URL" entry uses.
Android
`MainActivity.handleIncomingIntent` already routed file shares via
`ACTION_SEND` + `EXTRA_STREAM`. Extend it to also pick up URL shares
via `ACTION_SEND` + `EXTRA_TEXT`: parse the first http(s) token out of
the text payload and dispatch it on the existing `shared-intent` event
channel. No new event channel needed — `useAppUrlIngress` already
listens and re-broadcasts as `app-incoming-url`.
The existing `<intent-filter>` for `ACTION_SEND` with `*/*` MIME type
already accepts `text/plain` from browsers — no manifest change
required.
iOS
`gen/apple/` gains a new ShareExtension target. The extension's
`ShareViewController` extracts a URL from `NSExtensionContext.inputItems`
(prefers `public.url`, falls back to first http(s) token in
`public.plain-text`) and forwards it to the main app as
`readest://clip?url=<encoded>` via the responder-chain `openURL:`
selector — the standard share-extension trick used by Pocket,
Instapaper, Matter, etc.
`project.yml` adds the ShareExtension target and switches the main app's
Info.plist / entitlements references to `INFOPLIST_FILE` /
`CODE_SIGN_ENTITLEMENTS` build settings instead of xcodegen's `info:` /
`entitlements:` blocks. That way the hand-tuned `Readest_iOS/Info.plist`
(CFBundleDocumentTypes, UTExportedTypeDeclarations, locales,
CFBundleURLTypes for readest://, applesignin, associated-domains for
Universal Links) is treated as an opaque input — xcodegen won't
regenerate it.
JS
New `useClipUrlIngress` hook subscribes to `app-incoming-url`,
unwraps `readest://clip?url=<encoded>` into the inner URL (the iOS
forwarding path), filters out file URIs and annotation deep links,
and runs each remaining http(s) URL through `clip_url` →
`convertToEpubWithWorker` → `ingestFile` — the same path `/send` uses.
Mounted alongside `useOpenWithBooks` and `useOpenAnnotationLink` in
both `app/library/page.tsx` and `app/reader/page.tsx` so shares
arriving while the user is reading still process.
Notes
- The PR targets `feat/send-clip-mobile` (PR #4252) since the share
pipeline depends on `clip_url` being available on mobile.
- iOS Share Extension built locally via xcodegen; the regenerated
pbxproj is tracked because gen/apple is gitignored.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iOS and Android now run the same Web-URL clip flow as desktop. Paste
an article URL, the native side opens a full-screen WKWebView /
WebView with the same Chrome UA + fingerprint mask + "Saving to
Readest" overlay as the desktop hidden window, waits for load +
settle, captures `document.documentElement.outerHTML` via the
platform's `evaluateJavaScript`, and returns it through the existing
`convertToEpub` pipeline.
JS surface stays `invoke('clip_url', { url, options })` — no changes
in `library/page.tsx` or `send/page.tsx`. The platform branch lives
entirely in `clip_url.rs`.
Why not Tauri's `Window::add_child`
`add_child` is gated `#[cfg(any(test, all(desktop, feature =
"unstable")))]` in tauri 2.10. No public API for attaching a second
webview to the main window on mobile, so the clip flow can't be a
`#[cfg(mobile)]` branch of the existing `WebviewWindowBuilder` shape
— it needs native code. Extend `tauri-plugin-native-bridge` rather
than create a separate plugin: the Swift / Kotlin scaffolding +
Tauri IPC are already there.
Layout
- `src-tauri/src/clip_url.rs` — desktop branch unchanged; new
`#[cfg(mobile)]` `clip_url` command routes through
`app.native_bridge().clip_url(request)`. Shared `ClipOptions`
struct exposes its fields `pub` so the mobile branch can map into
the plugin's `ClipUrlRequest`.
- `plugins/tauri-plugin-native-bridge/src/models.rs` — `ClipUrlRequest`
+ `ClipUrlResponse` mirroring `ClipOptions` field-for-field so the
payload travels untouched from JS through to Swift/Kotlin.
- `plugins/tauri-plugin-native-bridge/src/{desktop,mobile}.rs` — desktop
returns an error (desktop has its own path); mobile dispatches via
`run_mobile_plugin("clip_url", payload)`.
- `ios/Sources/ClipUrlController.swift` — `UIViewController` hosting
`WKWebView` with the loading overlay drawn as native UIKit views
(not an injected user script, so the page's own hydration can't
wipe the spinner). 30 s hard timeout + 3 s settle window after
`didFinish`, same as desktop. Fingerprint mask injected as
`WKUserScript` at `.atDocumentStart`.
- `android/src/main/java/ClipUrlController.kt` — full-screen Dialog
hosting a `WebView`, mirrors the iOS controller's behaviour. JSON-
decodes the `evaluateJavascript` callback (raw return value is a
JSON-encoded string).
- `NativeBridgePlugin.{swift,kt}` — new `clip_url` method that parses
args via `invoke.parseArgs`, presents the controller, resolves the
invoke with `{ html }` on success or `invoke.reject` on failure.
Same rejection vocabulary as desktop (`"Invalid URL"`, `"Page took
too long to load"`, etc.) so the calling JS doesn't need a
platform branch.
- `build.rs` — adds `clip_url` to the plugin's `COMMANDS` array.
Notes
- The Swift overlay reserves the iOS safe-area-edge-to-edge so notch /
Dynamic Island devices don't see the underlying app peek through
during the brief capture window.
- The Android overlay's spinner tint follows the foreground theme
colour at 85 % alpha — same idea as the iOS controller.
- `WKWebView`'s JS keeps running while the controller is presented;
no off-screen / `isHidden` trick that would let iOS throttle the
page mid-capture.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(macos): place traffic lights via Tauri trafficLightPosition
Replaces the cocoa private-API positioning that drove traffic light placement through IPC with Tauri's supported trafficLightPosition window option, which routes through wry's macOS API and stays correct across versions including macOS 26 (Tahoe).
Position is now declared once at window creation: WebviewWindowBuilder.traffic_light_position in src-tauri/src/lib.rs for the initial main window, and trafficLightPosition on new WebviewWindow(...) in utils/nav.ts for reader windows and the recreated main window. The reader path mattered — those windows used to rely on the cocoa hack to place buttons after the on_window_ready hook fired, so any path that bypassed it left the buttons in AppKit's overlay default position (off-screen on macOS 26 until a resize).
The IPC surface narrows accordingly. set_traffic_lights now takes only visible: position is no longer a parameter and the WINDOW_CONTROL_PAD_X/Y static muts go away; setTrafficLightVisibility drops its position arg in trafficLightStore; useTrafficLight and HeaderBar drop their hard-coded { x: 10, y: 20 } magic numbers. position_traffic_lights stops touching the per-button NSWindowButton frames entirely and only collapses or restores the title-bar container view to hide / show buttons during reader chrome auto-hide. A short-circuit on the no-op transition keeps the cocoa setFrame from racing AppKit's own traffic-light tracking on every IPC call.
useTrafficLight stays — it still owns full-screen visibility synchronisation, the auto-hide visibility toggle, and feeds isTrafficLightVisible to the self-drawn <WindowButtons /> in the auth, library, OPDS, reader-sidebar, and user headers. None of those have an equivalent in the new declarative API. Only its 'where do the buttons sit' responsibility was moved out.
A single named constant TRAFFIC_LIGHT_RESTORE_Y_INSET is left behind in traffic_light.rs, used solely by the visible: false → true restore path to recompute the title-bar container height. It must agree with the y component of the two declarative trafficLightPosition values; a doc comment makes that contract explicit. Caching each window's natural title-bar height before the first collapse would let us delete the constant entirely, but the per-window state machine that requires is not worth the win for a single number.
y is tuned by eye to 24 to vertically center the buttons inside readest's ~48px header bar on macOS 26.1.
* fix(macos): center traffic lights from live AppKit offset, no version check
Restores the pre-PR cocoa-driven positioning that worked on macOS 15
while keeping the macOS 26 fix this PR was originally about: the
plugin owns `position_traffic_lights`, which now sizes the title-bar
container *and* sets each window button's frame.origin on every
on_window_ready / resize / theme-change / full-screen-exit event. Tao's
runtime `inset_traffic_lights` never fires (we never declare
`trafficLightPosition` or call `set_traffic_light_position`), so there
is no second code path fighting us on drawRect.
The y inset that visually centers the close button is computed at
runtime as
y = (header_height - button_height) / 2 + button_origin_y
where `button_origin_y` is the close button's natural rest position
inside the title-bar container. Apple shifted that rest position by
~2pt on macOS Tahoe (26), so the same formula yields y=22 on macOS 15.6
and y=24 on macOS 26.1 with a 48px header — no `NSProcessInfo` lookup
and no hardcoded per-OS offset. The natural origin.y is read once and
cached via `OnceLock` so any post-resize autoresize that AppKit might
apply doesn't feed back into the centering math.
Frontend plumbing: `set_traffic_lights` IPC now carries `headerHeight`;
the zustand store remembers it across visibility toggles; the
`useTrafficLight` hook accepts a header ref, mirrors `ref.current`
into local state (so the effect re-runs when LibraryHeader's
conditional render flips the ref from null to the live node), measures
the border-box height on mount, and observes via ResizeObserver to
re-push on responsive breakpoint / safe-area changes. LibraryHeader,
sidebar Header, OPDS Navigation, and the reader HeaderBar each pass
their own ref so y is computed against the chrome each page actually
renders.
Library header is normalised to h-[44px] desktop to match the reader's
h-11 and drops the `-2px` macOS marginTop workaround, since the runtime
centering removes the need for it.
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Worktrees set up by `pnpm worktree:new` symlinked `src-tauri/gen/apple` back
to the bare repo. The Xcode "Run Script" build phase
(`pnpm tauri ios xcode-script`) walks up from `Readest.xcodeproj` to find
`Cargo.toml`, but the symlink target resolves to the bare repo's
`src-tauri` — so `pnpm tauri ios dev` from a worktree silently built the
bare repo's Rust source, not the worktree's.
Switch to a filtered copy. `project.yml` references `../../src` and the
xcode-script walks up to `../../Cargo.toml`; with the project copied into
the worktree, both paths resolve to the worktree's source. Mirrors what the
script already does for Android (`tauri android init` per worktree).
Skipped on the copy:
- `build/` (~300 MB of Xcode derived output)
- `Externals/<arch>/{debug,release}/` (Rust static libs — rebuilt from the
worktree's `src-tauri` on first build; the `target/` symlink the script
already sets up keeps the Rust object cache shared)
- per-user Xcode state (`xcuserdata`, `*.xcuserstate`)
- `Pods/` (defensive; Readest's iOS uses SPM, not Cocoapods)
Result: ~2.7 MB copy, ~30 ms locally, no `pod install`, no
`tauri ios init`. Signing config, scheme, asset catalogs and Tauri's
generated Swift glue all preserved verbatim from the bare repo.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds the URL-clipping path of the "Send to Readest" feature: paste a
link, the renderer ingests the rendered page, and a self-contained EPUB
lands in the library. No server proxy, no external CDN refs left in the
EPUB once it's saved.
Architecture
- New Rust `clip_url` command spawns a hidden Tauri WebviewWindow at the
target URL with a real Chrome UA + WebKit fingerprint mask, so TLS-
fingerprint and JS-challenge walls (Cloudflare, Medium, X, WeChat MP)
resolve naturally instead of bouncing the server proxy.
- Capture transport is URL-payload navigation to a one-shot
127.0.0.1:RANDOM_PORT/clip/{token}?d={url-safe-base64} listener.
Top-level navigation isn't governed by CSP connect-src / form-action /
WebKit Private Network Access — the four earlier transports
(fetch, <form>, custom URI scheme, window.name) were each blocked by
one of those.
- Page-to-EPUB bundler (`assetBundler`) walks <img>/<picture> with
src → data-src → data-original → data-srcset → srcset fallback so lazy-
loading sites don't ship a 60px LQIP; fetches assets in parallel with a
per-asset timeout + per-asset/total caps; failed images degrade to alt-
text placeholders. A per-site rules table (seeded with WeChat MP) + a
selector fallback catches articles Readability misextracts. Builder
prepends the article <h1> + byline so the EPUB has a proper opening.
- Nested EPUB TOC built from h1–h6.
UI surfaces
- "From Web URL" entry in the library Import menu, gated to Tauri; web
build hides the URL field and points at the browser extension.
- `ImportFromUrlDialog` with auto-height (overrides Dialog's `sm:h-[65%]`
default) and a dim placeholder for the URL field.
- Clip webview window styled to match Readest's main window — macOS
decorations + overlay title bar; other desktops decorationless with a
drop shadow; native background + in-page loading overlay pick up the
caller's `themeCode.bg`/`fg` so light/dark/eink/custom themes all
render correctly. Title localised, all five overlay/title strings
translated across 33 locales.
Notes
- Gates the macOS traffic-light positioner to main/reader-* windows so
the decorationless clip window no longer null-derefs in
`position_traffic_lights`.
- Stricter validation across the path: schemes restricted to http/https,
hex-color parsing rejects malformed values, server endpoint returns
400 on missing/invalid base64.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The macOS system-dictionary HUD samples the underlying paragraph's
typography via getRangeTextStyleInWebview so AppKit can re-draw the
small label using the same font / size as the page text. The sampler
trusted getComputedStyle().fontSize directly, which works for the
typical EPUB inline box but breaks badly on pdf.js text layers: each
glyph span carries an intrinsic font-size that reflects the document's
unit-em size before transform: scale(...) shrinks it back to page-
coordinate pixels, so the value can be many times larger than the
on-screen glyph. Forwarded as-is to NSFont, that gives AppKit a giant
attributed string and the yellow highlight rectangle behind the HUD
ends up engulfing neighbouring paragraphs while the laid-out text
overflows off-screen.
Cross-check the declared size against range.getBoundingClientRect().
height as a sanity bound. When the declared value exceeds the inline
box height by more than 30 %, fall back to renderedHeight * 0.85
(roughly the cap-height-to-1.2-line-height ratio) so PDF lookups
converge on a sane scale; otherwise keep the declared value untouched
so normal EPUB body text is unaffected.
* feat(library): add Import from Folder dialog with format/size filters
Replaces the silent "import every supported file recursively" behaviour of the directory import menu item with an explicit dialog that lets users pick which formats to include, set a minimum file size, and choose between mirroring subfolders as nested groups (legacy behaviour) or flattening every match into the current library view.
The folder, the chosen Folder Structure radio, the ticked File Formats and the File Size threshold are all persisted in localStorage so re-opening the dialog seeds every field with the user's last choice. Cancelling the dialog does not write to storage so an aborted pick won't pollute the next session.
Also hides the native number-input spinner via a small .no-spinner utility in globals.css; on macOS WebKit the spin buttons were drawing over the rounded input border and looked broken. The KB suffix now lives inside the input's bordered shell instead of beside it.
Two correctness fixes the dialog flow exposed:
* The library importer + ingestService now treat groupId as a tri-state — undefined means "don't touch the existing group", '' means "explicitly the library root", any other string means a specific group. Previously a falsy check in both layers conflated '' with undefined, so re-importing a deduped book under flatten mode silently kept its stale groupId/groupName from the prior keep-as-groups run, making the book reappear in the old subfolder group instead of moving into the library root. New regression tests in ingest-service.test.ts cover both the empty-string case and the omitted case.
* Imports of arbitrary user paths (e.g. ~/Downloads) now go through a new allow_paths_in_scopes Tauri command that extends both fs_scope and asset_protocol_scope. The dialog plugin only auto-grants fs_scope, so reads through the asset protocol (RemoteFile / convertFileSrc) used to fail with "asset protocol not configured to allow the path". The shim is invoked after every selectFiles / selectDirectory call and once more at the start of runFolderImport so localStorage-restored paths are also covered. Granted scopes persist across restarts via tauri_plugin_persisted_scope.
* fixup(library): harden Import-from-Folder scope grant + RTL/dialog polish
Three review fixes on top of the Import-from-Folder feature:
* lib.rs: refuse to extend asset_protocol_scope for paths not already
in fs_scope. Without this gate, any frontend code (XSS via book
content, OPDS HTML, dictionary lookups, or a compromised dependency)
could call allow_paths_in_scopes with '/' or '~/.ssh' and gain
persistent read access to arbitrary user files via the asset
protocol — the grant survives restarts thanks to
tauri_plugin_persisted_scope. Mirrors the defensive check in
dir_scanner.rs.
* ImportFromFolderDialog.tsx: migrate from a custom ModalPortal chassis
to the project's shared <Dialog> primitive so eink mode auto-removes
shadows, mobile gets the bottom-sheet treatment, RTL direction is
applied, and focus management is correct.
* ImportFromFolderDialog.tsx: swap directional Tailwind utilities for
the logical equivalents (text-start, ps-/pe-, rounded-s-, text-end)
per DESIGN.md §2.8 — Arabic/Hebrew users were getting a mirrored
number-input row with the KB suffix on the wrong side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* i18n(library): translate Import-from-Folder dialog strings across 33 locales
Translates the 13 new strings introduced with the Import-from-Folder
dialog (folder picker label, format-filter section, size-threshold
input, folder-structure radios, OK button, empty-result toast). All
33 supported locales — including RTL fa/he/ar — are now complete; no
__STRING_NOT_TRANSLATED__ placeholders remain in the catalog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* perf(send): dynamic-import the conversion fallback so /library stays lean
conversionWorker.ts value-imported convertToEpub for the no-Worker
fallback path. That pulled mammoth, @mozilla/readability, DOMPurify and
@zip.js/zip.js into the main bundle — eagerly loaded on /library via
useInboxDrainer's static import of conversionWorker.
Switch the fallback to `await import('./convertToEpub')`. The worker
entry still value-imports convertToEpub for its own chunk; the
main-thread fallback only loads the heavy deps when Workers are actually
unavailable or fail.
Measured on the production web build:
- before: /library eagerly loads the 634KB conversion chunk
- after: the 634KB chunk + its two ~627KB duplicates are all lazy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(send): never clobber the library when /send writes before it has loaded
The /send page (and the inbox drainer when it races the library page's
load) called `useLibraryStore.updateBooks(envConfig, [book])` while the
store still held the empty initial library — `libraryLoaded: false`. The
merge ran against `[]`, so `saveLibraryBooks` persisted just the new
book as the *entire* library and sync pushed the clobbered copy to every
device.
Two-layer fix:
1. Harden `updateBooks`: if `libraryLoaded` is false, load the real
library from disk first, then merge — `updateBooks` is now self-
protecting against any future caller that forgets the load step.
2. Gate `useInboxDrainer` on `libraryLoaded`. The hook now subscribes to
the flag and starts draining the moment the library finishes loading,
instead of running the first pass against an empty in-memory copy.
Adds a regression test that fails without the store change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Confirm Deletion (and the three other Alert callsites — clear
annotations, delete files, book-detail delete) used to try to keep
the icon/title/message and the Cancel/Confirm buttons in a single
row. At narrow widths the row would flex-wrap and produce a cramped
two-column shape with stacked buttons next to wrapped text — the
case shown in the original PR thread's third screenshot.
Rework the layout to always stack:
* outer container is now `flex flex-col gap-3` instead of toggling
between flex-row at sm+ and flex-col below.
* top block: icon + title/message, items-start (icon nudged with
`mt-0.5` so it baselines with the title).
* bottom block: `flex items-center justify-end gap-2` — Cancel +
Confirm always right-aligned on their own row.
* Drop the daisyUI `alert` class. Its `display: grid` +
`justify-items: center` was collapsing the actions row to content
width and pulling it toward centre, which defeated `justify-end`
the first time around. The styles I actually wanted (`bg-base-300
rounded-lg p-4 shadow-2xl`) were already explicit.
* Replace the chain of viewport-relative max-widths with the more
conventional `max-w-md sm:max-w-lg md:max-w-xl` cap so the
capsule doesn't grow without bound on big monitors.
* Drop the `text-center` flip — text stays left-aligned at every
width, which matches the rest of the app.
Color theme unchanged: blue `stroke-info` icon, `bg-base-300`
surface, `btn-neutral` Cancel, `btn-warning` Confirm, `btn-sm`
sizing. `useKeyDownActions` keyboard binding and `role='alert'`
preserved. No callsite changes — the four consumers keep the same
props.
Verified visually at 1400 / 900 / 520 / 500 px viewports via
`pnpm dev-web`; `pnpm test` (4389 passed) and `pnpm lint` (tsgo
+ biome) clean.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(library): clear nested-folder groups when deleting from bookshelf
Deleting a group from the bookshelf right-click menu used to leave the group on screen whenever the import had any sub-directories. The cause: getBooksToDelete matched only `book.groupId === id`, but the bookshelf renders a top-level group with id = md5("MyDir") while books imported from a sub-folder carry groupId = md5("MyDir/sub"). Sub-folder books never got marked for deletion, refreshGroups re-built the parent group from their groupName on the next render, and the user saw an undeletable folder.
Fix: when an id resolves to a known group via getGroupName, also collect every book whose groupName equals that path or starts with `${path}/`. Hash-based dedup keeps a book from being queued twice when both rules match. Single-book deletes and flat-folder group deletes are unaffected.
* refactor: expand group selections into book hashes at intake
Address review feedback on #4226: instead of re-deriving which books
belong to a group inside the deletion path with a path-prefix sweep,
resolve group ids into their constituent book hashes upstream where the
selection enters the deletion pipeline.
* New helper `expandBookshelfSelection(ids, items)` in libraryUtils:
group ids resolve to every (non-soft-deleted) book in the rendered
rollup; standalone book hashes pass through. Tested in isolation.
* `Bookshelf.deleteSelectedBooks` runs select-mode picks through the
helper before populating `bookIdsToDelete`.
* `BookshelfItem` right-click group delete dispatches the
constituent hashes from `group.books` directly, so the receiver
is a simple pass-through.
* `getBooksToDelete` collapses to a flat hash lookup — no prefix
sweep, no `getGroupName` call in the deletion path, no dedup set.
The nested-folder fix still holds because `generateBookshelfItems`
already rolls "MyDir/sub" books into the top-level "MyDir" group;
expanding via the rendered `group.books` picks them up automatically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Email Worker rejects mail from senders that are not in the user's
approved-sender allowlist. With an empty allowlist the very first send
("email it to yourself") bounces with an approval-pending notice, which
is the wrong first impression for the feature.
Seed the caller's verified account email as `approved` the moment we
lazily create the user's send_addresses row. Best-effort: address
creation still succeeds if the seed insert fails (idempotent via the
existing UNIQUE (user_id, email) constraint).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The labels (Added to your library / Waiting to be processed /
Processing… / Failed) went through `_(activityStatusLabel(item.status))`,
a dynamic key the i18next-scanner cannot extract — so non-English locales
rendered them in English. Inline the four literal `_()` calls into the
JSX so the scanner picks them up.
Translates the two missing keys in all 33 non-English locales. Also
sweeps three pre-existing untranslated System Dictionary keys that were
introduced in #4219.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(send): Send to Readest — multi-channel capture into your library
A Send-to-Kindle equivalent: email, web-upload, share, or one-click
capture books and articles into the cloud library; they sync to every
device.
Architecture (client-side processing): out-of-app channels drop a raw
payload into a per-user send_inbox; Readest clients drain it through one
shared ingestService.ingestFile(). The server never parses or converts.
- ingestService.ingestFile() — channel-agnostic import orchestration
extracted from library/page.tsx (DI-based, forceUpload support).
- send_addresses / send_allowed_senders / send_inbox tables + RLS + 4
SECURITY DEFINER claim/lease RPCs (migration 012_send_to_readest.sql).
- Conversion subsystem (DOCX/RTF/HTML/article/TXT -> EPUB) in a Web Worker.
- send-email Cloudflare Email Worker; inbox-drainer controller +
useInboxDrainer hook; /api/send/* routes.
- Send to Readest settings panel: inbound address, approved-sender
allowlist, recent activity, per-device drain toggle.
- /send web page (file drop + article URL) + SSRF-guarded fetch-url proxy.
- OS-shared files routed through ingestFile; Manifest V3 browser extension.
Security: inbox state changes only via SECURITY DEFINER RPCs (clients get
SELECT-only on send_inbox); approved-sender allowlist gates email;
SSRF guard on the one server-side URL fetch; inbox payload signed URLs
authorize against send_inbox.user_id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: run format:check in the pre-push hook
Biome format checking is fast (~0.4s), so gate pushes on it too — catches
mis-formatted files that bypassed the staged-only pre-commit hook before
they reach CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(send): address CodeQL security findings
- ReDoS (senders.ts): the email regex had ambiguous quantifiers around
the literal dot. Rewrote it linear-time (domain labels exclude '.')
and cap the input at 254 chars.
- XSS (convertToEpub.ts): run untrusted HTML through DOMPurify
(sanitizeForParsing — keeps document structure) before DOMParser, so
title extraction and Readability never parse executable markup.
- SSRF (fetch-url.ts): harden the host guard — block bare single-label
hostnames, IPv4-mapped IPv6, CGNAT/benchmark/multicast ranges, and the
unspecified address. DNS rebinding stays a documented residual risk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On iOS the system text-selection menu (Copy / Look Up / Translate /
Share) appeared on top of Readest's annotation toolbar. The previous
workaround removed and re-added the selection range on a timer
(makeSelectionOnIOS) to shake the menu off — flaky on iOS 16 and on the
first long-press of a word.
Suppress the menu natively instead, in the native-bridge iOS plugin.
ContextMenuSuppressor swizzles WKContentView so non-editable web
selections produce an empty menu that is never presented:
* editMenuInteraction(_:menuForConfiguration:suggestedActions:) — the
UIEditMenuInteraction delegate WebKit uses to build the menu on
iOS 16+ (the menu users actually see on modern iOS).
* presentEditMenu(with:) — a present-time backstop.
* canPerformAction(_:withSender:) — the legacy UIMenuController gate
for iOS 15 and earlier.
Editable HTML fields keep their native menu (Paste / Select All still
work) via a cut:/paste: probe. Text selection and drag handles are
unaffected, so the annotation toolbar still triggers.
With suppression handled natively, makeSelectionOnIOS is removed and iOS
selections take the same path as desktop.
Closes#4218
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The release workflow installs the Rust-based tauri-cli from the
feat/truly-portable-appimage branch for Linux builds, but the
tauri-action step had no tauriScript input. Without it, tauri-action
falls back to the npm @tauri-apps/cli, so the custom truly-portable
AppImage bundler was never actually used.
Set tauriScript to `cargo tauri` for the Linux matrix entries so the
just-installed Rust CLI is used. macOS/Windows resolve to an empty
string and keep using the npm CLI as before.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace Prettier with Biome for formatting JS/TS/JSX/CSS/JSON. The CI
format check drops from ~23s to ~0.4s.
- Unify config into a single root biome.json (formatter + linter); the
former apps/readest-app/biome.json was linter-only
- Mirror the old .prettierrc.json style: 100 line width, 2-space indent,
LF, single quotes, trailing commas
- Enable the CSS tailwindDirectives parser for @apply in globals.css
- Convert // prettier-ignore comments to // biome-ignore format:
- Root scripts and lint-staged now run biome; apps/readest-app lint runs
`biome lint` (lint-only) so formatting stays a separate CI step
- Drop prettier + prettier-plugin-tailwindcss dependencies
Markdown/YAML are no longer format-checked (Biome does not format them)
and Tailwind class sorting is no longer enforced.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hand selected words off to the platform's native dictionary surface
when the user opts into the new "System Dictionary" entry under
Settings → Languages → Dictionaries. The setting is exclusive: enabling
it disables all other providers (and vice versa) so the in-app lookup
button either always opens the popup or always invokes the OS — no
mixed states.
Per platform:
- macOS: AppKit's -[NSView showDefinitionForAttributedString:atPoint:]
via a top-level Tauri command in src-tauri/src/macos/system_dictionary.rs.
Anchored at the selection's bottom-center (CSS pixels mapped into
NSView coords), so the inline Lookup HUD appears just below the
highlighted text without raising Dictionary.app to the foreground.
- iOS: UIReferenceLibraryViewController presented as a half-detent
pageSheet on iPhone (medium → large drag-to-expand) and as a
formSheet on iPad. Implemented in the native-bridge plugin.
- Android: ACTION_PROCESS_TEXT intent with EXTRA_PROCESS_TEXT_READONLY,
dispatched without createChooser so users get the standard system
disambiguation dialog with "Just once / Always" buttons. Reports
unavailable=true when no app handles the intent so the TS layer can
silently skip rather than open an empty chooser.
Web/Linux/Windows hide the row entirely. The provider is a sentinel —
the registry filters it out of the popup tab list (it has no in-popup
UI) and the annotator's handleDictionary checks isSystemDictionaryEnabled
to dispatch directly to the native bridge before opening the in-app
DictionaryPopup.
* ci(e2e): cache Playwright browsers and apt packages
- cache `~/.cache/ms-playwright` keyed on the lockfile; on a hit only
the OS deps are installed, skipping the browser download
- cache apt archives in test_web_app, matching the rust/tauri jobs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: enable Turbopack persistent cache and key it for cross-PR reuse
Enable `experimental.turbopackFileSystemCacheForBuild` so `next build`
persists a real Turbopack cache (~640 MB at `.next/cache/turbopack`),
instead of the ~340 KB of metadata the previous `.next/cache` cache
held. Dev caching is already on by default in Next 16.1+.
Redesign the cache keys so they actually pay off:
- drop `${{ github.sha }}` from the key — it made every commit a unique
entry that no other PR could exact-hit. The key is now
`turbo-<mode>-<target>-<os>-<lockfile-hash>`, deterministic across
branches, so every PR restores the same entry (in practice the one
`main` last saved — the only cache sibling PRs can all see).
- `build_web_app` (`next build`) caches `.next/cache`;
`build_tauri_app` (`next dev`) caches `.next/dev/cache` — `next dev`'s
Turbopack cache lives in a different directory.
- drop the Next.js cache step from `test_web_app`; it runs no build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(e2e): add Playwright web e2e lane
Adds a web-layer end-to-end suite that drives the Next.js web build
(`pnpm dev-web`) in a real browser, complementing the existing
WebdriverIO suite that drives the Tauri shell.
- playwright.config.ts: single Chromium project, auto-starts dev-web
- e2e/pages: BasePage/LibraryPage/ReaderPage page objects
- e2e/fixtures/base.ts: suppresses demo-book auto-import for a
deterministic empty library
- e2e/tests: library shell + search, book import, reader open +
pagination smoke specs
- e2e/fixtures/books: synthetic sample book for import tests
- scripts: test:e2e:web, test:e2e:web:ui, test:e2e:web:report
Tests run unauthenticated against isolated browser contexts;
authenticated/sync flows are out of scope until a test account is
provisioned.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(e2e): cover reading and annotation flows
Expands the Playwright web e2e lane beyond library/import smoke tests to
exercise the major reading and annotation features against the real
sample-alice.epub fixture (src/__tests__/fixtures/data/).
Reading (reading.spec.ts): open + page turn, TOC chapter navigation,
in-book search, font-size change via the settings dialog, bookmark
toggle.
Annotation (annotation.spec.ts): selection popup, create highlight,
change highlight color, add a note, delete an annotation.
- ReaderPage POM gains sidebar/TOC, search, settings, bookmark and
annotation actions; text selection is driven inside the section
iframe (synthetic drags do not produce a selection through nested
paginated foliate iframes)
- openBook fixture imports and opens a book so specs skip boilerplate
- books.ts centralises fixture book paths
- replaces the old reader.spec.ts smoke
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(e2e): add headed run script and always write HTML report
- test:e2e:web:headed runs the suite in a visible browser, one test at
a time, with traces captured
- the HTML reporter now runs for local runs too, so every run writes
playwright-report/ for test:e2e:web:report to open
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(e2e): fix headed-run flakes in reading and annotation specs
The headed run (slower rendering) surfaced two races that the headless
run happened to pass:
- TOC navigation read reading progress before the section's async
progress update landed — now polls with expect.poll.
- visibleSectionFrame required a paragraph fully inside the viewport,
which intermittently matched nothing — now accepts any paragraph
intersecting the viewport and tolerates frames detaching mid-navigation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: run the Playwright web e2e suite in test_web_app
Adds `pnpm test:e2e:web` to the test_web_app job, after the unit/browser
tests. The job already installs the Chromium browser, and `.env.web` is
committed so the auto-started `pnpm dev-web` server has its config. On
failure the HTML report is uploaded as an artifact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(e2e): exclude e2e specs from the vitest run
vitest's default glob matches `*.spec.ts`, so it picked up the new
Playwright `e2e/tests/*.spec.ts` files and crashed. Exclude `e2e/`
from vitest — those specs run via `pnpm test:e2e:web`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(e2e): run the web e2e suite against a production build
`next dev` renders a full-screen error overlay when the app emits its
`next-view-transitions` "Transition was aborted" unhandled rejection,
and the overlay intercepts pointer events — making the suite flaky on
CI. CI now builds the web app (`pnpm build-web`) and the Playwright
webServer serves it via `pnpm start-web`; local runs still use
`pnpm dev-web`. Verified: 14/14 pass against the production build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(e2e): run the web e2e suite in the build_web_app job
build_web_app already runs `pnpm build-web`, so the e2e suite belongs
there — it reuses that build (the CI Playwright webServer serves it via
`pnpm start-web`) instead of building a second time in test_web_app.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(e2e): run the web e2e suite with 4 workers
Specs are isolated (a fresh browser context per test), so they are
safe to parallelize. `test:e2e:web:headed` keeps --workers=1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(backup): include global settings in backup zip
Backup zips previously held only book files and library.json. Issue
#4098 asks for app configuration to be backed up too.
A `settings.json` snapshot is now written at the zip root. Restore
deep-merges it onto the current device's settings, so fields the
snapshot omits keep their current values.
`sanitizeSettingsForBackup` strips, via a blacklist, fields that are
device-specific or sync/migration bookkeeping (filesystem paths,
replica/kosync device ids, sync cursors, lastOpenBooks, screen
brightness, schema versions). Account credentials (kosync/Readwise/
Hardcover tokens, AI gateway key, OPDS catalog logins) are stripped
unless the user opts in via a new "Include account credentials"
checkbox in the Backup & Restore dialog — the zip is unencrypted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(backup): keep revived books visible after a cloud-synced restore
When the library is deleted (soft delete) and the deletion has synced
to the cloud, restoring an older backup un-deletes the books locally —
but the next sync's last-writer-wins merge re-applied the cloud's
deletion tombstone, so the restored books vanished again.
The deletion never bumps `updatedAt`, so a restored book and its cloud
tombstone share the same timestamp; `processOldBook` breaks the tie
toward the cloud.
`reviveRestoredBooks` now fixes up books that were soft-deleted locally
but present in the backup:
- Bumps `updatedAt` so the restore out-ranks the cloud tombstone. A
single uniform offset is applied to every revived book, so their
relative order — and the library's "Updated" sort — is preserved
exactly; the newest maps to now, none land in the future.
- Clears `syncedAt` so the next push re-uploads them and corrects the
cloud rows.
- Restores `downloadedAt` / `coverDownloadedAt` from the backup record
(the local deletion had cleared them) so revived books are not shown
as not-downloaded even though their files were re-extracted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The window-level `overrideUserInterfaceStyle` applied by
`set_system_ui_visibility` pins the WKWebView's trait collection, so the
`prefers-color-scheme` media query never fires while the app stays
foregrounded and `get_system_color_scheme` returned the stale pinned
value. Detect appearance at the window-scene level instead — it sits
above the per-window override — and push changes to JS via
`window.onNativeColorSchemeChange`.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The library sync lane (useBooksSync) only runs while the library page is
mounted. While a reader stays open on one device, in-reader auto-sync
pushes `configs` but never re-pushes the `books` row, so other devices'
library pull-to-refresh keeps showing stale reading progress until the
source reader is closed.
useProgressSync.pushConfig now also forwards the in-memory library Book
through the books lane after pushing the config. useProgressAutoSave has
already merged config.progress into that Book via saveConfig, so the
books push carries the up-to-date progress.
Fixes#4198
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The TXT-to-EPUB segment regex splits on dash dividers (`-{8,}`), which
authors commonly use as in-chapter scene breaks. Each heading-less section
after such a divider was emitted as its own chapter — a numbered paragraph
fallback chapter, or a chapter titled after a stray sentence — flooding the
generated TOC with entries that aren't real chapters.
Mark chapters with whether their title came from a detected heading, and
merge heading-less chapters into the preceding detected chapter instead of
pushing them as separate TOC entries. Fully heading-less text still chunks
into numbered fallback chapters as before.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OPDS servers that allow anonymous access (e.g. Calibre-Web) return 200
without a WWW-Authenticate challenge. `fetchWithAuth` only attached
credentials on a 401/403 retry, so a user who configured valid login
details kept seeing guest-only content (own shelves missing).
Send a Basic Authorization header on the first request whenever
credentials are available. Digest auth still falls through to the
challenge-driven retry since it can't be sent preemptively, and the
retry is skipped when it would just repeat the preemptive Basic header.
Fixes#4202
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sync-conflict dialog had two issues with servers other than KOReader
(e.g. Kavita's KOReader-compatible sync endpoint):
- "This device" preview rendered a bare "undefined" because reflowable
books built the string from `sectionLabel`, which is empty for spine
items with no matching TOC entry. It now falls back to the page count.
- Choosing "use remote" closed the dialog but never moved the reader:
`applyRemoteProgress` only knew how to navigate via CREngine XPointers,
so non-XPointer progress strings were silently ignored. It now falls
back to `view.goToFraction` using the reported percentage.
Also fixes the section-title indentation in the dialog (SectionTitle
bakes in `ps-4`, which misaligned the labels against their values).
Closes#4200
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The case-mismatch EPUB fixture builds an archive with @zip.js/zip.js' BlobWriter and then wraps the resulting Blob into a File:
const blob = await writer.close();
new File([blob], 'case-mismatch.epub', ...);
Under vitest's happy-dom/jsdom, the File/Blob polyfill does not correctly pull bytes out of a nested Blob part produced by zip.js. The outer File reports a non-zero size, but the bytes BlobReader sees in DocumentLoader (libs/document.ts: 'new BlobReader(this.file)') are not a valid ZIP — getEntries() yields nothing, open() falls through with book = null, and the test crashes at:
TypeError: Cannot read properties of null (reading 'sections')
Materialize the zip bytes into a plain ArrayBuffer first, then construct the File from that. ArrayBuffer parts go through the polyfill cleanly because they don't require recursive Blob unwrapping, so zip.js reads a real archive and the test passes:
const arrayBuffer = await blob.arrayBuffer();
new File([arrayBuffer], 'case-mismatch.epub', ...);
This brings the fixture in line with the rest of the test suite (paginator-expand, page-progress-epub, toc-cfi-mapping, ...) which already use ArrayBuffer-based File construction. No production code is affected: real browsers handle nested-Blob File construction correctly.
* feat(reader): add RSVP CJK character mode and whole-word highlight, closes#4131
Add two CJK-only options to the RSVP overlay settings row:
- Character Mode: split CJK text per-character instead of by jieba/Intl
word segmentation, restoring one-character-per-flash reading.
- Highlight Word: render a CJK word as a single centered, fully-colored
span, fixing the focus-only highlight and even-length left-shift.
The focus point now skips trailing CJK punctuation so tokens like "是。"
highlight the character, not the punctuation. Both toggles appear only
for sections that contain CJK text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* i18n: extract RSVP CJK character mode and highlight word strings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reader): import annotations from Moon+ Reader (.mrexpt)
Add a new menu entry under the reader sidebar 'More' menu that lets users import highlights and notes exported from the Moon+ Reader Android app.
Implementation:
- utils/mrexpt.ts: parser for the .mrexpt plaintext format (entry id, NCX navPoint index b4, character offset b6, type marker, word and note).
- services/annotation/providers/mrexpt.ts: convert mrexpt entries to BookNote[] using bookDoc. Locate the chapter via b4 -> toc -> spine, then TreeWalker-search the section DOM for the highlighted word with English suffix tolerance (ing/ed/s/...). Falls back to a section-level CFI when the exact word can't be located. Re-imports are deduplicated by a stable id derived from entryId.
- BookMenu: add 'Import from Moon+ Reader' menu item dispatching the 'import-mrexpt' event.
- Annotator: handle 'import-mrexpt' — pick the file (Web File / Tauri path), parse, convert against the live bookDoc, merge into booknotes (latest updatedAt wins), persist via saveConfig, and apply to all live views so highlights appear immediately. User feedback via toasts (importing / imported N / N unmatched / nothing new).
* refactor(reader): simplify Moon+ Reader import notifications
Reworks the .mrexpt import UX so it shows exactly one toast per run
instead of up to two, and removes redundant intermediate notices.
- Drop the intermediate "Importing N annotations…" toast. The toast
system shows one toast at a time, so it merely flashed and was
replaced by the result toast.
- Drop the duplicate "Failed to read the selected file." toast in the
read catch block; it falls through to the existing empty-content
check which surfaces the same message.
- Collapse the three-way result toast (already imported / N unmatched /
N imported) into one: "Imported {{count}} annotations" or
"No new annotations to import".
- Fix a result-message bug: when every converted note was already
imported and nothing was unmatched, the toast read "Imported 0
annotations." It now reports "No new annotations to import".
- Pluralize the success message via i18n `count` (the previous `{{n}}`
placeholder never pluralized, e.g. "Imported 1 annotations").
- Extract the dedupe/merge logic into a pure, unit-tested
`mergeImportedBookNotes` helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(i18n): translate Moon+ Reader import strings
Run i18next extraction and translate the new .mrexpt import strings
across all 33 locales (340 keys). The import feature added in this PR
introduced translatable strings that had not yet been extracted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(readwise): allow overriding the Readwise sync base URL
Add an advanced option to point Readwise sync/export at a custom,
Readwise-compatible endpoint instead of the hardcoded official API.
When the override is unset or blank, behavior is unchanged.
- ReadwiseClient resolves a custom `baseUrl` over `READWISE_API_BASE_URL`,
trimming whitespace and trailing slashes.
- ReadwiseSettings gains an optional `baseUrl` field; it syncs as
plaintext via the settings sync whitelist.
- ReadwiseForm exposes the URL under a collapsed "Advanced" disclosure
on the connect screen, and surfaces a custom URL read-only once
connected. Disconnect preserves the custom URL for easy reconnect.
Closes#4114
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* i18n(readwise): rename "Sync Base URL" label to "Custom URL"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A touch-surface mouse like the Magic Mouse emits a flood of tiny, low-
magnitude wheel events — plus an inertial momentum tail — for a single
physical gesture, and even a light brush of the surface produces spurious
deltas. The previous 100ms trailing debounce collapsed bursts but did not
filter by magnitude, so isolated micro-touches and the momentum tail each
turned a page, cascading into continuous accidental page turns in
paginated mode.
Add a wheel gesture detector that accumulates normalized wheel travel and
only flips once it crosses a deliberate-intent threshold, then swallows the
rest of the stream (the momentum tail) until the wheel goes idle — so one
physical gesture flips exactly one page, mirroring native readers.
Closes#4117
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pull skipped notes carrying a deleted_at tombstone but never removed the
matching local annotation. A highlight deleted on Readest therefore lingered
in KOReader, and a later push (notably a full sync) re-uploaded it,
resurrecting the note on the server and making it reappear on every device.
Add removeDeletedAnnotations, invoked at the start of the pull callback, to
drop local annotations the server has tombstoned. Tombstones are matched by
stored id, by the hash-derived id for native KOReader highlights, or by
position/page xpointer.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Footnotes/endnotes are hidden in the rendered page via `display: none`,
but TTS builds its blocks from its own document. For background
sections that document is raw XHTML loaded via `section.createDocument()`
without the page layout styles, so the footnotes were read aloud.
- `createRejectFilter` gains an `attributeTokens` option to match
`aside[epub:type~="footnote|endnote|note|rearnote"]` (value-token
match, like CSS `[attr~="x"]`), so footnotes are detectable on raw
documents that lack the `epubtype-footnote` class.
- `TTSController` adds the footnote selectors to its reject filter.
- `getBlocks()` (foliate-js) skips the subtree of any block-level
element the node filter rejects, ending the preceding block before
it so footnote text doesn't leak in.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A double-click selection can carry trailing whitespace and most imported
dictionaries store headwords lowercased, so an exact match on the raw
selection often misses (e.g. `Hello` or `world ` fail to resolve
`hello`/`world`). Case-sensitive formats like mdict are hit hardest since
their reader compares the raw word.
Seed the lookup history with a trimmed word and try ordered query
variants (trimmed, lowercase, title-case, uppercase) per provider,
keeping the first hit. Closes#4176.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Custom fonts vanished from the Font panel after an app restart unless a
book was opened first. The custom-font store is hydrated only by the
reader's FoliateViewer (on book open) or by useReplicaPull (gated on a
signed-in user), so opening Settings straight from the library left the
store empty.
Add a useCustomFonts hook that loads persisted custom fonts on mount,
unconditional of auth or book state, and mount it on the library page.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OPDS responses were classified as XML vs JSON with `text.startsWith('<')`.
Some servers (e.g. the Hungarian MEK catalog) return a valid Atom feed
prefixed with newlines/whitespace before `<feed>`, no `<?xml?>`
declaration, and a wrong `text/html` Content-Type. The naive check missed
the `<`, so the XML body was handed to `JSON.parse`, failing with
"Unexpected token '<' ... is not valid JSON".
Add a shared `looksLikeXMLContent()` helper that trims leading whitespace
(also stripping a UTF-8 BOM) before the check, and use it in both
`loadOPDS` and `validateOPDSURL`. Detection is now based purely on the
body, so formally-valid feeds with a bad Content-Type work.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
saveConfig refreshed config.updatedAt by mutating the config object in
place. That only worked because every reader view shares one config
object reference, and it bypassed Zustand change-detection entirely.
Refresh updatedAt via an immutable setConfig store update instead, so it
no longer depends on callers sharing the same reference, notifies
subscribers, and never mutates the caller-provided object. Sync behavior
is unchanged.
Refs #4184
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): use position absolute for skip-next-section link to prevent blank page
* fix(a11y): nest next-section skip link inside last content element
position:absolute alone does not fix the blank-page bug: a full-page
illustration wrapper commonly carries `column-break-after: always`, and
the skip link's static position after that break still renders in a
fresh, blank column. Nest the link inside the deepest last content
element so it shares the final content column, while remaining the last
node in document order for NVDA's virtual cursor. Also use left:auto so
it keeps its static position instead of pinning to the viewport edge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: leehuazhong <longsiyinyydds@gmail.com>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a "Clear Annotations" item to the book menu. Picking it opens a confirm dialog and, on confirm, soft-deletes every type='annotation' booknote on the active book by stamping deletedAt, removes overlays from live views, persists via saveConfig, and resets sidebar browse state. Bookmarks and excerpts are untouched. The dialog lives in Annotator (per-book, long-lived) and is wired up via a new 'clear-annotations' event so it survives the dropdown menu unmounting.
* feat(reader): add custom hardware-button page turning (#4139)
Lets users bind hardware remote keys (media keys, D-pad/arrow keys) to
previous/next page via a learn-mode capture UI in reader settings — an
accessibility feature for page-turner remotes.
- New global hardwarePageTurner system setting (enabled + key bindings).
- hardwareKeys.ts: key normalization, matching, and page-turn resolution.
- deviceStore: reference-counted media-key interception + learn mode.
- usePagination: flips pages from bound media keys (native bridge) and
D-pad/keyboard keys (DOM keydown), scoped to the active book and
suppressed while the toolbar is visible.
- Page Turner settings section on all platforms; web/desktop bind keys
via DOM keydown only, native media-key interception stays mobile-only.
- Android: intercept media + learn-mode keys in dispatchKeyEvent.
- iOS: forward media keys via MPRemoteCommandCenter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(i18n): add and translate hardware page turner strings (#4139)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reader): refine hardware page turner (#4139)
- Handle book-iframe key events (iframe-keydown messages) so custom
bindings work as soon as a book is open, not only after the settings
panel has been shown.
- Add Previous/Next Section bindings alongside the page bindings.
- Rename the hardwareKeys util to keybinding.
- Wire the Page Turner section into the settings Reset action.
- Drop the focus ring on the capture buttons; BoxedList gains an
optional description.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(i18n): translate page turner section and key strings (#4139)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The smooth-wheel feature (#3974, closing #3966) intercepts mouse-wheel
events in scroll mode: it makes the wheel listener non-passive,
preventDefault()s the native scroll, and replays the delta through a
main-thread rAF animation against the renderer container.
That regressed normal mouse scrolling on Windows (#4130): fast wheel
bursts were discarded entirely, and the JS replay is structurally worse
than native scrolling -- a non-passive wheel listener forces every wheel
event (mouse and trackpad) off the compositor thread, and the
postMessage hop plus main-thread animation add latency and jank that
native compositor scrolling does not have.
High-resolution scrolling (e.g. Logitech MX Master, the mouse in #3966)
needs no special API: the OS/driver just delivers regular wheel events
with smaller, more frequent deltas, and the browser scrolls them
natively. #3966's own report ("smooth scrolling works with all
applications apart from yours") points at the interception, not a
missing capability. Restore native wheel scrolling in scroll mode.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Edge TTS websocket requests fail intermittently, and a single transient
failure during preload silently dropped the cached audio chunk, which
could stall playback. Add a #createAudioUrlWithRetry helper that retries
createAudioUrl up to 3 attempts with a short backoff, bailing early when
the abort signal fires. Both the immediate and background preload paths
in speak() now use it.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browsing a folder in the Readest Library spawns a forked child per
cloud cover via syncbooks.downloadCover. On Boox / Adreno devices the
child crashed with a SIGSEGV (issue #4165): it terminated through the
libc exit() path, and __cxa_finalize ran the destructor of the GL
driver inherited from the parent, which segfaults on Adreno.
Terminate the child with ffi.C._exit(0) instead. _exit() skips libc
atexit handlers, so __cxa_finalize — and the Adreno destructor it runs
— never execute. The body is also wrapped in pcall so a network error
in http.request cannot unwind past that _exit call.
This eliminates the child-side crash in the reported tombstone. The
parent KOReader exiting is most likely a knock-on effect of the child
tearing down GPU state shared across the fork, but that link is not
provable from the log alone — so this intentionally does not
auto-close #4165 until confirmed on an affected device.
No unit test: the fork + network path isn't reproducible in the busted
harness, consistent with the other network methods in this file.
Remove the redundant "Apply also in Scrolled Mode" options for bars and
margins so scrolled mode renders the header/footer consistently with
paginated mode: transparent, fixed in position, and not obscuring content.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(koplugin): pull before push so sync doesn't wipe cloud book fields, closes#4138
The Library sync pushed a touched book row before pulling, so a row
still missing the cloud's uploaded_at / metadata / group_id (e.g. one
created by lightScan, not yet merged from a cloud pull) was sent with
those fields nil. The server's transformBookToDB explicit-nulls
uploaded_at and metadata for any field absent from the wire payload,
wiping the cloud copy — after which every device that pulled lost the
book's upload state.
syncBooks("both") now pulls first, then pushes, and takes a before_push
callback. syncBooksLibrary passes touchOpenBook through it so the
open book's updated_at bump lands after the pull has refreshed the
local row, letting the push carry the preserved cloud fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(koplugin): hide Library books with neither an uploaded nor a local file
The Library showed any row with cloud_present = 1, but a bare cloud
*record* whose file was never uploaded (uploaded_at NULL) has no cover
and can't be opened — showing it is meaningless. Tighten the visibility
predicate to (uploaded_at IS NOT NULL OR local_present = 1) across
listBooks, getGroups, listBookshelfGroups and listBooksInGroup.
This mirrors Readest, which only adds a synced book to the library when
uploadedAt is set and keeps locally-imported books that carry a
downloadedAt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(koplugin): close the Library widget when opening a book
Opening a book from the Library called ReaderUI:showReader without
closing the Library Menu, so it stayed in the UIManager widget stack
with M._menu still set. A later background M.refresh() — a cloud-sync
or cover-download completion — then repainted that ghost Library over
the reader, making it flash on screen for a few seconds.
Add M.close(); route the title-bar X, M.reopen() and both handleTap
book-open paths through it. A wrapped onCloseWidget clears M._menu on
every close path, so M.refresh() no-ops once the Menu is gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When syncing highlights between Readest and KOReader, the `note` field
was forced to an empty string (`""`) for annotations and bookmarks
without notes. KOReader's native annotations omit the field entirely
when no note exists, so the empty string caused KOReader to treat
every synced highlight as having a (blank) note. Apply the same
omission in both push and pull directions.
Co-authored-by: Claude <noreply@anthropic.com>
The TypeScript types in `src/types/opds.ts` declared fresh
`Symbol('content')` / `Symbol('summary')` instances. foliate-js's
`opds.js` declared its own distinct ones, and since Symbols are unique
per call, `metadata[SYMBOL.CONTENT]` always returned undefined — even
though the parser had written the value under a same-named Symbol.
This broke silently in 0.11.1 after foliate-js #14 stopped also setting
a plain `content: string` fallback. For OPDS 1.x feeds (e.g. CWA) the
book description lives in `<entry><summary>`, which foliate-js exposes
only via `[SYMBOL.CONTENT]` — so the description vanished.
Re-export the SYMBOL from foliate-js so consumers read the same Symbol
identities the parser writes.
In Tauri mobile dev the page origin doesn't match the dev server, so
Next.js's `getSocketUrl` builds an unreachable HMR URL (`wss://localhost`
on iOS, `ws://tauri.localhost` on Android), the HMR client never connects,
and the page stays blank.
Inject a tiny script in `<head>` (dev + Tauri only) that subclasses
`window.WebSocket` and rewrites the broken URL to the actual dev server.
`TAURI_DEV_HOST` is forwarded from the build env so `pnpm tauri {ios,android}
dev --host <ip>` also routes HMR through the LAN address.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
closes#4140
The bare-numeric-text heuristic added in #3894 to detect non-superscript
footnotes (`/^.{0,2}\d+$/` over `anchor.textContent`) was too permissive:
in-book TOCs that list chapter/verse links such as `<a>1</a>, <a>2</a>, ...`
all match the regex, so clicking them sets `check=true` and the footnote
handler renders the destination as a popup instead of letting the link
navigate. The OSB v2 verse-index and OSB v4 chapter-index from the bug
report both hit this.
Reject the `check` heuristic when the clicked link sits inside a numeric
link list (2+ sibling links with the same short-numeric pattern within
three ancestor levels). A real body paragraph with a couple of footnote
markers still passes; a flat TOC of numeric links does not.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When TTS playback crosses a section boundary, the page would stay on
the last page of the previous chapter while audio continued reading
the next chapter — leaving the user stuck behind the "back-to-TTS"
button.
Two compounding issues since the paginator adjacent-section preloading
landed:
1. `handleSectionChange` called `view.renderer.goTo(resolved)` without
awaiting. `TTSController.#initTTSForSection` does
`await this.onSectionChange?.(sectionIndex)` precisely so the view
can finish navigating before audio of the new section starts, but
the missing await defeated that contract.
2. `handleHighlightMark` returned silently on a cross-section
mismatch (`viewSectionIndex !== ttsSectionIndex`), so when the
renderer.goTo above completed only partially — which can happen on
the new paginator when the target section is already loaded as an
adjacent view and the post-goTo state appears reused without a
visible page flip — there was no second chance to drag the view to
the TTS cfi.
Fix:
- Await `view.renderer.goTo` in `handleSectionChange`.
- In `handleHighlightMark`, run the cross-section branch *before* the
`followingTTSLocationRef` check and call `view.goTo(cfi)` directly,
stamping `sectionChangingTimestampRef` so the back-to-TTS button
stays suppressed while progress.location catches up. Skip only when
the user is actively selecting text.
Adds unit tests covering both the cross-section navigation path and
the in-section scrollToAnchor path.
* feat: add default ruby rt styles with user-select: none
* fix: prevent furigana text from being copied via ruby transformer
* fix: register ruby transformer in FoliateViewer pipeline; use span wrapper for reliable ::before rendering
* refactor(reader): simplify furigana copy exclusion
Drop the ruby transformer and the .rt-text::before pseudo-element
wrapping. Instead, pass ['rt'] to getTextFromRange unconditionally so
furigana is excluded from annotator/translation/copy text extraction,
and let `rt { user-select: none }` handle the native selection cursor.
Avoids DOM rewriting and HTML-entity round-tripping in the data-text
attribute, and keeps <rt> text in the DOM for TTS, in-page find, and
screen readers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add tauri-plugin-webview-upgrade as a git submodule under
apps/readest-app/src-tauri/plugins/. On Android devices whose system
WebView is locked to an old Chromium build (Huawei phones, Moaan / Onyx
/ Kobo e-ink readers, AOSP forks without Play Store, etc.), the reader
bundle renders as a blank screen. The plugin bootstraps before
Application.onCreate via androidx.startup and redirects the in-process
WebView loader to a recent com.google.android.webview when the user has
one sideloaded — opening the only window in which WebViewUpgrade can
swap the provider, before Tauri/Wry creates any WebView.
Thresholds (minUpgradeMajor / minSupportedMajor) come from
plugins.webview-upgrade in tauri.conf.json and are baked into Kotlin
constants at Gradle build time. Below the supported threshold with no
upgrade option, the plugin shows a localized AlertDialog (15 languages,
English fallback) prompting the user to install Android System WebView.
Plugin source: https://github.com/readest/tauri-plugin-webview-upgrade
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Annotations and bookmarks inserted into KOReader via the Readest sync
plugin were missing the chapter field, which native KOReader highlights
stamp at creation time. Downstream tools that group highlights by
chapter (e.g. obsidian-koreader-highlights, KOReader's own Markdown
exporter) treated these as orphans.
Resolve the chapter title from the xpointer using the same TOC call
that ReaderHighlight uses natively, and include it on both annotation
and bookmark item tables.
Closes#4133
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
||{printf'\nERROR: Required git submodules are not initialized in the source directory.\nEnsure submodules are initialized before running docker build.\nRun: git submodule update --init packages/foliate-js packages/simplecc-wasm\n\n';exit 1;}
RUN pnpm --filter @readest/readest-app setup-vendors
- [Issue #4112 scroll-anchoring](issue-4112-scroll-anchoring.md) — RESOLVED (PR #4349). Scroll-anchoring suppressed at scrollTop 0 when prepending a section in scrolled mode; fix patterns (prepend compensation, eager backward preload, no-blank nav) + test & dev-server gotchas
- [Reading ruler line/column-aware](reading-ruler-line-aware.md) — ruler snaps to real lines; multi-column band spans one column; Range.getClientRects() returns tall block boxes that must be dropped; iframe frame-offset mapping; synthetic-key throttling
- [TOC expand + auto-scroll](toc-expand-and-autoscroll.md) — #4059 collapse-by-default policy in `tocTree.ts`; pinned-sidebar mounts before progress → dynamic expansion breaks scroll-to-current via (1) spurious onScroll clearing pending and (2) Virtuoso scrollToIndex landing short after row growth (re-assert on rAF)
- [BooknoteView auto-scroll (#4352)](booknote-view-autoscroll-4352.md) — virtualizing the annotation/bookmark list dropped auto-scroll-to-nearest; two paths (reload: OverlayScrollbars resets scrollTop → re-apply in `initialized` via ref; tab-switch: synchronous scrollToIndex on fresh-mounted list wedges Virtuoso → use `initialTopMostItemIndex` + skip-gate). Mirrors TOCView. Includes dev-server/Fast-Refresh/screenshot-vs-DOM verification gotchas
- [Swipe page-turn bg flash](paginator-swipe-bg-flash.md) — white↔black flash on swipe+animation only; `#background` was static screen-space and didn't track content during drag/snap; fix = sliding per-view full-bleed segments (`computeBackgroundSegments`) rebuilt on scroll + per-rAF synced to the view transform during snap
- [Duokan fullscreen cover hidden in scroll mode](duokan-fullscreen-cover-scroll.md) — #4379`data-duokan-page-fullscreen` cover pinned `position:absolute height:100%` collapses against auto-height scroll container; gate fullscreen on `this.#column` + reset stale absolute props on toggle (`setImageSize` in paginator.js)
- [Paginated texture occlusion](paginated-texture-occlusion-4399.md) — #4399 host `.foliate-viewer::before` texture absent in paginated (shown in scrolled); opaque `#background` container (`= fallbackBg`) from the swipe-flash fix occludes it; shared `textureAwareBackground` helper + `hasTexture ? '' : fallbackBg` container
- [Background overflows column (#4394, PR #4429)](paginator-gutter-bleed-asymmetry-4394.md) — paginated page bg stretched into the outer `--_outer-min` gutter → mixed cover/title 2-up spread shifted off-centre (~250px at 1920px). KEEP the grid (`--_outer-min` keeps margins symmetric); fix = clamp `computeBackgroundSegments` to `[containerStart,containerEnd]` (Math.max/Math.min) so bg stays in its column. 2 wrong tries first (bleed-gating, "page shouldn't be yellow"); foliate submodule needs dev-server RESTART to pick up edits
## Critical Files (Most Bug-Prone)
-`src/utils/style.ts` - Central EPUB CSS transformation hub (14+ bug fixes)
- [KOSync CFI spine resolution](kosync-cfi-spine-resolution.md) — convert via the CFI's own spine (`getXPointerFromCFI`/`getCFIFromXPointer`), never `new XCFI(primaryDoc, primaryIndex)`; primaryIndex lags during scroll → spine-mismatch throw
- [Empty-start CFI sync bug](empty-start-cfi-sync.md) — `epubcfi(/6/24!/4,,/20/1:58)` (empty-start range) from the cfi-inert skip-link transitional window; jumps to wrong section end; `isMalformedLocationCfi` → discard the synced value in `useProgressSync` (NOT the local open path); foliate fix doesn't repair already-synced values
- [Custom fonts disappear on cloud sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins: re-import-after-delete needs a `reincarnation` token or the pull re-applies the tombstone; `addFont`/`addTexture` minted none; fix mirrors dictionary (both cases) + OPDS token style; coverage matrix per kind
## Testing
- [Tauri Rust↔JS parser parity tests](tauri-parser-parity-tests.md) — #4369 native Rust EPUB/MOBI parser; how to cross-check vs foliate-js in the `.tauri.test.ts` WebView suite (CWD disk path for Rust, Vite URL for JS, normalizer-based compare, cover presence-only, desc whitespace-collapse); the `dcterms:modified`→`published` divergence fix
## Build & Vendoring
- [pdfjs vendor wasm decoders](pdfjs-vendor-wasm-decoders.md) — scanned PDFs blank in CI build only (0.11.2 regression); pdfjs 5.7.x moved JBIG2 to `jbig2.wasm`, `copy-pdfjs-wasm` allow-list dropped it; `cpx` no-errors on empty glob; local stale `public/vendor` (gitignored, not refreshed by `tauri build`) masked it; fix = copy `wasm/*`
## Platform Compat
- [Window-state sanitizer (#4398)](window-state-sanitize-4398.md) — Windows launch crash (WebView2 0x80070057) from invalid `.window-state.json` (`-32000` minimized sentinel / `0×0`); our plugin already has upstream #253 fix so bad files are stale; defense-in-depth `window-state-sanitizer` plugin registered BEFORE window-state (plugin init = registration order); coord threshold `-16000` (~halfway to the -32000 sentinel; real desktops sit a few thousand px off origin) keeps multi-monitor negatives
## Feature Notes
- [Manage Cache + iOS container layout](manage-cache-ios-layout.md) — `'Cache'` base = `Library/Caches/<bundle>` only (not all of Caches); iOS `Documents/Inbox` cleared too; WebKit cache + tmp out of reach; never touch App Support
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / keyboard arrow navigation design, key files, and pitfalls
- [Share-a-Book Feature (in progress)](share-feature.md) — locked decisions for the /s/{token} share-link feature; plan at ~/.claude/plans/ok-we-will-learn-cosmic-acorn.md
- [readest.koplugin i18n](koplugin-i18n.md) — gettext loader at `apps/readest.koplugin/i18n.lua`, `.po` catalog at `locales/<i18next-code>/translation.po`, extract/apply scripts in `scripts/`
- [koplugin cover upload](koplugin-cover-upload.md) — #4374 uploadBook only shipped cached cloud covers; local-origin books uploaded blank. Fix = `extractLocalCover` via `FileManagerBookInfo:getCoverImage(nil, file)` → `writeToFile(path,"png")`. KOReader checkout at `/Users/chrox/dev/koreader`
## Patterns
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews
- [Design system → DESIGN.md](feedback_design_system_doc.md) — codify recurring UI/UX rules in `apps/readest-app/DESIGN.md`; never `pl/pr/ml/mr/text-left/text-right` (RTL); §5 boxed list anatomy has uniform `min-h-14` rows and chromeless controls
## Reader UI Fixes
- [Android image callout freeze](android-image-callout-freeze.md) — long-press `<img>` fires WebView native callout that collides with app touch handlers → whole-app freeze; `-webkit-touch-callout: none` doesn't inherit so put `.no-context-menu` on an ancestor of the image (`.no-context-menu img` rule in globals.css); seen on book covers (#4345) + image preview/zoom (#4420, `ImageViewer.tsx`)
- [ProgressBar focus-ring line (#4397)](progressbar-focus-ring-4397.md) — decorative `.progressinfo` footer was `tabIndex={-1}` → Android long-press focused it → stray content-width focus-ring line at the bottom every page; fix = drop tabIndex (role='presentation' must not be focusable); ffmpeg-the-video debugging + live-browser `:focus-visible` confirmation
- [Table dark-mode tint regression (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` color-mix tint in `getColorStyles` must stay gated on `overrideColor` (gate added #2377, removed #4055, re-broke → #4419); safe now that #4392 light-bg rewriters handle #4028 zebra legibility; SAME rule paints vertical-TOC `.space`/▉ spacer cells (▉ U+2589 = blank glyph, contours=0) → "spacing changes" symptom; both fixed by the gate
## Library Fixes
- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — un-awaited `Menu.append()` (async IPC) in `BookshelfItem.tsx` → context-menu items shuffle order every open (native only, invisible in jsdom); fix = single `await Menu.new({ items })` of ordered `MenuItemOptions`; order/inclusion extracted to pure `getBookContextMenuItemIds` for unit testing
- [TXT author recognition (#4390)](txt-author-recognition-4390.md) — 【】-titled Chinese web-novels show author missing/garbage; they're TXT→EPUB (title==full filename is the tell, check `txt.ts` not foliate-js); `extractTxtFilenameMetadata` only handled 《》 + greedy header capture grabbed metadata blobs; fix = `parseLabeledAuthor` for any filename + `isPlausibleAuthorName` guard
## Architecture Notes
- foliate-js is a git submodule at `packages/foliate-js/`
- Multiview paginator: loads adjacent sections in background, multiple View/Overlayer instances per book
@@ -34,6 +69,8 @@
- TTS uses independent section tracking (`#ttsSectionIndex`) decoupled from view
- Dropdown menus use `DropdownContext` (not blur-based) for screen reader compat
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md) — to suppress reader gestures from the app, use `{capture:true}`; the paginator registers bubble-phase doc listeners first (during `view.open()`)
- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) — app-bundle code (style.ts, iframeEventHandlers.ts) runs in top realm; `iframeEl instanceof Element` is ALWAYS false → guards silently drop all iframe elements (passes jsdom, dead in app). Duck-type `'closest' in target` instead. Bit PR #4391's touch routing + applyTableStyle dedupe
## Workflow
- [Test file filter](feedback_test_file_filter.md) — use `pnpm test <path>` without `--` to run a single file
**Fix Strategy:** Scope event handlers to the loaded section's index. Use unique IDs for SVG elements across overlayer instances. Minimize iframe DOM mutations during drag operations.
### 13. Whole-Field-Synced Flag Reaches an Unsupported Platform
**Pattern:** A setting is whole-field synced across devices (e.g. `dictionarySettings.providerEnabled`), so a flag enabled on one platform arrives `true` on a platform where that feature isn't supported. The lookup/runtime path correctly gates on platform support, but a *secondary consumer* (usually UI gating) reads the raw synced flag and misbehaves.
**Example:**
- System Dictionary enabled on macOS synced to web → web's `CustomDictionaries.tsx` locked all other dictionary toggles read-only (`lockedBySystem`) even though System Dictionary is hidden + a no-op there. The annotator's lookup path used the platform-gated `isSystemDictionaryEnabled(settings)` (registry.ts, gates on `isSystemDictionarySupported()`), but the settings UI compared the raw `providerEnabled[systemDictionary] === true`.
**Fix Strategy:** Every consumer of a synced flag for a platform-specific feature must route through the *same* platform-aware gate the runtime uses — not the raw `providerEnabled[...]`/setting value. Here: `lockedBySystem = isSystemDictionaryEnabled(settings) && ...`. Search for other readers of the raw flag when fixing one.
## Debugging Workflow
1.**Identify the category** from the issue description
# #4410 — disappearing custom fonts when logged into cloud
**Symptom:** custom fonts vanish a few seconds after opening a book (or ~1 min idle) ONLY when logged into cloud sync; logging out fixes it; a brand-new never-deleted font is fine; problem starts after deleting a font / "Clear Custom Fonts" then re-uploading the same file.
**Root cause — CRDT remove-wins.** The replica sync (`src/libs/crdt.ts`, `src/libs/replicaInterpret.ts`) is remove-wins: once a row has a `deleted_at_ts` tombstone, a plain field upsert does NOT revive it. Only a `reincarnation` token whose effective HLC beats the tombstone revives it. `isReplicaRowAlive(row)` = `!deleted_at_ts || (reincarnation && updated_at_ts >= deleted_at_ts)`. In `mergeReplica` the reincarnation candidate's timestamp is the **row's `updated_at_ts`** (fresh on every upsert), NOT the token's mint time — so preserving an old token still revives, as long as the upsert carries it.
Flow that broke: import→delete writes a server tombstone (`publishReplicaDelete`). Re-upload same file → same `contentId` → `addFont` cleared `deletedAt` locally and called `publishFontUpsert` with `reincarnation = undefined` → server tombstone survives → next pull (boot 5s / periodic / book-open / visibility) sees `isReplicaRowAlive===false` → `softDeleteByContentId` → font disappears.
**Fix (PR for #4410):** in `addFont` (`src/store/customFontStore.ts`) and `addTexture` (`src/store/customTextureStore.ts`), when re-adding an existing entry, mint a reincarnation token (`Math.random().toString(36).slice(2)`, matching OPDS) when `!!contentId && !existing.reincarnation && (existing.deletedAt || existing.contentId === new.contentId)`; otherwise preserve `existing.reincarnation`. Covers both re-import-after-local-delete AND the stale-local race (local still live but another device tombstoned the row). Token is inert without a tombstone, so live re-imports are safe.
**Coverage matrix across collection kinds (all share the remove-wins replica):**
- Dictionary — handles BOTH cases (gold standard): `dictionaryService.ts importDictionaries` via `findTombstonedDictionaryMatches` + `shouldMintReincarnationForLiveReimport` (helpers in `dictionaries/dictionaryDedup.ts`), mints `uuidv4()`.
- OPDS — case 1 only: `customOPDSStore.addCatalog` (`existing?.deletedAt && !input.reincarnation`).
- Fonts / Textures — handled NEITHER → this bug. Now fixed to dictionary-parity.
Whole chain carries the token: returned font → `publishFontUpsert` upsert AND `queueReplicaBinaryUpload` → manifest publish (`replicaBinaryUpload.ts` uses `record.reincarnation`).
Note: `saveCustomFonts`/`saveCustomTextures` persist tombstoned (deletedAt) entries too, so the soft-deleted entry is still in the store at re-import time → the `existing.deletedAt` branch fires. (OPDS strips deleted at save; fonts/textures keep them.)
Issue #4379: an EPUB cover with `data-duokan-page-fullscreen` on `<html>` (Duokan/DangDang convention) shows in paginated mode but is **blank in scrolled mode**; only the first cover image, other images fine in both modes.
**Root cause** — `View.setImageSize()` in `packages/foliate-js/paginator.js` has a `pageFullscreen` branch that pins each img with `position:absolute; inset:0; width:100%; height:100%` and forces ancestors + `<html>` to `height:100%`/`position:relative`. This fills the page in paginated/columnized mode (html has a fixed pixel height). In scrolled mode `scrolled()` sets `html`/`body` height to `auto`, so the `height:100%` chain resolves to **0** and the absolutely-positioned cover collapses out of view (offsetHeight 0).
**Fix** — gate the fullscreen treatment on column mode: `const applyFullscreen = pageFullscreen && this.#column`. Use `applyFullscreen` for the max-height/max-width margin term and the `if` block. Add an `else if (pageFullscreen)` that `removeProperty`s the stale `position/inset/width/height/margin` on the img (and `width/height/margin/padding` on ancestors, `position` on html) so toggling paginated→scrolled doesn't leave the cover collapsed (same iframe/img is reused via `view.render(layout)` on `flow` change). In scrolled mode the cover then flows like a normal full-page image bounded by `max-height = availableHeight`.
**Key facts**
-`this.#column = layout.flow !== 'scrolled'` (set in `render()` before `setImageSize`), so it's reliable inside `setImageSize`.
- Foliate writes these styles as **inline `!important`** → cannot be overridden from `src/utils/style.ts`; the fix must live in the paginator.
- Regression test: `src/__tests__/document/paginator-duokan-cover.browser.test.ts` + fixture `repro-4379.epub` (cover xhtml with the duokan attr + dimensionless `<img>`). Asserts cover `img.offsetHeight > 0` in scrolled mode, paginated sanity, and paginated→scrolled toggle. Browser test (real layout) is required — jsdom can't compute the collapse.
description: "Invalid synced-progress CFIs like epubcfi(/6/24!/4,,/20/1:58) — the empty-start range bug from the cfi-inert skip-link, and the read-side normalizeLocationCfi sanitizer"
There are **three** independent touch-listener registrants on the foliate iframe `doc`:
1.`FoliateViewer.tsx` (~line 326) — passive forwarders that only `postMessage`.
2.`Annotator.tsx` (~line 332) — non-passive, drive text selection.
3.**foliate-js's own paginator** (`packages/foliate-js/paginator.js:1034`) — non-passive, **bubble-phase**, registered during `view.open()` (so *before* any app-level `load` handler). It can `preventDefault`, set `#touchScrolled`, `scrollBy`.
Consequence: a bubble-phase app listener registered "before the existing FoliateViewer listeners" **cannot**`stopImmediatePropagation` the paginator — the paginator already ran. Registration order only controls listeners within the same phase, and the paginator's are earlier regardless.
**Fix pattern:** register with `{ capture: true, passive: false }`. Capture-phase listeners on `doc` fire before all bubble-phase listeners when the event target is a descendant, so capture-phase `stopImmediatePropagation()` suppresses paginator + Annotator + FoliateViewer handlers alike. Scrolled mode also needs `preventDefault` from the first armed move (the paginator early-returns on `scrolled`, so native container scroll is what moves content).
Verified end-to-end for the [[brightness-swipe-gesture]] feature (test asserts a bubble-phase paginator stand-in never fires after a capture-phase `stopImmediatePropagation`). Both Codex and a Claude subagent independently confirmed against `paginator.js` during the /autoplan review.
**RESOLVED** — merged in readest/readest#4349 (foliate-js fix + submodule bump + browser tests). Kept as reusable paginator scroll-mode knowledge.
readest/readest#4112 — two scrolled-mode bugs, ONE root cause.
**Root cause:** Browser scroll-anchoring (`overflow-anchor: auto`, paginator.js container) is **suppressed when scrollTop === 0**. The multiview paginator preloads the *previous* section by inserting its View element **above** the current one (`#loadAdjacentSection`, sorted insertion in `#createView`). When that prepend happens while `scrollTop === 0`, the inserted section pushes the current content down with no scroll compensation, so the viewport ends up showing the previous section.
**Bug 1 (TOC backward jump → lands on n-1):** Reproduce by navigating *one section back* (target N-1 is an already-loaded adjacent view → `#goTo` "view already loaded" branch, NOT `#display`). `scrollToAnchor(0)` lands at scrollTop 0 with target as topmost view; ~250ms later the **debounced** backward-preload (paginator.js ~line 977) inserts N-2... wait, inserts the section before the target, at scrollTop 0 → suppression → viewport drifts to target-1. Intermittent (~1/3–2/3) because it races `#fillVisibleArea`'s reanchor. `primaryIndex` stays = target but the *visible* top section = target-1.
**Bug 2 (can't scroll up / jumps to beginning of prev section):** same suppression — prev section inserted above at scrollTop≈0 shifts viewport to the *beginning* of prev instead of staying put. Backward-preload is debounced-only (forward preload is eager/immediate) → asymmetry adds lag.
**FIX (landed on foliate-js branch `fix/scrolled-prev-prepend-anchor`, 2 changes in `#loadAdjacentSection` + `#goTo`):**
1. Manual scroll compensation at the single prepend choke point `#loadAdjacentSection`: when prepending in scrolled mode (`index < sortedViews[0]`), after `await view.load()`, set `#renderedStart` to `startBefore + addedSize`. `containerPosition += (#vertical ? -1 : 1) * correction`; no-op (correction≈0) when the browser already anchored at scrollTop>0. Fixes drift (Bug 1) + scroll-up-shows-beginning (Bug 2b).
2. The already-loaded `#goTo` branch only preloaded prev for *short* sections (`contentPages < columnCount`); changed to `needsPrev || this.scrolled` (+ `#isSameDirection` guard), mirroring `#display`. Fixes can't-scroll-up (Bug 2a) — the debounced backward-preload bails while `#stabilizing` after nav, so nav must preload prev itself.
**UX follow-ups (same branch, same file):**
3.**No blank flash on adjacent nav**: the already-loaded `#goTo` branch faded the container `opacity 0→1`; in continuous scrolled mode that flashed (worse after change 2 put the prev-load inside the blank window). Now `blank = !this.scrolled || this.noContinuousScroll` — continuous scrolled scrolls straight to the (already-rendered) target. `loadPrev` helper: paginated loads prev BEFORE the scroll (fill leading columns), scrolled loads it AFTER (instant transition; compensation keeps position).
4.**Eager backward preload**: removed the debounced, one-viewport-gated backward preload; added an eager one in the immediate scroll listener mirroring forward (`pagesBehind < minPages`, scrolled-gated). Fixes "scroll up dead-ends at top until you nudge down". Safe now because change 1 compensation handles position stability (the old "debounced to avoid cascade" reason is obsolete).
**Verified live + tests.** Scrolled regression tests live in `paginator-scrolled.browser.test.ts` (split out of the old `paginator-multiview.browser.test.ts`, which was renamed to `paginator-paginated.browser.test.ts` for the default/paginated + CFI tests). The 4 #4112 tests: drift / prev-preload-after-nav / no-blank / eager-backward-within-a-few-viewports (+ the moved 'columnCount=1 in scrolled mode' and the #3987 toggle-off test). `pnpm test` (4921) + `pnpm lint` + `pnpm format:check` + 57 paginator browser tests green (4 files: scrolled, paginated, expand, stabilization).
**GOTCHA for live verification:** programmatic `el.scrollTop = N` does NOT fire 'scroll' events in the claude-in-chrome context (real wheel/touch does). To test scroll-driven preloading via the JS console, set scrollTop AND `el.dispatchEvent(new Event('scroll'))`. Also: the Next.js 16 dev server bundles foliate-js (transpilePackages); editing paginator.js hot-reloads, but verify the served chunk has your edit (fetch `_next/static/chunks/packages_foliate-js_paginator_*.js` and grep) — recompile can lag.
readest-app test change is uncommitted on `dev`; foliate-js fix on branch `fix/scrolled-prev-prepend-anchor` (uncommitted) → needs a PR to readest/foliate-js then a submodule bump.
**Verification harness:** localhost:3000/reader/<id> book "凡人修仙传" (2470 sections). Expose `__pg`/`__fv`, tag view elements with section index via `iframe.contentDocument` identity, measure `visibleTopSec()` vs target after settle. jsdom CANNOT reproduce (no real layout); use Chrome.
readest.koplugin cover handling (in `apps/readest.koplugin/library/`):
- **Local book covers** come from coverbrowser.koplugin's `BookInfoManager` (a hard dependency). `coverprovider.get_local_cover(file_path)` returns `BIM:getBookInfo(file_path, true).cover_bb` (a downscaled thumbnail bb) and kicks off background extraction on a miss.
- **Native-resolution cover** for a file (no open doc needed): `require("apps/filemanager/filemanagerbookinfo"):getCoverImage(nil, file_path)` — opens+closes the doc itself, honors KOReader custom covers, returns a blitbuffer. Same `(nil, file)` call form `calibre.koplugin` uses. Write it to PNG with `bb:writeToFile(path, "png")` (pcall-wrapped internally) then `bb:free()`.
- **Cloud covers** are downloaded to `DataStorage:getSettingsDir()/readest_covers/<hash>.png` (`cloud_covers.covers_dir()`); cloud storage key is `<user_id>/Readest/Books/<hash>/cover.png` (`build_cover_key`), which matches readest's `getCoverFilename` (`<hash>/cover.png`).
**Issue #4374 (fixed):**`syncbooks.uploadBook` only shipped a `cover.png` if one was *already cached* under `readest_covers/<hash>.png` from a prior cloud download — so books that originated locally in KOReader uploaded with no cover and showed blank in readest. Fix = `syncbooks.extractLocalCover(file_path, dst_png)` (extracts via `getCoverImage` → writeToFile), called from `uploadBook` when `has_cover` is false. Only file-upload path is `librarywidget.lua` "Upload to Cloud" → `uploadBook` (FileManager `addToReadest` only stages a local row).
Tests: network/live parts of `syncbooks.lua` aren't unit-tested (manual matrix in `docs/library-design.md`); `extractLocalCover` IS tested by injecting a fake `apps/filemanager/filemanagerbookinfo` via `package.loaded` in `spec/library/syncbooks_spec.lua`. A real KOReader checkout lives at `/Users/chrox/dev/koreader` for verifying KOReader APIs. See [[koplugin-i18n]].
KOSync progress push/pull converts between foliate CFI and KOReader XPointer via `src/utils/xcfi.ts`. `XCFI` is constructed for ONE spine section (`spineItemIndex`) and `cfiToXPointer`/`xPointerToCFI` throw `CFI spine index N does not match converter spine index M` if asked to convert a CFI from a different section.
**Pitfall:**`view.renderer.primaryIndex` lags behind the viewport during scrolling (see `paginator.js``#primaryIndex` comment). So `progress.location` can be a CFI in a different spine section than the currently-rendered primary view. Building the converter from the primary view's `doc`/`index` then converting `progress.location` throws on mismatch.
**Fix (PR branch fix/kosync-spine-index):** always go through the exported helpers `getXPointerFromCFI(cfi, doc?, index?, bookDoc)` / `getCFIFromXPointer(...)` in `xcfi.ts`. They call `XCFI.extractSpineIndex(cfi)` and, when the passed `index` ≠ the CFI's spine, load the correct section's document via `bookDoc.sections[xSpineIndex].createDocument()`. `generateKOProgress` in `src/app/reader/hooks/useKOSync.ts` had hand-rolled `new XCFI(primaryDoc, primaryIndex)` instead of using the helper — that was the bug.
**Why:** the helper already handles cross-section resolution correctly; never reconstruct the converter inline against `primaryIndex`.
**How to apply:** for any CFI→XPointer or XPointer→CFI in KOSync code, use the `getXPointerFromCFI`/`getCFIFromXPointer` helpers and pass `bookDoc` so off-screen sections can be loaded. Related: [[issue-4112-scroll-anchoring]] (same primaryIndex/scroll-lag family). The companion foliate `fromRange` crash (`Cannot destructure property 'nodeType'`) during relocate is separate scroll-lag noise, not the sync blocker.
**Conflict-detection comparison (`promptedSync` in `useKOSync.ts`):** KOReader's reported `remote.percentage` comes from CREngine pagination and is NOT comparable to Readest's progress. For reflowable books, convert the remote XPointer → local CFI → `view.getCFIProgress(cfi).fraction` (helper `getRemoteLocalFraction` in `kosyncProgress.ts`) and compare THAT to the local percentage; fall back to `remote.percentage` only when it can't be resolved locally (non-XPointer/Kavita progress or missing section). The resolved fraction also drives the "Approximately X%" remote preview so the dialog shows what was actually compared. Fixed-layout still compares page-derived `remotePercentage`. Threshold: `0.0001` cross-device, but loosened to `0.01` when `remote.device_id === settings.kosync.deviceId` (same device = our own earlier push; don't prompt on sub-page drift). Percentages render via `formatProgressPercentage` (2 decimals, `kosyncPreview.ts`).
Manage Cache feature: `src/utils/cache.ts` (helpers `getCacheEntries`/`getCacheStats`/`clearCacheEntries` over `CacheSource[]`), `src/app/library/components/CacheManagerWindow.tsx` (singleton dialog, `setCacheManagerDialogVisible`, `getCacheSources()` composes the source list), menu item in `SettingsMenu.tsx` Advanced Settings, mounted in `library/page.tsx`.
Scope: **native mobile apps only** (gated on `appService?.isMobileApp`; hidden on desktop + web). Sources cleared: iOS → Cache + Temp + `Documents/Inbox`; Android → Cache + Temp.
On-device analysis (dev build `com.bilingify.readest`, pulled via `xcrun devicectl device copy from --domain-type appDataContainer`):
- The `'Cache'` base = Tauri `appCacheDir()` = **`Library/Caches/com.bilingify.readest`** only — NOT all of `Library/Caches`. On the test device this held ~272 MB: a 249 MB duplicate dictionary (`concise-enhanced.mdx`, canonical copy lives in `App Support/Readest/Dictionaries/<id>/`), ~23 MB duplicate import-staged epubs (canonical in `App Support/Readest/Books/<hash>/`), the `search/` results cache, plus tiny system scratch. All safe to clear — every large item is an orphaned import/download staging duplicate.
- iOS open-in leftovers: **`Documents/Inbox`** (resolved via `documentDir()` + join `Inbox`, scanned/cleared with base `'None'` + absolute paths). Already-imported books linger here. The feature now clears it on iOS. (`tmp/<bundle>-Inbox` also exists but was empty.)
- NOT reachable by the `'Cache'` base: `Library/Caches/WebKit` (~173 MB, WKWebView disk cache — needs native `WKWebsiteDataStore.removeData`) and `tmp/` blob scratch (~59 MB, maps to `'Temp'` base, not currently cleared). These are the bigger "free up space" wins if the feature is ever expanded.
- Never clear `Library/Application Support/com.bilingify.readest/Readest` — the real Books/Dictionaries/Fonts/DB (~1.7 GB).
Root-cause follow-up worth doing: the import pipeline leaves staging copies in the cache root and `Documents/Inbox` instead of deleting them after import.
description: "Paginated page background overflowed its column into the outer --_outer-min gutter (asymmetric on mixed spreads); fix = clamp computeBackgroundSegments to the content area, keep the grid"
CI-built DMG rendered scanned PDFs as blank pages (could still turn pages); text PDFs and EPUBs fine. Local build of the same commit worked. Regression between 0.11.1 (fine) and 0.11.2 (broken).
**Root cause:**`pdfjs-dist` was bumped 5.4.530 → 5.7.284 in #4143 (commit e8df651d5, between the 0.11.1 and 0.11.2 tags). pdfjs 5.7.x moved image decoders — notably **JBIG2** (the codec used by virtually every black-and-white *scanned* PDF) — from pure JS into WebAssembly modules the worker fetches at runtime from `wasmUrl` (`/vendor/pdfjs/`, set in `packages/foliate-js/pdf.js`: `wasmUrl: pdfjsPath('')`). The `copy-pdfjs-wasm` npm script only copied an allow-list `{openjpeg.wasm,qcms_bg.wasm}` and silently dropped `jbig2.wasm`. **`cpx` does not error when a glob matches nothing**, so the missing decoder was invisible: worker loads, pages turn, JBIG2 decode fails → blank.
**Why local masked it:**`pnpm build` / `tauri build` do NOT run `setup-vendors`. Local builds reuse whatever stale `public/vendor/pdfjs/` is already on disk (gitignored — `/public/vendor`). The dev's local copy was the old 5.4.530 worker (pure-JS JBIG2) → worked. CI runs `setup-vendors` fresh (release.yml:192) → ships the new 5.7.284 worker that needs jbig2.wasm → broke.
**Fix:** changed `copy-pdfjs-wasm` to copy the whole `wasm/*` dir instead of an allow-list (mirrors the `{cmaps,standard_fonts}/*` fonts pattern). Robust against future codecs moving to wasm; also ships the `*_nowasm_fallback.js` files for graceful degradation. Regression test: `src/__tests__/document/pdfjs-wasm-assets.test.ts` asserts every `.wasm` the bundled pdf.js references is covered by `copy-pdfjs-wasm`.
**Gotchas for future:**
- Vendor assets in `public/vendor/` are gitignored and only refreshed by `setup-vendors`. Local stale vendor can mask CI breakage — `git status` won't show it.
-`cpx` allow-lists are fragile: any upstream-added required file is dropped silently. Prefer copying whole dirs.
### iPad reports a desktop UA → never branch native dispatch on `getOSPlatform()`
-`getOSPlatform()` (utils/misc) is **user-agent based**, and iPadOS sends a desktop "Macintosh" UA → it returns `'macos'` on iPad. Any native-OS dispatch keyed on it misroutes iPad to the macOS path.
- Symptom seen: system dictionary on iPad threw `"Command show_lookup_popover not found"` — the macOS-only Rust command (`src-tauri/src/macos/system_dictionary.rs`); iOS only registers the plugin command `plugin:native-bridge|show_lookup_popover`.
- **Rule:** for OS-specific native dispatch/capability, use `appService.isIOSApp / isMacOSApp / isAndroidApp` (derived from the Tauri OS plugin `type()` → `OS_TYPE` in `nativeAppService.ts`), NOT `getOSPlatform()`. The misc.ts comment says this explicitly.
- Sync, non-React modules: `getInitializedAppService()` (environment.ts) returns the cached singleton synchronously (null pre-init). Used by `systemDictionary.ts` for `isSystemDictionarySupported()` (sync) + `invokeSystemDictionary()` (async).
#4397 "Strange line on the bottom of the page when long-pressing the footer" (Android 13, 0.11.2).
**Symptom:** a thin horizontal line, content-column wide, across the last text line / bottom margin. Appears after long-pressing the footer + turning a page; persists across page turns; cleared by double-tapping the footer + turning a page. Maintainer couldn't repro on eink (device/WebView-specific long-press focus behavior).
**Root cause:**`ProgressBar.tsx` (the always-on page-info footer, `.progressinfo`) rendered as `<div role='presentation' tabIndex={-1} onClick=...>`. The `tabIndex={-1}` made the decorative div click/touch-focusable. Android WebView long-press focused it and painted the browser default focus ring (`outline: auto`, matched `:focus-visible`). It's a top-document element pinned `absolute bottom-0` at book-view width, so the ring shows as a content-width box at the bottom on *every* page until focus clears. NOT a range/slider input — those are `opacity-0`/`visibility:hidden` (red herring: globals.css excludes `input[type='range']` from its outline-suppression rule, but that's unrelated here).
**Fix:** remove `tabIndex={-1}`. A `role='presentation'` decorative element must not be focusable. `onClick` (tap-to-cycle progress mode / dismiss annotation popup) fires regardless of tabindex, and no ancestor has a real `tabindex` to grab focus instead (verified: focus falls through to `<body>`). Nothing programmatically focuses `.progressinfo`. Test asserts `.progressinfo` has no `tabindex` attribute (in `ProgressBar.test.tsx`).
**Debugging technique that worked:** download the issue video (`gh` attachment URL) → `ffmpeg` extract frames → zoom on the line; the **downward corner at the right end** revealed it was a rectangular *outline* (focus box), not a strikethrough/selection. Then in the live dev app (`mcp__claude-in-chrome__javascript_tool`): `el.focus(); el.matches(':focus-visible'); getComputedStyle(el).outlineStyle` → returned `true` / `auto`, confirming the element + mechanism. See [[annotator-reader-fixes]] [[layout-ui-fixes]].
description: "How to test the native Rust EPUB/MOBI parser against foliate-js in the Tauri WebView suite, plus the dcterms:modified→published parity gotcha"
PR #4369 added native Rust EPUB/MOBI parsers (`src-tauri/src/epub_parser.rs`, `mobi_parser.rs`, shared `parser_common.rs`) with foliate-js fallback. Parity between the two parsers is the key risk.
**Cross-language parity test harness** (`src/__tests__/tauri/epub-parser-parity.tauri.test.ts`):
- The `.tauri.test.ts` suite (run by `scripts/test-tauri.sh` / `pnpm test:tauri`, included only by `vitest.tauri.config.mts`) is the ONLY env where both parsers coexist: Rust via `invoke()` (`./tauri-invoke.ts`), foliate-js via `DocumentLoader`. Default `pnpm test` excludes `**/*.tauri.test.ts`.
- Rust commands read an absolute on-disk path → build it from `process.env.CWD` (injected by the tauri config = readest-app dir): `${CWD}/src/__tests__/fixtures/data/<name>`. The JS side fetches the SAME file via a Vite URL: `new URL('../fixtures/data/<name>', import.meta.url).href` (same pattern as `paginator-stabilization.browser.test.ts`).
- Compare via the app's own normalizers so you compare user-visible values, not raw parser shapes: `formatTitle`, `formatAuthors(authors, lang)` (Rust gives `string[]`, foliate gives `string|Contributor|array` — both through formatAuthors), `getPrimaryLanguage`, `formatPublisher`, `formatDescription`.
- **Cover is presence-only**: Rust downscales/re-encodes the cover to a ~512px JPEG (`parser_common::maybe_resize_cover`), so bytes never match foliate's original — assert `(rust.coverBase64 != null) === ((await js.getCover()) != null)`.
- **Description needs whitespace-collapse**: foliate collapses an internal source newline to a space; Rust preserves the raw `\n`. Compare `formatDescription(x).replace(/\s+/g,' ').trim()`.
- Section `href` is undefined on foliate `SectionItem` — compare sections by `{id,size,linear}` only. `linear` can be `null`.
- Strongest test = open the same EPUB twice through `DocumentLoader` (with `{nativeFilePath}` → Rust prefetch, and without → pure zip.js) and assert identical `metadata`, sections, `toc`, and `computeBookNav` output (toc tree + per-section fragment CFIs/sizes). This validates `parse_epub_full` prefetch + the parallelized TOC pipeline are behavior-preserving in one shot. `isTauriAppPlatform()` is true in the webview (`.env.tauri` sets `NEXT_PUBLIC_APP_PLATFORM=tauri`) so the prefetch fires.
- No `.mobi`/`.azw3` fixture exists anywhere in the repo → MOBI parity is NOT covered by this harness; add a Kindle fixture to extend. Rust MOBI is covered by `mobi_parser`'s own unit tests.
- Offline verification trick (no chromedriver/tauri-driver locally): dump foliate `book.metadata` via a temp node vitest test, dump Rust `parse_epub_metadata_sync`/`compute_partial_md5` via a temp `#[cfg(test)]` mod using `env!("CARGO_MANIFEST_DIR")/../src/__tests__/fixtures/data`, diff. Lets you lock every expected value before CI runs the WebView suite.
**Running the `.tauri.test.ts` suite locally on macOS** (no chromedriver/tauri-driver needed — `tauri-plugin-webdriver` 0.2 embeds an axum WebDriver server with a `platform/macos.rs` WKWebView impl, default port 4445):
-`pnpm test:tauri` runs `scripts/test-tauri.sh`: starts `next dev` on :3000, `tauri dev --features webdriver --no-watch`, waits for :4445/status, then `vitest --config vitest.tauri.config.mts`. Its cleanup does `lsof -ti :3000 | xargs kill` — so it KILLS whatever is on :3000 (e.g. another worktree's dev server). To avoid that, copy the script and shift `DEV_PORT`/`next dev -p`/`--config devUrl` to a free port (e.g. 3100). Run via `pnpm exec bash <script>` so npm bins (`dotenv`, `next`, `tauri`, `vitest`) resolve, not the Ruby `dotenv` gem.
- **Single-instance blocker**: `tauri_plugin_single_instance` (lib.rs, keyed on bundle id `com.bilingify.readest`) is registered unconditionally for desktop and is NOT gated by the webdriver feature. If `/Applications/Readest.app` (or any Readest instance) is running, the test instance forwards to it and exits → "Tauri exited before WebDriver ready." Quit Readest.app before running. CI doesn't hit this (Linux, no Readest installed).
- **`vitest.tauri.config.mts` needs an `optimizeDeps` block** mirroring `vitest.browser.config.mts` (include the CJS deps `js-md5`/`@zip.js/zip.js`/`franc-min`/`iso-639-*`/etc.; exclude `@pdfjs/pdf.min.mjs` + the turso wasm). Without it, esbuild's dep-scan can't resolve foliate-js's `import '@pdfjs/pdf.min.mjs'`, skips pre-bundling, and any tauri test that imports `@/libs/document` fails to load with "Importing a module script failed" on a cold `.vite` cache. (A warm cache from a prior run masks it.)
- **Pre-existing failure, not parity-related**: `tauri-app-service.tauri.test.ts` has 13 failures on macOS — `forbidden path: …/src/__tests__/fixtures/data/sample-alice.epub … allow-open permission`. importBook's library-copy goes through the fs plugin (scoped), and `capabilities-extra/webdriver.json` doesn't grant the fixtures dir. Confirmed identical at base config (no regression from parity work). Parser parity tests avoid this: Rust `File::open` is unscoped, and the JS side fetches fixtures via a Vite URL. To fix separately, add the fixtures path to the webdriver capability scope.
**Parity gotcha found + fixed**: Rust `handle_meta` mapped `dc:date` AND `dcterms:modified` → `published`. foliate keeps `dcterms:modified` as a separate `modified` field and leaves `published` empty. EPUB3 mandates `dcterms:modified`, so native-imported EPUB3 books with no `<dc:date>` got a bogus publication date. Fix: map only `"date"` (not `"modified"`) to published. Regression tests: `epub_parser::tests::{dcterms_modified_does_not_populate_published, dc_date_populates_published}`.
Issue #4059: the TOC sidebar used to auto-expand *every* top-level container (`computeExpandedSet` added all `toc.filter(item => item.subitems?.length)`), so multi-volume books ("文学必读合集20册") opened fully expanded. Fix: expand only the current location's ancestor path (`findParentPath`), with a single-root fallback (expand `toc[0]` only when `toc.length === 1`) so a one-root-wrapper TOC isn't reduced to one row. Pure logic lives in `src/app/reader/components/sidebar/tocTree.ts` (extracted from TOCView so it's unit-testable — TOCView itself can't be imported in jsdom: react-virtuoso + OverlayScrollbars CSS).
Collapsing-by-default introduced a **regression**: the current chapter no longer scrolled into view on fresh load. Root cause is two-fold, both stemming from the pinned sidebar mounting *before* the first relocate (progress is null at mount, so `initialScrollTarget.index` is 0 and Virtuoso's `initialTopMostItemIndex` can't anchor):
1. When progress arrives, the current volume expands and the virtualized list grows by dozens of rows in one commit. Virtuoso emits a synthetic `onScroll`; the old handler treated *any* scroll while a scroll was queued as "user took over" and cleared `pendingScrollRef`. Fix: ignore a queued-state scroll unless a real gesture preceded it — track `userInputRef` via capture-phase `wheel`/`touchstart`/`pointerdown`/`keydown` listeners on the scroller; `if (pendingScrollRef.current && !userInputRef.current) return;`.
2. Even with pending preserved, a single `scrollToIndex(idx, {align:'center'})` fired in the same commit as the 28→90 row growth **lands short** — Virtuoso scrolls before measuring the new rows. Fix: re-assert the scroll on the next `requestAnimationFrame` (only for the instant `behavior:'auto'` case). The toggle-sidebar (remount) path always worked because remounting feeds `initialTopMostItemIndex` before first render.
Verified live: 红楼梦 book at `/reader/217e3f1e...` — only 下卷 expanded, current chapter centered (0px from viewport center), user scroll no longer snaps back. Related: [[issue-4112-scroll-anchoring]], [[reading-ruler-line-aware]], [[virtuoso_overlayscrollbars]].
Issue #4398 (PR #4401) — Windows app fails to launch ("WebView2 error 0x80070057 / The parameter is incorrect") when `%APPDATA%\com.bilingify.readest\.window-state.json` holds the minimized sentinel (`x/y = -32000`) or `0×0` size.
Non-obvious facts:
- Our `tauri-plugin-window-state` (2.4.1, and every shipped 2.2.1+) ALREADY contains the upstream fix for [tauri-apps/plugins-workspace#253](https://github.com/tauri-apps/plugins-workspace/issues/253): `update_state` + the Moved/Resized handlers skip saving while `is_minimized()`, and restore only re-applies position if it `intersects()` a monitor. So current builds shouldn't generate bad state — a bad file is almost certainly stale from an old build. The version-inquiry comment is on the issue.
- Fix = **defense-in-depth**, NOT a behavior change: `src-tauri/src/window_state.rs` — a tiny `window-state-sanitizer` Tauri plugin whose `setup` strips window entries with invalid geometry (`width<=0 || height<=0 || x<=-30000 || y<=-30000`) from the file before window-state reads it. Pure `sanitize_json(&str)->Option<String>` is unit-tested (`cargo test -p Readest window_state`).
- **Plugin init order matters**: Tauri's `PluginStore::initialize_all` iterates the store in registration order (Vec push), and window-state loads the file in its `setup` (runs during `initialize`). So the sanitizer plugin MUST be registered BEFORE `tauri_plugin_window_state` in `lib.rs` (it is). The app-level `.setup()` closure runs AFTER all plugin setups → too late to sanitize there.
- Coord threshold is `-16000` (sentinel is exactly `-32000`; ~halfway). Real desktops sit a few thousand px off origin (4K-left = `-3840`, 3×4K ≈ `-11520`), so `-16000` is well past normal use yet clearly the sentinel zone; a legit negative like `-1920` is kept. Do NOT justify by extreme N-monitor walls (user pushed back on that). The size check (`<=0`) is the real WebView2-crash guard; this coord check is secondary (the plugin already refuses to restore an off-screen position via its `intersects()` monitor check). Missing fields default to valid so a schema change never drops a good entry.
Repo Rust testing reality: zero pre-existing `#[cfg(test)]` modules; CI only runs `cargo fmt --check` + `cargo clippy -p Readest --no-deps -D warnings` (NOT `--all-targets`, so it won't compile test code). `generate_context!` tolerates a missing `frontendDist` (`../out`), so `cargo test`/clippy build without a frontend build. See [[tauri-parser-parity-tests]].
@@ -65,9 +65,28 @@ pnpm worktree:new feat/my-feature # New branch from origin/main
pnpm worktree:new 3837# Checkout PR #3837 with push access to fork
```
## Agent Workspace
Project-related agent context lives under `.agents/`, which is a symlink to `.claude/`. Treat `.agents/` as the canonical path when looking for or updating local agent material:
-`.agents/memory/` — persistent project memory and recurring context
-`.agents/plans/` — active or archived implementation plans
-`.agents/rules/` — project rules for test-first work, TypeScript, verification, and related workflows
## Project Rules
Rules are in `.claude/rules/`: test-first, typescript, verification.
Rules are in `.agents/rules/`: test-first, typescript, verification.
### Implementation Scope
For every coding task, write the minimum code that solves the requested problem.
- Do not add features beyond what was asked.
- Do not add abstractions for single-use code.
- Do not add flexibility or configurability unless requested.
- Do not add error handling for impossible scenarios.
- If a solution is much longer than necessary, simplify it before finishing.
- Before shipping, ask: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
### i18n
@@ -91,21 +110,3 @@ Every new UI widget must look right under `[data-eink='true']`. E-ink screens ha
- **Don't rely on color/shadow alone for hierarchy.** Two same-tone buttons differ only by hover on color themes, and hover doesn't exist on e-ink touchscreens. Pair a borderless ghost (cancel) with a solid CTA (submit) so eink can invert one without flattening the difference.
When in doubt, toggle E-ink in Settings → Misc and check. The rules in `globals.css` cover most cases automatically, but composite components (custom buttons, layered cards) often need `eink-bordered` on the right element to stay legible.
Available gstack skills:
-`/plan-ceo-review` — CEO/founder-mode plan review
-`/plan-eng-review` — Eng manager-mode plan review
-`/plan-design-review` — Designer's eye review of a live site
-`/design-consultation` — Design system consultation
require-corp` on every document. The COOP/COEP pair is required so that the
browser exposes `SharedArrayBuffer`, which the Turso WASM thread pool needs in
order to run the in-browser replica database; without those headers
`initThreadPool` hangs.
`/runtime-config.js` is a server route that emits
`window.__READEST_RUNTIME_CONFIG = {...}` as a JavaScript file. It is loaded as
a `<script>` tag from `app/layout.tsx` and `pages/_document.tsx`. This is what
lets a single Docker image be rebranded with a different Supabase project, S3
endpoint, or quota at deploy time without rebuilding — see commit
`9ad43aa8` and the `docker/` directory.
## 3. Frontend architecture
The frontend is a Next.js 16 + React 19 app. It uses both routers:
| Concern | Lives in | Why |
|---|---|---|
| Library, reader, auth, OPDS, send, user pages | `src/app/*` (App Router) | Standard for new pages; supports server components and the runtime-config route. |
| Reader entry by ID list `/reader/[ids]` | `src/pages/reader/[ids].tsx` (Pages Router) | Historical entrypoint; coexists with the App Router reader. |
| Cross-origin isolation document shell | `src/pages/_document.tsx` | Pages Router still owns `<Document>` for COOP/COEP and `runtime-config.js`. |
| HTTP API endpoints | both `src/app/api/*` and `src/pages/api/*` | Mix of new App Router routes and legacy Pages Router routes. |
This note summarizes the runtime boundaries inside `apps/readest-app`, with two goals:
- explain which directories are server-side, client-side, or mixed
- explain the directory-level role of `apps/readest-app/src/services`
## First: `src-tauri`
`apps/readest-app/src-tauri` is the Tauri native shell layer for all Tauri targets, not just desktop.
- Desktop: Windows, macOS, Linux
- Mobile: Android, iOS
That is visible in `apps/readest-app/src-tauri/tauri.conf.json`, which contains both `bundle.android` and `bundle.iOS` configuration, plus mobile deep-link settings.
So the rough split is:
-`apps/readest-app/src`: the Next.js/React app
-`apps/readest-app/src-tauri`: the native host layer for Tauri desktop and mobile builds
Next.js App Router server endpoints (`route.ts`). These run on the server / edge runtime, not in the browser.
-`apps/readest-app/src/pages/api`
Next.js Pages Router API endpoints. This is where the classic server handlers live, including sync, storage, send, DeepL, and user endpoints.
-`apps/readest-app/src/app/runtime-config.js`
A server route that emits runtime JavaScript config for the client.
-`apps/readest-app/workers`
Worker-side backend code outside the normal page UI tree. For example, `workers/send-email` is operational backend code.
### Mostly client-side directories
-`apps/readest-app/src/components`
Reusable React UI components.
-`apps/readest-app/src/context`
React context providers and app state wiring.
-`apps/readest-app/src/hooks`
Client-side React hooks.
-`apps/readest-app/src/store`
Frontend state stores.
-`apps/readest-app/src/styles`
Styling, theme assets, and UI presentation helpers.
-`apps/readest-app/src/data`
Static or bundled app data.
-`apps/readest-app/src/i18n`
Internationalization resources and setup.
-`apps/readest-app/src/workers`
Browser worker code used by the frontend.
-`apps/readest-app/public`
Static assets served to the frontend.
-`apps/readest-app/extension`
Browser-extension-specific client code.
-`apps/readest-app/extensions`
Platform integration extensions such as Windows thumbnail support.
### Mixed or shared directories
-`apps/readest-app/src/app`
Mostly frontend routes and UI, but not purely client-side. In Next App Router, `page.tsx`, `layout.tsx`, and related files can mix server rendering and client components. The exception is `src/app/api`, which is server-only.
-`apps/readest-app/src/pages`
Mixed. `src/pages/api` is server-only; `src/pages/reader/[ids].tsx` is frontend page code; `_document.tsx` is server-side document wiring.
-`apps/readest-app/src/services`
Shared domain/service layer. Most of this is not “backend-only”; it contains platform adapters, client logic, network clients, sync logic, and some code that is reused by server routes.
-`apps/readest-app/src/utils`
Shared helpers used by both frontend code and server handlers.
-`apps/readest-app/src/libs`
Shared library code. Some of it is server-oriented, some client-oriented, some neutral.
-`apps/readest-app/src/helpers`
General helper code, usually shared.
-`apps/readest-app/src/types`
Shared type definitions.
-`apps/readest-app/src/__tests__`
Test code covering both client and server behavior.
-`apps/readest-app/e2e`
End-to-end test suite.
-`apps/readest-app/scripts`
Build, release, and maintenance scripts.
-`apps/readest-app/docs`
App-specific documentation.
## `src/app` and `src/pages` at directory level
### `src/app`
-`src/app/api`: server-side HTTP endpoints
-`src/app/auth`: auth pages and auth-related UI/helpers
For top-anchored slide-in panels (sidebar, notebook), use `getPanelTopInset()` from `src/utils/insets.ts`. It clears the status bar on tablet/desktop and full-height mobile sheets, but stays flush for a partial-height mobile bottom sheet (which doesn't reach the top of the screen). Gating only on `isFullHeightInMobile` is wrong — a non-mobile panel is also top-anchored and would let the status bar obscure its toolbar.
### Bottom Inset Rules
For UI elements anchored to the **bottom** of the screen (footer bars, controls, progress indicators), use `gridInsets.bottom * 0.33` as padding — a fraction of the full inset since bottom bars don't need as much clearance as the home indicator area:
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the reading ruler snap to the next group of *actual* rendered text lines on each tap/page-change so lines stay centered in the band, eliminating the drift caused by the current arithmetic step.
**Architecture:** Two new pure functions in `src/app/reader/utils/readingRuler.ts` derive line boxes from `progress.range.getClientRects()` and compute a snapped band center. `ReadingRuler.tsx` caches the line boxes per page and calls the snap function from both the tap handler and the page-change auto-move, falling back to the existing arithmetic step when line geometry is unavailable (scrolled mode, fixed-layout, missing range).
`buildLineBoxes` converts per-fragment client rects (from `range.getClientRects()`) into sorted visual-line spans along the ruler axis, in container coordinates. It clusters fragments that belong to the same visual line (high overlap on the ruler axis) and maps coordinates exactly as the existing auto-move does:
Run: `pnpm test src/__tests__/utils/readingRuler.test.ts`
Expected: FAIL — `buildLineBoxes`, `READING_RULER_LINE_PADDING_PX`, `snapReadingRulerToLines` are not exported (also a build/type error on the import).
- [ ]**Step 3: Implement the type, constant, and `buildLineBoxes`**
In `src/app/reader/utils/readingRuler.ts`, add below the existing `FIXED_LAYOUT_READING_RULER_LINE_HEIGHT` constant (line 3):
```typescript
// Extra band height (px) added so the centered lines clear the band edges.
- [ ]**Step 4: Run tests to verify the `buildLineBoxes` tests pass**
Run: `pnpm test src/__tests__/utils/readingRuler.test.ts`
Expected: the `buildLineBoxes` describe block PASSES. (The `snapReadingRulerToLines` import still makes the file fail to compile — that's fixed in Task 2. If the runner refuses to run due to the missing export, temporarily comment out the `snapReadingRulerToLines` import line to confirm, then restore it.)
Given the current band center, viewport dimension, padded band size, line count, direction, and the line boxes, return the next band center (px) centered on the next `lines`-line block — or `null` when there is no next group (caller falls back to a page flip).
- [ ]**Step 1: Write the failing tests**
Append to `src/__tests__/utils/readingRuler.test.ts`:
```typescript
describe('snapReadingRulerToLines',()=>{
// 10 lines, each 16px tall, 20px apart, starting at 0.
Glue the pure functions in: padded band size for snap-capable books, a per-page line-box cache, and snapping in both the tap handler and the page-change auto-move, with the existing arithmetic path as fallback.
This task is verified by `pnpm lint` + `pnpm test` (the pure logic is fully covered by Tasks 1–2) and a manual check in the reader, since the behavior depends on live DOM range geometry that unit tests cannot reproduce.
- [ ]**Step 1: Update imports**
In `src/app/reader/components/ReadingRuler.tsx`, change the `@/types/book` import (line 4) from:
Replace the `performAutoMove` function body (lines 193-215) with the version below. It tries the line snap first and keeps the existing first-visible-text offset as a fallback:
// For vertical mode: use marginRight for vertical-rl, marginLeft for vertical-lr
constdefaultOffset=isVertical
?rtl
?(viewSettings.marginRightPx??44)
:(viewSettings.marginLeftPx??44)
:(viewSettings.marginTopPx??44);
constoffset=textPosition??defaultOffset;
consttargetPosition=clampPosition(
((offset+rulerSize/2)/containerDimension)*100,
containerDimension,
);
setRulerPosition(targetPosition,true);
};
```
Then add `lines` and `supportsLineSnap` to the auto-move effect's dependency array (the array currently ending at lines 232-242). It should read:
```typescript
},[
progress?.pageinfo?.current,
viewSettings.scrolled,
isVertical,
rtl,
viewSettings.marginTopPx,
viewSettings.marginLeftPx,
viewSettings.marginRightPx,
rulerSize,
lines,
supportsLineSnap,
setRulerPosition,
]);
```
- [ ]**Step 6: Snap in the tap/key move handler**
In the `reading-ruler-move` effect, replace the body from the `const nextPosition = stepReadingRulerPosition(...)` block through the `return true;` (lines 400-412) with:
Then add `lines` and `supportsLineSnap` to that effect's dependency array (currently line 419) so it reads:
```typescript
},[
bookKey,
containerSize.height,
containerSize.width,
isVertical,
lines,
rulerSize,
supportsLineSnap,
setRulerPosition,
]);
```
- [ ]**Step 7: Type-check and lint**
Run: `pnpm lint`
Expected: PASS (no Biome errors, no tsgo type errors). If tsgo complains that `ReadingRulerLineBox` is unused, confirm Step 3 added the `lineBoxesRef` typed with it.
- [ ]**Step 8: Run the full unit suite**
Run: `pnpm test`
Expected: PASS — no regressions.
- [ ]**Step 9: Manual verification in the reader**
Start the web dev server (`pnpm dev-web`), open a reflowable EPUB, enable the reading ruler (Settings → Color/Layout → Reading Ruler), and confirm:
- Tapping the page advances the band so the next lines sit centered in it, with no manual adjustment needed across many taps.
- At the bottom of a page, a forward tap flips the page and the band lands centered on the first lines of the new page.
- Backward taps and (if available) a vertical-writing-mode book behave symmetrically.
- A fixed-layout PDF still uses the old fixed-step behavior (no errors).
- **Type consistency:** `ReadingRulerLineBox { start; end }`, `buildLineBoxes(rects, isVertical, rtl, containerRect)`, and `snapReadingRulerToLines(currentCenterPx, dimension, rulerSize, lines, direction, lineBoxes)` are used identically in tests and glue.
- **No placeholders:** every code step contains complete code and exact run commands.
# Hotkey to Highlight the Currently-Spoken TTS Sentence — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a keyboard action (default `Shift+M`, "Text to Speech" section) that persists the sentence TTS is currently reading aloud as a normal highlight using the user's default style/color — eyes-off, silent, idempotent (skip duplicates).
**Architecture:** Connect the two existing owners through the app event bus. `TTSController` is the only place that knows both `view.tts` (current sentence Range) and the TTS section index, so it exposes `getSpokenSentence(): { cfi, text } | null`. `Annotator` owns highlight persistence/rendering, so it creates the note. The shortcut → `useBookShortcuts` dispatches `tts-highlight-sentence` → `useTTSControl` (holds the controller ref) resolves the sentence and dispatches `create-tts-highlight` → `Annotator` builds/persists/draws the highlight. The bug-prone create-or-skip decision is a pure, unit-tested helper.
- Test-first (project rule `.agents/rules/test-first.md`): write the failing test, run it red, implement, run it green.
- Never use the `any` type (`.agents/rules/typescript.md`); the test code below casts mock objects via `as unknown as <Type>`, matching the existing suites.
- Run a single test file with `pnpm test <path>` (no `--`).
- Conventional commits with scope, e.g. `feat(tts): ...`. End every commit message body with:
```
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
```
- Do not push during implementation; commit locally only.
Append to `src/__tests__/utils/annotator-util.test.ts`. Also add `buildTTSSentenceHighlight` to the existing import from `@/app/reader/utils/annotatorUtil` and `HighlightStyle`, `HighlightColor` to the existing `@/types/book` import:
```ts
describe('buildTTSSentenceHighlight', () => {
const params = {
cfi: 'epubcfi(/6/4!/4/10,/1:0,/1:42)',
text: 'A spoken sentence.',
style: 'highlight' as HighlightStyle,
color: 'yellow' as HighlightColor,
page: 7,
};
it('builds an annotation BookNote when none exists at the cfi', () => {
Run: `pnpm test src/__tests__/utils/annotator-util.test.ts`
Expected: FAIL — `buildTTSSentenceHighlight is not a function` / import error.
- [ ] **Step 3: Implement the helper**
In `src/app/reader/utils/annotatorUtil.ts`, extend the top imports and add the function. Change the `@/types/book` import line to include `HighlightStyle`, and add `uniqueId`:
```ts
import { BookNote, DEFAULT_HIGHLIGHT_COLORS, HighlightColor, HighlightStyle } from '@/types/book';
import { uniqueId } from '@/utils/misc';
```
Add at the end of the file:
```ts
/**
* Build a persistent highlight BookNote for a TTS-spoken sentence, or return
* `null` when one already exists at the same CFI (idempotent — pressing the
* hotkey twice on the same sentence must not create a duplicate).
*
* `now` is injected so the result is deterministic for tests. A soft-deleted
* note (`deletedAt`) or a non-annotation note (e.g. a bookmark) at the same CFI
* does not block creation — it mirrors the live-annotation predicate used by
* the selection-based highlight path in Annotator.tsx.
Append a new `describe` block inside the top-level `describe('TTSController', ...)` in `src/__tests__/services/tts-controller.test.ts` (e.g. after the `dispatchSpeakMark` block). It reuses the file's existing `controller`, `mockView`, and `createMockView` setup:
```ts
describe('getSpokenSentence', () => {
test('returns the trimmed text and cfi of the current sentence', async () => {
Run: `pnpm test src/__tests__/services/tts-controller.test.ts`
Expected: FAIL — `controller.getSpokenSentence is not a function`.
- [ ] **Step 3: Implement the method**
In `src/services/tts/TTSController.ts`, add this public method immediately above `dispatchSpeakMark(mark?: TTSMark)` (~line 565). It performs the same Range→CFI conversion `dispatchSpeakMark` already uses, reading the current sentence Range from the foliate TTS engine and the active TTS section index:
feat(tts): expose getSpokenSentence on TTSController
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: Register the default shortcut binding
**Files:**
- Modify: `src/helpers/shortcuts.ts` (TTS section, after `onTTSGoPreviousParagraph`, ~line 75)
There is no standalone unit test for the static registry; correctness is verified by `pnpm lint` (tsgo derives `ShortcutConfig` from this object) and by the wiring tasks. The new action automatically appears in the keyboard-shortcuts help dialog because its `section` is non-empty.
- [ ] **Step 1: Add the entry**
In `src/helpers/shortcuts.ts`, insert into `DEFAULT_SHORTCUTS` immediately after the `onTTSGoPreviousParagraph` block (line 75):
```ts
onTTSHighlightSentence: {
keys: ['shift+m'],
description: _('Highlight Current Sentence'),
section: 'Text to Speech',
},
```
- [ ] **Step 2: Type-check**
Run: `pnpm lint`
Expected: PASS. `ShortcutConfig` now includes `onTTSHighlightSentence`. (If `useBookShortcuts` is type-checked before Task 4 wires the handler, `useShortcuts` accepts a partial map, so this should not error on its own; if it does, proceed to Task 4 and re-run.)
- [ ] **Step 3: Commit**
```bash
git add src/helpers/shortcuts.ts
git commit -m "$(cat <<'EOF'
feat(tts): add default Shift+M binding for highlight-current-sentence
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Dispatch the shortcut event from `useBookShortcuts`
**Files:**
- Modify: `src/app/reader/hooks/useBookShortcuts.ts` (handler ~after line 301; registration ~line 358)
- [ ] **Step 1: Add the handler**
In `src/app/reader/hooks/useBookShortcuts.ts`, add immediately after `ttsGoPreviousParagraph` (line 301), mirroring `ttsGoNextSentence`:
In `src/app/reader/hooks/useTTSControl.ts`, add after `handleTTSBackward` (line 81), mirroring the bookKey-matched pattern of the existing TTS handlers:
(Leave the existing `ttsControllerRef.current?.shutdown()` cleanup below unchanged.)
- [ ] **Step 3: Type-check**
Run: `pnpm lint`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/app/reader/hooks/useTTSControl.ts
git commit -m "$(cat <<'EOF'
feat(tts): resolve spoken sentence and relay create-tts-highlight
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 6: Create the highlight in `Annotator`
**Files:**
- Modify: `src/app/reader/components/annotator/Annotator.tsx` (import line 50; handler near `handleHighlight` ~line 840; effect lines 535-545)
- [ ] **Step 1: Import the helper**
Change line 50 to add `buildTTSSentenceHighlight`:
```ts
import {
buildTTSSentenceHighlight,
getHighlightColorHex,
removeBookNoteOverlays,
} from '../../utils/annotatorUtil';
```
- [ ] **Step 2: Add the event handler**
Add immediately after `handleHighlight` (after its closing brace, ~line 840). It reads state freshly via store getters (the listener is registered with `[]` deps, so it must not close over render-time `config`/`settings`/`progress`), matching the fresh-read pattern in `onShowAnnotation`:
Expected: PASS. (`useSettingsStore` is already imported at line 13; `getConfig`, `getProgress`, `getViewsById`, `updateBooknotes`, `saveConfig`, `envConfig` are all already in scope per lines 80-85.)
feat(tts): persist current TTS sentence as a highlight on create-tts-highlight
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 7: Full verification
**Files:** none (verification only)
- [ ] **Step 1: Run the full unit suite**
Run: `pnpm test`
Expected: PASS (no regressions; the two new test blocks green).
- [ ] **Step 2: Lint + type-check**
Run: `pnpm lint`
Expected: PASS (Biome + tsgo clean).
- [ ] **Step 3: Manual smoke test (dev web)**
Run: `pnpm dev-web`, open a book, start TTS (`t`), let it read a sentence, then press `Shift+M`. Confirm:
- the spoken sentence gets a persistent highlight in the user's default color/style;
- pressing the hotkey again on the same sentence does **not** add a second highlight;
- pressing it while TTS is stopped does nothing (no error in console);
- the highlight survives reopening the book (persisted), and appears in the notebook/annotations list.
- the action shows up in the keyboard-shortcuts help dialog (`Shift+?`) under "Text to Speech".
- [ ] **Step 4: (Optional) i18n extraction**
The new `_('Highlight Current Sentence')` string uses key-as-content, so tests/lint pass without extraction. If desired, run the project i18n extraction (`/i18n` skill or `pnpm i18n`) to sync locale catalogs; this is not required for verification to pass and may touch unrelated locale files — keep it out of the feature commits if run.
---
## Notes for the implementer
- **No production test seams.** `getSpokenSentence` is tested by setting the private `#ttsSectionIndex` through the public `controller.initViewTTS(0)` path, then overriding `mockView.tts`/`mockView.getCFI` — exactly how the existing `forward`/`backward`/`start` tests in that suite operate.
- **Granularity is always sentence.** All TTS clients report only `'sentence'` from `getGranularities()`, so `view.tts.getLastRange()` is always a sentence Range — no word-vs-sentence branching is needed.
- **Two events, three components, by design.** Only `TTSController` (via `useTTSControl`) can produce the CFI; only `Annotator` owns highlight persistence. The relay mirrors the existing `tts-forward`/`tts-backward` shortcut pattern rather than duplicating annotation logic.
- **bookKey matching everywhere.** Both new handlers compare `detail.bookKey === bookKey`, so split-view (two open books) routes the highlight to the correct book.
| TTS reading a not-yet-visible section | CFI uses the TTS section index; note is saved and draws when that section renders (existing `onCreateOverlay` path) |
| Live gray TTS cursor overlay overlaps the new highlight | distinct overlay keys; the gray cursor moves on as TTS advances, leaving the persistent highlight |
| Wrong book (split view) | every handler is bookKey-matched, as the existing TTS handlers are |
## Testing
Test-first, per project rule. Both new units are testable with **no production
test seams**:
- **`TTSController.getSpokenSentence()`** — add to
`src/__tests__/services/tts-controller.test.ts`. The private
`#ttsSectionIndex` is set through the public `await controller.initViewTTS(0)`
path (already exercised in that suite); `mockView.tts.getLastRange` and
`mockView.getCFI` are stubbed via the existing `createMockView` helper.
Cases: returns `{ cfi, text }` (trimmed) when a sentence is active; returns
`null` when `view.tts` is absent (pre-init), when `getLastRange()` is
undefined, when `getCFI` throws, and when the range text is empty/whitespace.
- **`buildTTSSentenceHighlight`** — add to
`src/__tests__/utils/annotator-util.test.ts`. Cases: builds a `BookNote`
(`type: 'annotation'`, given `cfi`/`text`/`style`/`color`/`page`, injected
timestamps) when none exists; returns `null` when a non-deleted annotation
already exists at that `cfi`; still builds when the only match at that `cfi`
is `deletedAt`-soft-deleted or a non-annotation (`bookmark`).
Then verify with `pnpm test` and `pnpm lint`.
## Non-goals / YAGNI
- No toast/confirmation, no haptics (locked: silent).
- No toggle-to-remove and no color cycling on repeat (locked: skip).
- No shortcut-customization UI — that is #3772; this only adds one entry to the
existing registry, which #3772 will expose.
- No word-level highlighting (#4017) — out of scope; granularity is always
sentence in practice.
- No new user-facing settings.
- No change to selection-based highlighting behavior.
toolbar badge updates, the lazy-load scroll dance (incl.
`prefers-reduced-motion`), and the popup UI rendering for every progress
phase. From the `apps/readest-app` workspace root:
```bash
pnpm test:extension # 47 shell tests, ~1 s
pnpm build-browser-ext # production webpack build (catches alias / stub regressions)
pnpm test # full suite — also runs the extension tests via vitest's default glob
```
The shared EPUB pipeline (`convertPageToEpub`) and the server's
`/api/send/inbox/file` endpoint have their own tests under
`apps/readest-app/src/__tests__/services/`. The unification regression
specifically lives in `send-convert-page-unified.test.ts`.
**CI coverage:** the GitHub Actions `test_web_app` job runs
`pnpm test:pr:web`, which invokes the full vitest suite (extension shell
tests included), the browser-test suite, and the extension's production
webpack build. A webpack-config or shared-pipeline regression fails the
job on its own line.
## Internationalisation
The extension uses **key-as-content** i18n (matches the readest-app's
`stubTranslation as _` convention): the English source string IS the
lookup key. Import as `_` at every call site to mirror the main repo:
```ts
import { translate as _ } from '../lib/i18n';
_('Send to Readest');
_('Sent — {count} images could not be fetched.', { count });
```
Two parallel translation surfaces:
| Folder | Scope | When it's read |
|---|---|---|
| `src/locales/<lang>.json` | 29 runtime UI strings — popup, errors, status, badges. `{ "<english source>": "<translation>" }`. | At runtime by the `_(...)` helper. Falls through to the English key when an entry is missing or set to the `__STRING_NOT_TRANSLATED__` sentinel. |
| `_locales/<lang>/messages.json` | Three manifest fields — `app_name`, `app_description`, `action_title` — referenced as `__MSG_*__` in `manifest.json`. | At install time + by the Chrome Web Store listing. Chrome falls back to `default_locale` (en) automatically, so a locale file is only needed when you want to override the toolbar tooltip / store copy. |
The full set of supported locales lives in
`apps/readest-app/i18n-langs.json`. The extension's extractor reads the
same file, so a locale added there ships in the extension automatically
on the next `pnpm i18n:extract` run.
### Extracting strings
After adding `_('...')` calls or `data-i18n="..."` attrs:
```bash
pnpm i18n:extract # populates every src/locales/*.json with new keys
pnpm i18n:check # exits non-zero if any bundle has untranslated entries
```
The extractor:
1. Reads the canonical locale list from
`apps/readest-app/i18n-langs.json` and ensures a stub
`src/locales/<lang>.json` exists for every entry (creates an empty
`{}` for any missing locale).
2. Scans every `.ts`/`.tsx` (skipping `*.test.ts`) and every `.html` file
under the extension.
3. Pulls source strings from `_('…')` calls AND `data-i18n` /
`data-i18n-title` HTML attributes.
4. For every non-`en` locale: adds missing entries with
"description":"Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description":{
"message":"Сохраняйте веб-страницы в библиотеке Readest",
"description":"Short description shown in the Chrome Web Store listing."
},
"action_title":{
"message":"Отправить в Readest",
"description":"Tooltip on the toolbar action button."
}
}
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.