forked from akai/readest
c5304cd46ccead0b52037ebcd6a5e59464a69b71
145 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4d645befde |
feat(library): add "Progress Read" sort option (#4427) (#4893)
Sort the library by reading progress (current/total pages). Books that have never been opened read 0% and sort to the unread end; groups sort by their most-progressed book. Direction reuses the existing ascending/descending toggle, so "most read first" is Descending. Adds the "Progress Read" entry to the Sort by menu and translates it across all locales. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4d0be496b2 |
fix(layout): respect author vertical-align on inline images (#4866) (#4878)
img.has-text-siblings forced vertical-align: baseline on every inline image with text siblings, out-specifying a book's own value (for example a CJK glyph-substitution image nudged with vertical-align: -0.15em). Because baseline is the CSS initial value, the declaration only ever mattered when it clobbered an authored value. Keep baseline as a default only: move it to a new has-text-siblings-baseline class that applyImageStyle adds only when the image has no author-set vertical-align (detected via getComputedStyle). Refactor applyImageStyle to a two-phase read-then-write pass to avoid a getComputedStyle-after-write style recalc. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
3ac1a1a45b |
fix(reader): remember last read position for markdown files (#4871)
Markdown sections were created with `cfi: ''`, but foliate-js builds a location CFI as `section.cfi ?? CFI.fake.fromIndex(index)`. Nullish coalescing does not fall back for an empty string, so every saved position collapsed to a section-less CFI that resolves to no section. Reopening a `.md` book then fell back to the start even though the library still showed the correct read percentage. Set each section's `cfi` to `CFI.fake.fromIndex(index)`, the same fake spine CFI foliate synthesizes for single-file formats that omit it (e.g. fb2), so positions round-trip across reopens. Fixes #4862 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
70bad93ebf |
feat(reader): select word on double-click and run instant action or toolbar (#4846)
Double-click (mouse) or touch double-tap on a word now selects that word, like a long-press selection, then runs the configured instant quick action or raises the annotation toolbar when none is set. The iframe posted iframe-double-click but nothing consumed it, so a touch double-tap did nothing (Android has no native double-tap word-select; on desktop the browser already selects the word natively via the pointerup path). - sel.ts: getWordRangeAt expands a caret to its word-like segment via Intl.Segmenter (CJK and Latin); getWordRangeFromPoint resolves the caret at a point and delegates. - useTextSelector: handleDoubleClick selects the word and routes through the existing makeSelection flow (guarded so the programmatic selectionchange echo is ignored). It no-ops when a native selection already exists, so the desktop double-click path is not double-fired. - Annotator: consume iframe-double-click, resolve the visible section doc/index, and set pointerDownTimeRef to 0 so the deliberate double-tap bypasses the touch long-press hold gate before the instant action fires. Tests: unit coverage for the word-range helpers and the selection routing (plus the desktop guard), and an Android CDP e2e for the double-tap gesture on a real device. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7da41a65ad |
feat(widget): add mobile home-screen reading widgets (#1602) (#4842)
Add a resizable home-screen widget on iOS and Android showing recent
in-progress books with cover, reading progress, and tap-to-open.
- One responsive widget: Android resizable 1x1 to 4x3 (one book per
column, up to 3); iOS Small/Medium/Large families. Covers are cropped,
rounded, with a percent badge and a progress bar (baked into the bitmap
on Android, SwiftUI overlays on iOS).
- TTS controls (previous, play-pause, next) appear in 2+ row sizes when
TTS is active, wired to the existing media session. Reading progress
stays live during background TTS via a fraction computed from the baked
offline locations.
- Publishes a snapshot plus downsized cover thumbnails to the iOS App
Group and Android SharedPreferences through a new update_reading_widget
native-bridge command; refresh is debounced and driven by library and
progress changes, TTS, and app backgrounding.
- Tapping a cover opens readest://book/{hash}, switching the reader in
place when one is already open.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
324bb8a366 |
feat(reader): add e-ink screen refresh page-turner action (#4687) (#4822)
Add a bindable "Refresh Page" action to Settings > Behavior > Page Turner that triggers a deep e-ink full refresh (GC16) to clear screen ghosting, gated to e-ink mode on Android. It reuses the existing hardware page-turner key-binding machinery: a new 'refresh' slot in HardwarePageTurnerSettings, shown only when isAndroidApp and the e-ink view setting is on. Pressing the bound key calls a new native bridge command instead of paginating. The native side is device-agnostic: EinkRefreshController probes each vendor mechanism via reflection and stops at the first that works, covering Onyx BOOX (Qualcomm View.refreshScreen), Tolino/Nook (NTX postInvalidateDelayed) and Boyue-style Rockchip (requestEpdMode) without bundling any vendor SDK. A success:false result is a soft no-op on non-e-ink hardware. iOS gets a stub. Verified on an Onyx BOOX Leaf5: the Onyx path fires and performs a visible full GC16 refresh. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
24370ca511 |
feat(reader): render Markdown (.md) files at runtime (#774) (#4816)
Open standalone .md files in the reader without converting to EPUB. A new makeMarkdownBook (src/utils/md.ts) parses Markdown to sanitized HTML with marked + DOMPurify, splits the document into sections at H1 boundaries, and builds an in-memory foliate book (modeled on fb2.js) with a nested heading TOC. DocumentLoader routes .md/.markdown before the TXT path so a Markdown file served as text/plain is not converted to EPUB. Layout, font and theme settings apply the same as for any other format. Relative-image resolution and Markdown bundle/folder packages are left as follow-ups (a standalone file has no sibling-asset access on the web). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
97868f0486 |
fix(reader): keep negative table margins from clipping wrapped layout tables (#4439) (#4808)
A decorative table-of-contents page lays out as nested tables where the inner table pulls itself up with a negative top margin and the CONTENTS heading uses line-height:1em. Since #4400 wraps every table in a `.scroll-wrapper` (overflow:auto), that negative margin bled the heading above the wrapper's clip box and overflow cut off the top half of its glyphs. Hoist any negative margins from the wrapped element onto the wrapper and zero them on the element: the box stays in place, the element sits flush inside it so overflow cannot clip it, and scrollWidth is no longer inflated by the margin so a table that actually fits still gets marked fit. Positive and auto margins are left alone, so an over-wide table still scrolls and a centered table stays centered. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
01a54238ae |
fix(annotator): clean up empty highlight on annotation cancel (#4791) (#4804)
Clicking "Annotate" on a selection eagerly creates a highlight (with an empty note) as the anchor for the note being typed, so the selection stays visible while the NoteEditor is open. Cancelling the note instead of saving left that empty highlight behind: it leaked into the config DB, showed as a stale card in the Booknotes list, and left a phantom yellow highlight. handleHighlight now returns the created BookNote only when it pushes a new record (null when it restyles an existing highlight, which predates the flow and must survive a cancel). handleAnnotate tracks that id via the new notebookNewHighlightId store field; cleanup is keyed on the id, not the cfi, so a fresh selection that collides with an existing highlight's cfi can't wrongly delete it. removeEmptyAnnotationPlaceholder tombstones the tracked placeholder only when it still has no note text, and the Notebook tears its overlay down. Cleanup is presentation-driven: an effect removes the placeholder whenever the creation editor stops being shown (Cancel, Escape, overlay, close, swipe, navigate), plus a second effect for book-switch and reader-close. Save survives the guard and clears the tracked id. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1558078391 |
fix(settings): keep global settings in sync across windows (#4580) (#4803)
On desktop the app runs multiple windows (one library plus one per open book), and each keeps its own in-memory settings loaded once at window open. Global settings persist to a single shared settings.json, and every window writes the whole object on save. A window opened before the user customized a global view setting therefore clobbers that change with its own stale (often default) value on its next save, most visibly a reader window reverting Click to Paginate back to the default on close. Broadcast the global view and read settings after every save and have all other windows adopt them, preserving each window's device-local fields (paths, lastOpenBooks, sync cursors, brightness). The receive path only updates the in-memory store, so there is no save or broadcast loop. No-op off Tauri. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0b4993407c |
feat(reader): add contrast option to PDF/CBZ view menu (#4800)
Add a Contrast stepper to the reader View menu for fixed-layout (PDF/CBZ) documents. It increases and decreases page contrast via a CSS filter on the rendered page images, applies to the whole book, and is stored per-book (local to the current document). The filter is built in applyFixedlayoutStyles by combining any dark-mode invert with the contrast amount into a single filter declaration. Persisted with skipGlobal so it never touches global view settings, and added to FoliateViewer's effect dependencies so the change re-applies across all rendered pages. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
79ae8a48ba |
feat(reader): sync per-book proofread rules across devices (#4781)
Per-book and selection-scope proofread (find/replace) rules were pushed in the synced book config but dropped on pull (applyRemoteProgress only applied location), so they never propagated across devices. Merge them by id on the config pull, mirroring the booknote CRDT path. Library-scope rules keep syncing via the settings replica. - Add updatedAt/deletedAt to ProofreadRule. Delete is now a tombstone for book/selection scope so a removal is not resurrected by a peer's live copy; library-scope deletion keeps the hard splice (settings-replica whole-field LWW already handles it). - Add mergeProofreadRules (by id, updatedAt/deletedAt last-write-wins) and merge into applyRemoteProgress; refresh the live view only when the merged rules actually changed. - Backfill a content-derived id for id-less rules (legacy/foreign/hand-edited) via ensureRuleId, and seed book/library ids from content so the same rule created on two devices dedupes instead of duplicating. Selection rules keep a per-instance unique id. Without this, id-less rules collide on one Map key and clobber each other. - Filter tombstoned rules from the transformer and the manager dialog list. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0589cb4f4a |
fix(reader): stop a quick-deleted highlight from being re-drawn (#4773) (#4779)
The per-relocate re-apply effect reads a memoized annotation index. A highlight deleted in place after the index was built still sits in its bucket, and selectLocationAnnotations trusted the build-time deletedAt filter, so the effect re-drew the just-deleted overlay and left it orphaned on the page until the book was reopened. Re-check deletedAt at the read site: in selectLocationAnnotations and in the sibling globals re-apply loop in Annotator. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
163487b5e3 |
feat(reader): add regex and nearby-words search modes (#4560) (#4764)
Add Calibre-parity search modes to the reader's full-text search. The "Match Whole Words" toggle becomes a single-select mode group: Contains, Whole Words, Regular Expression, Nearby Words. - Regex and nearby-words matching live in the foliate-js submodule (bumped here); the sidebar threads `mode` and `nearbyWords` through. - Nearby distance is chosen with a "within N words" control (5/10/20/50, default 10), not parsed from the query, so trailing numbers stay literal search words. - Per-mode modifiers: Match Diacritics is greyed out for regex (no-op). - Calm inline error for invalid regex / too-few nearby words, a no-results state, and a results-count footer. - Nearby matches render a segmented excerpt emphasizing each matched word and highlight every word in the book. - BookConfig schema v2 -> v3 migrates the deprecated `matchWholeWords` boolean to `mode` (still written for sync back-compat). Also fix two search interactions: - option changes (e.g. within-N-words) now take effect immediately by reading the latest config at search time instead of a stale closure. - closing search from the results nav bar now exits the sidebar search mode, not just the results (search-bar visibility lifted to the store). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7da5f83213 |
fix(reader): make annotation toolbar customization apply to all books (#4760)
The customized annotation toolbar only took effect in the book where it was changed, instead of applying to every book. Root cause: serializeConfig decided which per-book view settings to persist as overrides using a reference check (globalViewSettings[key] !== value). It deep-clones the config first, so array-valued settings like annotationToolbarItems are always a fresh reference and were stored as a per-book override on every save (each progress autosave). On reopen the per-book override shadowed the global value, so a global toolbar change never reached already-opened books. Compare view-setting values by content, not reference, so array/object settings equal to the global value are no longer persisted per-book. This also fixes the same latent issue for paragraphMode, proofreadRules, ttsHighlightOptions and noteExportConfig. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b1346bf16d |
feat(wordlens): en-en glosses, styling, derivation lemmas, display-time cap (#4744)
* feat(wordlens): support en-en monolingual glosses Gloss difficult English words with a short English definition for learners reading English with English hints. - build: buildEnEn + shortDefGloss read ECDICT's English `definition` column (first/primary WordNet sense, POS-stripped, drop ;-example, <=24 word-boundary with trailing-connector trim). New `en-en` CLI branch; buildEnZh/buildEnEn now share a buildEnPack core. - gating: drop the hardcoded `hint === source` rejections (wordlensSection, WordLensPanel) so same-language packs are allowed; availability is decided by the manifest (resolvePack returns null when no pack exists). - data: data/wordlens/en-en.json (26,578 entries) + regenerated manifest.json. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(wordlens): gloss styling, derivation lemmas, display-time cap Builds on the en-en monolingual gloss support with refinements and regenerated packs. - settings: per-book gloss <rt> font size (em) and color in Settings > Language > Word Lens (getRubyStyles reads viewSettings). - en-en hints: WordNet hybrid (a simpler synonym, else a category hypernym, else the ECDICT definition) instead of raw verbose definitions. - lemmatization: gate difficulty by the lemma rank for every English source pair. enBaseFormCandidates now also covers -able/-ible suffixes and negative prefixes (un/in/im/ir/il), so insufferable resolves to suffer. A candidate is accepted when the English definition names the base OR the Chinese translations share a content character, which keeps true derivations (insufferable -> suffer) and rejects coincidental stems (capable -> cap). en-X packs inherit the en-en lemma table. - display cap: the max gloss length is applied at render time in cleanGloss (MAX_GLOSS_LEN), so the packs store the full hint and the cap can change without regenerating data. - tooling: pnpm wordlens:preview to sample pack entries; cache build corpora under data/wordlens/.sources (gitignored). - data: regenerate en-en, en-zh and en-de/es/fr/pt/ru plus the manifest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e982af1725 |
feat(reader): adjust text selection with Shift/Ctrl/Opt+Arrow keys (#4728) (#4738)
Support standard desktop shortcuts for refining an active text selection: Shift+Left/Right by character, Ctrl/Option+Shift+Left/Right by word. Only active while text is selected; otherwise the keys fall through to page navigation as before. Root cause: after a selection the reader container (not the book iframe) holds focus, so Shift+Left/Right keystrokes reach the parent shortcut handler and matched the page-turn shortcuts, turning the page instead of refining the selection. The new onAdjustTextSelection action runs before the navigation actions: when a selection is active it extends the iframe selection via Selection.modify() and suppresses the page turn; an iframe-forwarded key (already extended natively) just suppresses navigation. handleSelectionchange now refreshes the popup/range for keyboard-driven changes (no pointer drag) so the selection toolbar follows the refined selection. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
082edc204b |
fix(sync): sync updated book covers across devices (#4544) (#4731)
* docs: design for syncing updated book data (cover + file) (#4544) Cover-change sync via a content hash (coverHash = partial MD5 of cover.png) plus a cover_updated_at field-level merge timestamp; file updates ride the existing re-import / metaHash dedupe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): sync updated book covers across devices (#4544) Editing a book's cover wrote cover.png locally but changed no hash (the cover is keyed by the file hash), so peers had no signal to re-download it and the change never propagated. Give the cover its own content-addressed version: - coverHash = partial MD5 of cover.png; a peer re-downloads the cover iff the synced hash differs from the local one (idempotent, no churn on identical/re-extracted covers — compatible with the metaHash dedupe). - coverUpdatedAt = field-level LWW timestamp so a page-turn that wins whole-row LWW on updated_at can't clobber a cover edit (mirrors the reading_status_updated_at fix for #4634). Editing a cover recomputes the hash, bumps coverUpdatedAt, and re-uploads only the cover; the server merges cover fields independently; peers re-download on a hash diff. File updates continue to ride the existing re-import / metaHash dedupe (changed file -> changed hash -> re-key). Migration 016 adds cover_hash / cover_updated_at to books. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
942095bcd6 |
fix(reader): make Shift+P toggle, exit, and resume paragraph mode reliably (#4717) (#4725)
Three paragraph-mode problems, all fixed:
- Shift+P inside paragraph mode flashed and re-entered (and pressing it
repeatedly did nothing). A single keypress toggled twice: eventDispatcher
.dispatch() iterated the live listener Set while awaiting each listener, and
the exit's awaited dispatch('paragraph-mode-disabled') let React re-run
useParagraphMode's subscription effect mid-loop, adding a handler the same
dispatch then called. Snapshot the listeners before iterating (dispatchSync
already did), so a listener added during a dispatch can't fire for the
current event.
- Shift+P / Escape only worked when focus sat on the overlay. Handle the
overlay's keys the way a dialog/alert does: focus the dialog element on open
and handle Escape / the toggle shortcut / paragraph navigation in its own
onKeyDown (stopping propagation so the global handler can't double-fire),
instead of a global window listener. Suppress the focus ring on the
programmatically-focused, non-tab-stop container.
- Resume jumped to the chapter start, and repeated enter/exit walked further
back. Two causes: (a) entering/exiting scrolled the underlying view to the
focused paragraph's start, which rewinds a page when that paragraph began on
the previous page — don't scroll on resume/exit (the paragraph is already on
screen); navigation still scrolls. (b) resume preferred the rAF-debounced
store progress and a stored last-paragraph CFI that can come out malformed
and resolve to an empty range, shadowing the correct candidate and sending
findByRange to the first block. Resume from the view's live, foliate-
generated lastLocation CFI first (set synchronously on every relocate,
resolved against the current document so it survives iframe recreation).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
a6d28ffcdf |
fix(reader): add Alt+P proofread shortcut and let Shift+P exit paragraph mode (#4717) (#4723)
On Windows/Linux, Ctrl+P opens the proofread/replace rules but also triggers the browser print dialog, since the selection shortcut handlers return undefined and never preventDefault. Add a print-free `alt+p` binding for Proofread Selection alongside ctrl+p/cmd+p. Also fix Shift+P being unable to exit paragraph mode: the paragraph overlay attaches a capture-phase keydown listener that calls stopImmediatePropagation() on every key while visible, so the global toggle shortcut never reached useShortcuts. Honor the configured "Toggle Paragraph Mode" shortcut directly in the overlay so the same shortcut that enters paragraph mode also exits it. Extract the shared shortcut event-matching into matchesShortcut() in utils/shortcutKeys.ts and reuse it from useShortcuts instead of its private duplicate. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c781aeddaa |
feat(reader): add sticky progress bar with chapter ticks (#4707)
Add an always-visible, opt-in progress bar with chapter tick marks in the persistent footer, so reading progress no longer disappears like the hover footer slider does. - New StickyProgressBar: a 1px rounded-border capsule with a fill and chapter tick marks; display-only, e-ink aware, and RTL safe. Ticks render inside the clipped track so the rounded ends crop them and they never exceed the border. - Chapter ticks come from the TOC, mapped to spine-section start fractions (getChapterTickFractions); the first and last ticks are trimmed so they do not crowd the rounded ends. - Thread the overall size-domain reading fraction through setProgress so the bar fill aligns with the tick domain. - Footer layout: when enabled the bar grows on the left and the info widgets group to the right with even spacing; otherwise the existing layout is unchanged. - Horizontal writing mode only; vertical keeps the current footer. - Add the showStickyProgressBar view setting, a LayoutPanel toggle, and i18n. Closes #1616. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7185dca1a2 |
feat(reader): add save/share button to image gallery toolbar (#4680)
* feat(reader): add save/share button to image gallery toolbar Add a button to the top-right toolbar of the fullscreen image viewer that saves the currently viewed image to the device. It uses the native or web Share flow where available (iOS/Android/macOS, navigator.share) and falls back to a save dialog or browser download otherwise, reusing the existing export path via appService.saveFile. The button icon and label reflect the active flow (share vs save). Adds dataUrlToBytes/imageExtensionFromMime helpers, unit and component tests, and translations for the new strings across all locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(share): write shareable file to a Temp subdirectory to avoid 0-byte share On Android, Tauri's Temp dir is the app cache dir, and the sharekit plugin copies the shared file to <cacheDir>/<name> before firing the share intent. When saveFile wrote the shareable file to the Temp root, that copy became a copy onto itself whose output stream truncated the source to 0 bytes, so the shared image (and any shared export) arrived as a 0 KB file. Write the file to a Temp subdirectory instead so the plugin's copy has a distinct source. Verified on a Xiaomi device: sharing a file in the Temp root truncated it to 0 bytes, while sharing from the subdirectory produced a real, non-empty copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): save image to system gallery on Android The Android share sheet cannot save an image to a file (no file manager registers as an ACTION_SEND target), so the Save Image button now writes the image straight into the system photo gallery via MediaStore. It lands in Pictures/Readest, visible in Gallery and the Files app, with no picker and no storage permission on Android 10+. Adds a save_image_to_gallery command to the native-bridge plugin (Rust + Kotlin MediaStore insert) and an appService.saveImageToGallery method. On Android the Save button uses it; iOS/macOS/desktop/web keep the existing share/export flow, and the button label/icon reflect the actual action. Also includes local agent memory notes that were staged alongside. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6caa376f82 |
feat(reader): Webtoon Mode seamless continuous scroll for image books (#3647) (#4662)
* feat(reader): make fixed-layout scroll gap configurable (foliate-js bump) (#3647) * feat(reader): add webtoonMode view setting + scroll-gap helper (#3647) * feat(reader): Webtoon Mode toggle in the fixed-layout view menu (#3647) * feat(reader): apply Webtoon Mode gap on fixed-layout book open (#3647) * fix(reader): clear Webtoon Mode + reset gap when Shift+J leaves scrolled (#3647) * chore(i18n): translate Webtoon Mode string across locales (#3647) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(deps): bump foliate-js to merged readest/foliate-js#30 (#3647) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
1faa931a0e |
fix(txt): stop detecting measure-word prose as chapters in TXT import (#4658) (#4660)
The Chinese chapter-detection regex treated certain measure words (the classifiers for "letter" and "book") as chapter units and let a title attach directly after the unit. As a result, ordinary prose such as "the first letter" or "the fourth book records..." was split out as bogus TOC entries when importing Chinese TXT novels. Split the unit characters into two explicit tiers that share the same number prefix and stay in one alternation (a single split pass, so a segment mixing chapter and volume headings is handled together): - Chapter units may carry a title attached directly, unchanged. - Volume/measure-word units only start a heading when the title is introduced by a separator (colon, comma, space, or parentheses) or the line ends, never a bare noun directly after the unit. Real volume and chapter headings still match. Adds regex-level and end-to-end tests covering both reported cases. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
86f5502724 |
fix: bot-review robustness fixes (TTS sync, updater, nightly, a11y) (#4659)
Cherry-picked and re-verified the applicable subset of julianshen/readest@fa1b74a0 (its "address PR #15 bot reviews" commit). The fork-only AI-annotation-tool change was dropped — readest has no 'ai' toolbar tool. Each logic fix is covered by a failing-first test. - TTS position sequence is now an app-wide monotonic counter, so a fresh TTSController (constructed per `tts-speak`) isn't dropped by consumers holding `lastSequenceSeen` from a prior session. - share.ts only swallows AbortError (user cancel); other failures — e.g. NotAllowedError when a quick action fires without a user gesture — fall back to the clipboard so the text still reaches the user. - document.isTxt tolerates MIME params (text/plain;charset=utf-8), uppercase extensions (BOOK.TXT), and a nameless Blob, so a TXT can't slip onto the non-text path and yield a null book. - updater getNightlyPlatformKey matches x86_64/aarch64 explicitly; a 32-bit or otherwise unknown arch yields no nightly instead of mis-routing to aarch64. - UpdaterWindow downloadWithProgress resolves on tauriDownload completion even when Content-Length is absent (no more hang on portable/AppImage/Android). - nightly_update.rs uses async tokio::fs::read in the async command. - nightly.yml: serialize runs via a concurrency group (no cancel) and persist-credentials:false on checkouts. - edge TTS route only emits the word-boundary header when it fits under ~8KB; oversized values get dropped by proxies, and the client falls back to []. - RSVPOverlay drops the contradictory aria-disabled on the functional rate button (it opens the pace picker). - nightly verify harness handles artifact stream errors instead of crashing. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
72233e1c6a |
feat: sync reading status across devices and with KOReader (#4634) (#4656)
* docs(sync): design spec for syncing reading status (#4634) Field-level LWW for reading_status (dedicated reading_status_updated_at), a new 'abandoned' status in the Readest UI, and a koplugin bridge to KOReader's native summary.status (whole-library apply + capture). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(sync): implementation plan for syncing reading status (#4634) Bite-sized TDD tasks across 3 parts: A) cloud field-level LWW (reading_status_updated_at on server upsert + client pull-merge), B) 'abandoned'/On-hold status in the Readest UI, C) koplugin bridge to KOReader summary.status (mapping + reconcile + whole-library apply/capture). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): add reading_status_updated_at for field-level status LWW (#4634) * feat(sync): stamp readingStatusUpdatedAt on status change in updateBookProgress Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): stamp status timestamp on explicit library status edits * feat(sync): resolve reading status by its own timestamp in client pull-merge * feat(sync): resolve reading status by its own timestamp in server upsert (#4634) * fix(sync): tighten reading-status merge typing + strengthen test (A5 review) Replace as-unknown-as double-casts at read sites with typed locals (clientBook/serverBook); retain a single as-unknown-as only at the server-wins construction site where the static type is too narrow. Strengthen test 3 to assert both fields with toEqual. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(library): render the 'On hold' (abandoned) status badge * feat(library): add 'Mark as On hold' actions + i18n for abandoned status Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * i18n: translate 'On hold' and 'Mark as On hold' for the abandoned status (#4634) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(koplugin): add reading-status mapping + reconcile between Readest and KOReader * feat(koplugin): persist + sync reading_status_updated_at in LibraryStore Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(koplugin): bridge reading status to KOReader summary.status on library sync (#4634) * test(sync): cover koplugin v1->v2 migration + tighten status-sync tests (final review) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(sync): redesign KOReader first-sync (decisive-only + bootstrap) (#4634) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(koplugin): safe first-sync of reading status (decisive-only + bootstrap) (#4634) KOReader auto-sets summary.status='reading' on first open, and legacy Readest statuses have reading_status_updated_at=0, so pure timestamp LWW let opening a finished book downgrade it. Restrict sync to deliberate statuses (finished/ complete, abandoned/on-hold, unread->clear); never capture KO 'reading'/'New'. On the unsynced baseline (Readest ts=0) conflicts resolve Readest-authoritative, then stamp now_ms to exit bootstrap into steady-state LWW. reconcile now returns write_ko/write_store flags; statussync captures now_ms once and equalizes both sides (convergent, idempotent, resumable). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(koplugin): cover remaining first-sync graph cells + document sort effect (review) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
be5862f08c |
fix(library): group secondary series sort by series name then index (#4652) (#4653)
The Series sort comparator compared only seriesIndex, ignoring the series name. When used as the within-group order for "group by Author → sort by Series", this ranked every book #1 across all series as a block, then every #2, etc., scattering each series instead of keeping it consecutive. Compare series name first, then index, so all books of one series appear together in series order before the next series begins. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ff96c6d3f7 |
feat(annotations): unify highlights and annotations into one record (#3870, #4511) (#4647)
A highlight and its note are now a single BookNote. Adding a note attaches it to the highlight at that CFI (or creates one with the current global style) instead of creating a second record, and a unified record renders as both a highlight overlay and a note bubble. - onDrawAnnotation chooses the draw kind from the overlay value prefix (cfi -> highlight, NOTE_PREFIX -> bubble) instead of annotation.note, so a record with both a style and a note draws both. Fixes notes synced from KOReader losing their highlight (#4511). - handleSaveNote updates the existing annotation at the CFI rather than pushing a new record (#3870); re-styling preserves the note. - unifyAnnotations migration (book config schema v1 -> v2, run in deserializeConfig) collapses existing split highlight+note records into one survivor and tombstones the redundant record (deletedAt) so the merge syncs to the cloud and KOReader. - Sidebar: a note's quoted highlight text uses the theme foreground so it stays legible on the highlight background. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6626db967c |
fix(reader): keep last paragraph's line spacing by making the section skip link a <span> (#4642)
The next-section accessibility skip link is injected nested inside each section's last content element (findSectionEndHost in a11y.ts, added for #4126). The paragraph-layout rule in getParagraphLayoutStyles() targets `div:not(:has(*:not(b,a,em,i,strong,u,span)))`, so nesting a <div> made the enclosing paragraph fail the :has() test and silently lose its line-spacing, word/letter-spacing, text-indent, and hyphenation overrides — but only for the last paragraph of every section, and only in <div>-based EPUBs (common in Chinese-source books). <p>-based books were unaffected because the bare `p` clause matches regardless of children. Create the next-section skip link as a <span> instead. <span> is in the selector's allow-list, so the enclosing paragraph keeps matching. The link is still position:absolute (an out-of-flow 1x1px box) and focusable, so layout and NVDA focus behavior are unchanged. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
495783d045 |
fix(security): harden OPDS proxy SSRF, storage key validation, Stripe check (#4638)
Server-side hardening for three reported web advisories: - OPDS proxy (/api/opds/proxy): add http(s) scheme allowlist, internal/loopback/ link-local host blocklist, and manual per-hop redirect re-validation so a public URL can't redirect into an internal address. Move isBlockedHost into the shared src/utils/network.ts as the canonical blocklist and reimplement isLanAddress to delegate to it (also tightens the /api/kosync LAN check); fetch-url.ts re-exports it. (GHSA-c7mm-g2j2-98cx, GHSA-5g3f-mq2c-j65v) - Storage upload (/api/storage/upload): validate the client-supplied fileName with a new isSafeObjectKeyName helper before building the object key, so a name can't escape the caller's own prefix. (GHSA-mfmj-2frf-vhgw) - Stripe (/api/stripe/check): bind the entitlement to the session owner — reject a Checkout Session whose metadata.userId differs from the authenticated caller. (GHSA-pv88-3727-j7v8) Unit tests added for each path; full suite + lint green. The Tauri-native advisory (GHSA-55vr-pvq5-6fmg) is handled in a separate change. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
757ed8066b |
feat(library): show series and number in list view (#4593) (#4612)
In the library list view, surface each book's series and series number on
their own line, in addition to the description. Previously series info was
only visible by grouping by series or opening a book's details.
- Add `formatSeries(series, seriesIndex)` helper ("Series #N", trims the
name, omits a zero/NaN/negative index) with unit tests.
- In list mode, render a dedicated single-line "Series #N" line above the
description when the book has series metadata.
- Clamp every list line (incl. title) to one line and tighten the row gap
to `gap-1` so the extra line fits the fixed-height row without clipping.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
a30a310a17 |
fix(opds): handle entries with no downloadable format (#4599) (#4611)
An OPDS entry with full metadata and a cover image but no acquisition link — e.g. a Calibre book whose file was removed but kept for tracking borrowed/loaned titles — was classified by foliate-js as a navigation item whose href fell back to the cover image link. Tapping it loaded the image, which is neither XML nor JSON, so the OPDS browser crashed with a JSON parse error. - Bump foliate-js to include the getFeed fix that classifies such metadata-only entries as publications instead of navigation. - PublicationView: show "No downloadable format available" when an entry has no acquisition or stream links. - loadOPDS: defense-in-depth — surface a clear message instead of a raw JSON.parse SyntaxError when a response is neither XML nor JSON. - Add tests covering the Calibre no-format entry and a regression guard that a true navigation entry still classifies as navigation; add the two new UI strings across all locales. Closes #4599 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4908245042 |
feat(reader): Word Wise inline vocabulary hints (#4589)
* feat(reader): Word Wise — inline native-language vocabulary hints Kindle-style Word Wise: a short native-language gloss renders above difficult words as you read (always-on ruby), gated by a CEFR vocabulary-level slider (A1–C2); tapping a glossed word opens the existing dictionary. - Pipeline: CEFR→frequency-rank difficulty, inflection-aware gloss index, pure offset-aware planner (EN regex + jieba for CJK). - Rendering: <ruby cfi-skip>…<rt cfi-inert> injected per occurrence — CFI-transparent (verified), so highlights/bookmarks/progress are unaffected; kept out of TTS word offsets and find-in-book. - Delivery: gloss packs are version-controlled in data/wordwise/, mirrored to R2, and downloaded on demand into local storage (sha-verified, single-flight) when enabled. - Settings: a Word Wise sub-page under Settings → Language (enable, level, hint language, per-pack download/manage, auto-download toggle). - Build tooling: scripts/build-wordwise-data.mjs (ECDICT / CC-CEDICT+HSK / WikDict + FrequencyWords, with lemmatization) and scripts/sync-wordwise-r2.mjs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * data(wordwise): bundled gloss packs + manifest + attribution 13 frequency-trimmed gloss packs (en↔中文 + es/fr/de/pt/it/ru↔en, ~19 MB) generated by build-wordwise-data.mjs from ECDICT (MIT), CC-CEDICT + HSK, and WikDict + FrequencyWords (CC-BY-SA). Source of truth, mirrored to the CDN via `pnpm wordwise:sync`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0f0b4279a7 |
perf(reader): memoize global-annotation fan-out per section (#4575) (#4579)
Highlighting recurring text (e.g. main-character names) as global annotations made page turning very laggy. The `progress` effect re-fans-out every global annotation across every rendered section on EVERY page turn, and each pass re-walks the section DOM, recomputes `view.getCFI()` for every occurrence, and tears down + recreates an SVG overlay per match. The overlays already exist after the first pass, so this is pure wasted work — profiled at ~25–45ms of synchronous main-thread time per page turn for 6 names / 226 occurrences across 2 rendered chapters, multiplied on slower mobile hardware. Memoize, per live section `Document`, which global notes have been expanded (signature embeds `updatedAt`/style/color/text). Subsequent page turns short-circuit to ~0ms. Keying on the `Document` makes invalidation automatic: a re-rendered section gets a fresh document (and fresh overlayer) so its overlays are rebuilt, while edits/recolors bump `updatedAt` and toggling global off clears the memo. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
57501cc520 | feat(updater): nightly update channel (Android/Windows/macOS/Linux) (#4577) | ||
|
|
bfb85c2f68 |
feat(reader): sync paragraph mode & speed reader with TTS read-along (#3235) (#4576)
* docs(reader): TTS-sync design spec for paragraph mode + RSVP (#3235) Hardened via brainstorming + /autoplan (CEO/Design/Eng dual-voice review). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tts): emit canonical tts-position event from TTSController (#3235) Controller emits { cfi, kind, sectionIndex, sequence } alongside the existing tts-highlight-mark/-word events. Monotonic sequence lets downstream consumers (paragraph mode, RSVP — later slices) drop out-of-order positions. Additive; existing events untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): containment+cursor CFI->index mappers for TTS sync (#3235) RSVPController.syncToCfi + setExternallyDriven: containment match (fixes mid-token skip), monotonic cursor + binary search (avoids O(N)-per-word jank, no per-word getCFI), -1/no-op on no match (no silent jump to word 0), timer suspension while externally driven. ParagraphIterator.findIndexByRange: hinted + binary-search containment mapper returning -1 on no match (never first()). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tts): forward tts-position + tts-playback-state onto the app bus (#3235) useTTSControl republishes the controller's canonical tts-position (tagged with bookKey) via a dedicated listener — NOT inside the suppression-gated highlight handlers, so page-follow suppression can't silently desync the modes. Adds tts-playback-state (playing/paused/stopped) so RSVP can track playback without the hook-local isPlaying. Verified by extending the real-foliate-view browser harness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): paragraph mode follows TTS playback (#3235) When paragraph mode + TTS are both active, the focused paragraph follows the spoken position (sentence granularity, all engines). Section-generation contract (stash cross-section position, apply after the iterator re-inits); sync-focus path that does NOT arm isFocusingRef (avoids the relocate-eaten wrong-section paragraph-0 bug); stale-sequence drop; decouple on manual nav, re-engage on next playing. Start-alignment + visible indicator deferred to later slices. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(rsvp): speed reader follows TTS playback (#3235) Edge word-boundary voices: RSVP shows the spoken word via syncToCfi. Non-Edge (sentence-only) voices: sentence-paced estimator (clamp 60..600 wpm from voice rate, hold at +60 words cap, snap to first word on each new sentence mark). RSVP auto-advance suspended while TTS-driven. Decouple on manual nav via a rsvp-manual-nav signal; re-engage on next playing. Cross-section positions re-extract then apply. Pure decideRsvpTtsPosition helper unit-tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): fixed-layout gate + ttsSyncStatus for TTS sync (#3235) Gate sync to reflowable books (D7): fixed-layout reports 'unsupported' and never engages. Both modes expose ttsSyncStatus (idle/following/syncing/ decoupled/unsupported) as the data source for the upcoming indicator. RSVPControl now forwardRef-exposes the status via an imperative handle. Cross-bookKey events ignored (regression-tested). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): 'following audio' indicator for TTS sync (#3235) 5-state pill (following/syncing/decoupled, idle+unsupported render null) shown top-center in the paragraph overlay and as a status row in the RSVP overlay. Decoupled state is the tap-to-resume control; first decouple fires a one-time toast. eink-bordered, glyph+text (no color-only), RTL logical props, touch targets, safe-area top inset. RSVP 'plain' variant matches its themed surface; non-Edge shows '· estimated'. New i18n keys need extraction before merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(rsvp): in-overlay TTS toggle + audio-paced speed control (#3235) Voice-glyph audio toggle in the RSVP control row (trailing, by the gear) starts/ stops read-along from inside the full-screen overlay, start-aligned to the current word (range validated against the live doc). While TTS-driven, the WPM control shows a locked 'Audio pace' affordance that opens a compact rate picker; rate changes go through a new tts-set-rate bus event reusing the existing throttled setRate path. Pure buildRsvpTtsSpeakDetail helper unit-tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(reader): e2e paragraph mode follows TTS across a section boundary (#3235) Real <foliate-view> browser e2e: with paragraph mode active, the focused paragraph follows the TTS walk and re-targets to the new section after a Ch4->Ch5 boundary (proves no stuck wrong-section paragraph-0 / isFocusingRef trap). Asserts on the owning section of the current range. Test-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n(reader): translate TTS-sync strings across 33 locales (#3235) Following audio / · estimated / Resume audio / Stopped following audio / Play audio / Pause audio / Audio pace / Speed follows audio. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): resolve TTS CFI anchors across iframe realms (#3235) RSVP and paragraph follow silently failed to track the spoken word: the CFI anchor from view.resolveCFI(...).anchor(doc) is a Range created in the book iframe's realm, so 'anchor instanceof Range' (top realm) was always false (cross-realm instanceof) -> resolveCfiToRange/applySyncCfi returned null -> syncToCfi never advanced. Add isRangeLike() duck-type (cloneRange is unique to Range) and use it at all 4 CFI-resolution sites. Confirmed live via CDP: before = syncToCfi false (frozen); after = exact word map + RSVP follows Edge TTS at ~171 wpm (audio pace). Unit tests reproduce the cross-realm anchor (jsdom is single-realm so the old code passed there but died in the app). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rsvp): stop estimator/word fight + map transport to TTS play/pause (#3235) Two read-along refinements (verified live via CDP with Edge TTS): 1. No more jump-ahead-then-snap-back flashing. Word-boundary engines (Edge) emit BOTH sentence marks and word boundaries; RSVP was routing sentence -> the estimator (self-paces ~190xrate, up to +60 words ahead) while word positions snapped it back. Now once a word position is seen, sentence positions are ignored and any running estimator is stopped, so words alone drive RSVP. 2. The RSVP transport (center play/pause, Space, center-tap) maps to TTS play/pause while read-along is engaged (tts-toggle-play), instead of RSVP's own suspended timer. Pausing TTS keeps RSVP suspended (no runaway); a full stop releases it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rsvp): keep indicator on pause + reach dict management from RSVP (#3235) - Pausing read-along no longer dismisses the 'following audio' indicator / 'Audio pace' lock (layout shift). New 'paused' sync status keeps the indicator row and WPM lock present while TTS is engaged-but-paused; only a full stop clears them. Verified live via CDP: pause keeps the layout, no shift. - Dict management is reachable from RSVP: the settings dialog is z-50, far below the full-screen RSVP overlay (z-[10000]), so it opened invisibly behind it. handleManageDictionary now exits RSVP first (position saved/resumable) so management shows over the reader. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rsvp): show dict management over RSVP instead of exiting it (#3235) Per feedback: opening dictionary management from the RSVP lookup popup no longer closes the speed reader. The settings dialog is raised above the full-screen RSVP overlay (z-[10000] -> SettingsDialog !z-[10050]) so it shows on top, and RSVP's capture-phase keyboard handler bails while the settings dialog is open so its inputs accept Space and Escape closes settings (not RSVP). Verified live via CDP: management opens over RSVP, RSVP stays active behind it, Escape returns to it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dict): only apply drag-handle margin compensation when the handle shows (#3235) The dictionary sheet header used -mt-4 to compensate for Dialog's drag handle, but that handle is sm:hidden (shown only below sm). On sm+ the handle is display:none, so -mt-4 pulled the header up into the top edge (broken layout when the lookup renders as a sheet on a short/wide window). Mirror the handle's breakpoint: -mt-4 sm:mt-0. Verified live via CDP. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): in-mode TTS audio toggle for paragraph mode (#3235) Paragraph mode already follows TTS, but there was no way to start read-along from inside it. Add an audio toggle to the ParagraphBar (mirroring RSVP's): it starts TTS start-aligned to the focused paragraph (range validated live, + section index) and stops it. Track session-active vs playing so a pause keeps the indicator ('paused' status) instead of collapsing to idle. Pure buildParagraphTtsSpeakDetail helper unit-tested. Verified live via CDP: tapping the icon starts audio from the focused paragraph and the focus follows speech. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): highlight current TTS word/sentence in paragraph mode (#3235) Paragraph mode follows TTS by advancing the focused paragraph, but the spoken word wasn't highlighted within it like normal mode. The overlay renders a CLONE of the paragraph, so the iframe's TTS highlight isn't visible there — reproduce it on the clone with the CSS Custom Highlight API (no DOM mutation, spans inline boundaries natively, leaves the fade-in animation untouched). - TTSController already tags tts-position with kind word|sentence. The hook decides granularity: word boundaries (Edge) drive a per-word highlight; once seen, the coarse sentence event is skipped so the whole sentence doesn't flicker over the current word. Engines without word boundaries (WebSpeech/Native) fall back to the sentence highlight. - Offsets are computed relative to the paragraph start (so they map 1:1 onto the clone's text) and tagged with the paragraph index so a stale highlight never paints the wrong paragraph. Cleared on stop / section change / disabled. - The ::highlight() style mirrors the user's ttsHighlightOptions color+style. Pure helpers (offset math, word/sentence decision, css builder) unit-tested. Verified live via CDP: word highlight tracks Edge word-by-word and follows across paragraph boundaries (news -> ... -> ladies), matching the TTS color. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5a8f0873fa |
fix(library): refresh book cover after editing metadata (#4572)
* fix(library): refresh book cover after editing metadata Editing a book's cover in Book Details and saving showed the old cover until a full reload, in two render paths: - Library grid: handleUpdateMetadata mutated the book object in place, so the memoized <BookCover> compared fields off the same (mutated) reference and skipped re-rendering. Build a new book object via the new getBookWithUpdatedMetadata helper instead of mutating. - Book Details view: BookDetailView renders cover/title/author from the modal's `book` prop, which the parent never re-passed after save. BookDetailModal now tracks the saved book locally (displayBook) and renders the view from it. Adds a unit test for the immutable helper, a BookDetailModal regression test (edit cover -> save -> view reflects it), and a sample-alice.txt fixture for TXT import testing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(agent): add cover-refresh stale-render memory Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
67c22c770b |
feat(reader): Share intent + customizable annotation toolbar (#4014) (#4570)
* docs(spec): annotation Share tool + customizable toolbar (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(plan): implementation plan for Share tool + customizable toolbar (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(annotator): add 'share' annotation tool type and button (#4014) * feat(annotator): add pure toolbar order/visibility helpers (#4014) * feat(annotator): add annotationToolbarItems view setting (#4014) * feat(annotator): add shareSelectedText ladder helper (#4014) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(annotator): render Share tool and honor toolbar order in selection popup (#4014) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(settings): add drag-and-drop annotation toolbar customizer (#4014) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(settings): open the toolbar customizer from the Behavior panel (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(i18n): extract and translate annotation share/toolbar strings (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(annotator): extract canShareText helper, preserve hidden Share on cross-platform edit (#4014) Addresses final-review findings: de-duplicate the triplicated canShare definition into share.ts::canShareText, trim ShareCapableService to the fields actually read, and stop the toolbar customizer from dropping a synced 'share' tool when edited on a non-share-capable device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(settings): WYSIWYG drag-and-drop toolbar customizer (#4014) Rework the customizer per live testing: - Render 'In toolbar' as a faithful, content-width, start-aligned preview of the real selection popup (gray bar, icon-only buttons); 'Available' tools show as labeled chips. - Multi-container dnd-kit pattern: in-place dragging (no DragOverlay, which a transformed modal offsets), pointerWithin collision so empty zones accept drops, live onDragOver reparent, itemsRef to dodge dnd-kit's drag-start handler-capture stale closure. - Add 'Add all' (canonical predefined order) and 'Clear all' shortcuts. - Align zone labels with the SubPageHeader breadcrumb. - Empty toolbar now suppresses the selection popup entirely (no empty bar), while still allowing highlight-edit/notes popups. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(i18n): translate Add all / Clear all toolbar shortcuts (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(annotator): size selection popup to visible tool count (#4014) With the customizable toolbar a fixed-width popup looked sparse for a 2-3 tool toolbar (buttons spread to the corners). Size the popup to the number of visible tools (responsive) capped at the previous max; annotated selections keep the max width since they show highlight options / notes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): reword empty-toolbar hint to 'No tools, drag one here' (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(annotator): render default tools (not Share) in popup layout screenshot (#4014) The visual regression test rendered every annotationToolButtons entry, so adding the Share tool shifted the toolbar to 9 buttons and broke the baselines. Share is hidden by default (added via Customize Toolbar), so the popup screenshot should mirror the default-enabled set — filter to DEFAULT_ANNOTATION_TOOLBAR_ITEMS, keeping the existing baselines valid. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
852d0ae3e9 |
fix(reader): keep dark-mode page body transparent so the bg texture shows, closes #4446 (#4564)
The body.theme-dark catch-all from #4392 painted every section iframe's body with the opaque theme bg in dark mode, occluding the host background texture and poisoning foliate's docBackground capture (so paginated segments and scrolled view backgrounds resolved opaque too). Force transparent instead: the dark page fill already comes from the paginator container / reader grid cell, and book-forced light page backgrounds stay neutralized since the theme-dark fill shows through. Unconditional rather than texture-gated because docBackground is captured once per section load and a gated rule would go stale on live texture toggling. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7f57af8f90 |
perf(cfi): bucket booknotes per chapter and batch-collapse location matcher (#4561)
* perf(cfi): bucket booknotes per chapter and batch-collapse location matcher
When iterating a list of CFIs against the same currentLocation (Annotator
on every page turn, useSearchNav, useBooknotesNav), the standalone
isCfiInLocation collapses the location twice per CFI. With 1000+
booknotes -- which a heavy user reported -- that's 2000 CFI parses
per page turn. The foliate epubcfi.js chunk showed up as ~15% of
self time in Bottom-Up profiles of the release Android build.
Fix:
- createCfiLocationMatcher(location) collapses once and returns a
matches(cfi) predicate that reuses the cached bounds. O(N) calls
become 1 collapse + N compares.
- getCfiSpinePrefix(cfi) extracts the spine path via pure string ops
(no CFI.parse round-trip) for use as a chapter bucket key.
- Annotator builds annotationIndex = { bySection, globals } via
useMemo([config.booknotes]) once when booknotes change, not per
page turn. The progress-driven effect then only scans the current
chapter's bucket -- ~50 CFIs in a typical book instead of all 1000.
globals are pre-filtered too.
- useSearchNav / useBooknotesNav switch to the batched matcher for
the same reason.
Includes parity tests covering empty/malformed inputs, equality
shortcut, prefix shortcut, in-range, and out-of-range cases.
* fix(annotator): keep note-only annotations in the per-chapter bucket
The booknote bucketing gated entries on `item.style`, which dropped
note-only annotations (a `note` with no highlight style/color, created
via the Notebook flow) from the per-relocate re-apply path. Their note
bubble was no longer redrawn on relocate or when booknotes changed while
a section stayed rendered.
Restore the original two-list semantics: bucket on style OR note, then
classify per location (annotations need a style, notes need a note).
Extract the logic into a dedicated, unit-tested `annotationIndex` module
(buildAnnotationIndex + selectLocationAnnotations) instead of inlining it
in Annotator, matching the reader/utils domain-named convention.
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>
|
||
|
|
9dc41e7adf |
feat(reader): reference page numbers from EPUB page-list with manual page count fallback (#4549)
Add a 'Reference Pages' reading progress style that shows physical book page numbers in the footer progress info: - When the book carries a page list (EPUB3 nav page-list or EPUB2 NCX pageList — foliate-js already parses both and resolves the current pageItem on relocate; it was just never consumed), display the current page label and use the highest numeric label as the total, so a trailing roman-numeral index page can't corrupt the total (#672). - When the book has none, a per-book 'Reference Page Count' input appears; the reading fraction is mapped linearly onto the entered count (#4542). The count is saved per book only and never propagates to global view settings. - Falls back to percentage display when neither source is available. Verified with the sample books from #672: Caleb's Crossing (EPUB3 page-list, 419 pages) and Count Zero (EPUB2 NCX pageList/page-map, 346 pages — chapter 2 lands exactly on page 22 per its page-map), plus a stripped no-pagelist copy for the manual-count path (175/350 at 50%). Closes #672 Closes #4542 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ceddee3793 |
feat(library): search a book on Goodreads from the library and reader (#4543) (#4548)
Adds a quick "Search on Goodreads" action so readers can jump straight to Goodreads to track a book instead of retyping the title there. - Library: a Goodreads button in the Book Details view (works on web, desktop and mobile) searching the book's title + author, plus a "Search on Goodreads" item in the desktop right-click context menu. - Reader: Goodreads is added as a built-in web-search provider so highlighted text (e.g. a short-story title inside a magazine) can be looked up on Goodreads. Disabled by default like the other built-ins; enable it in Settings -> Dictionaries. Both surfaces are used because the native context menu is desktop-only; the Book Details button covers web and mobile. Adds a shared openExternalUrl() helper and translates "Search on Goodreads" across all locales (the Goodreads brand name is kept verbatim). Closes #4543 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
cfe2bb9116 | fix(reader): Android text selection breaks on the first word of hyphenated paragraphs (#4545) | ||
|
|
d6e981e568 |
fix(reader): hide footnote aside border again when custom fonts are loaded (#4438) (#4540)
PR #4383 inlined custom `@font-face` rules at the very front of the iframe stylesheet, ahead of the `@namespace epub` declaration that lived inside `getPageLayoutStyles`. Per the CSS spec a `@namespace` rule is only honored when it precedes every style and `@font-face` rule; a misplaced one is silently ignored. That dropped the namespaced `aside[epub|type~="footnote"]` hide rule, so EPUBs whose footnote `<aside>` carries a `border: 3px #333 double` rendered a stray horizontal line below the annotation marker — but only for users who had custom fonts loaded (otherwise `customFontFaces` is empty and `@namespace` stayed first). Hoist the `@namespace` declaration to the very start of the assembled stylesheet, before the inlined custom `@font-face` rules, and drop it from `getPageLayoutStyles`. Custom faces still precede the `--serif`/`--sans-serif` font lists that reference them, preserving #4383's first-paint behavior. Verified in Chromium against the reported book's CSS: the aside goes from `display: block` (3px double border visible) back to `display: none`. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
cf41e7d50d |
feat(rsvp): apply reader font face/family settings to the RSVP word (#4519) (#4537)
RSVP displayed the focal word in a hardcoded monospace font, ignoring the reader's configured font. Resolve the reader's body font-family (serif or sans-serif chain, per the "Default Font" setting, including the chosen typeface, CJK font, and any user-imported custom font) and apply it to the RSVP word display. Custom and additional fonts are already mounted in the top document where the overlay renders, so the resolved family resolves the same typeface. The monospace fallback is kept only when no font setting is available. Extracts the font-family list building from getFontStyles into a shared buildFontFamilyLists helper and exposes getBaseFontFamily for top-level UI. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d165e8df2c |
fix(reader): turn automatically when highlighting across pages (#4487)
* fix(reader): turn automatically when highlighting across pages (closes #1354) * refact: Refactor time retrieval to use Date.now() * fix(reader): rework auto page-turn as a corner-dwell gesture Rework the initial #1354 implementation into a deliberate corner-dwell gesture that works across platforms, and fix popup positioning for cross-page selections. Trigger: - While a text selection is active, hold any engagement signal — the pointer (web/desktop/iOS), the Android native touchmove, or the selection caret — inside a screen corner for 500ms to turn one page: bottom-right goes to the next page, top-left to the previous. - One turn per engagement: a signal must leave the corner and return to turn another page, so the user controls it one page at a time. - The corner is a quarter-ellipse of radius 15% of each axis, measured against the reading frame (the <foliate-view> rect) inset by the page content margins, so the zone lands on the text — not the margin/footer or a sidebar — and the pointer can actually reach it. Per-platform signals: - web/desktop/iOS: the iframe pointermove, mapped to window coordinates via the iframe element's on-screen rect. - Android: the selection caret (the only signal during a native handle drag, where the handles live in a separate window so their touches never reach the Activity) plus a throttled (~10/s) native touchmove added in MainActivity.dispatchTouchEvent for content drags. Android scroll-pin (#873): an active selection pins the container scroll, which reverted the turn; suspend the pin during the turn and re-anchor it to the page we land on. Popup positioning: getPosition decided which selection end was on-screen using window bounds, so a cross-page selection's off-screen start (which maps behind the sidebar but inside the window) read "in view" and pinned the popup off the visible page. Test visibility against the reading frame instead, and for a multi-page selection anchor to the last on-screen line. Also: logical view.prev()/next() (RTL-correct); skip in scrolled mode; pass contentInsets down to the annotator. 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> |
||
|
|
1e26c5d765 |
fix(nav): bound section-scan concurrency to keep zip.js writers from ERRORED-ing (#4528)
On Tauri, section.loadText() drives a plugin-fs open/read/close round trip per call. The unbounded Promise.all in computeBookNav and enrichTocFromNavElements can fire 200+ concurrent IPC chains against a long-spine EPUB, saturate the JS↔Rust bridge or the fd pool, and cause individual reads to reject. The zip.js TextWriter then transitions to ERRORED, surfacing as 'Cannot close a ERRORED writable stream' and silently dropping TOC fragments for the affected sections. In the worst case the rejection propagates through Promise.all and prevents the reader from opening the book.
Hoist the OPDS module's runWithConcurrency to utils/concurrency.ts (zero behaviour change for OPDS) and reuse it in computeBookNav and enrichTocFromNavElements, capped at 128. The cap was binary-searched against the worst-case repro (Android emulator + dev mode + 250-section EPUB): 30/64/128 pass, 200 fails. Section-internal loadText/createDocument dedupe is unchanged.
The worker pool also isolates per-section failures: the outcome shape ({item,result}|{item,error}) lets us log and skip the offending section instead of aborting the entire build as Promise.all did. Even if a future workload pushes past the cap, the reader still opens.
|
||
|
|
82bd90afc5 |
feat(reader): random-access file reads on Android via rangefile scheme (#4534)
* feat(reader): random-access file reads on Android via rangefile scheme NativeFile's per-chunk Tauri IPC (open+seek+read+close) is slow on Android, and RemoteFile can't replace it because the WebView mishandles Range requests on intercepted custom-protocol responses — it re-applies the offset to the already-sliced body, so any non-zero-start range returns corrupt data or net::ERR_FAILED (Chromium 40739128, tauri-apps/tauri#12019/#3725). Add a `rangefile` custom URI scheme that carries the byte range in the URL query (?path=&start=&end=) instead of a Range header. With no Range header the WebView delivers the 200 body verbatim, while bytes still stream through the network stack rather than the IPC bridge. The handler is scope-gated by asset_protocol_scope (same boundary as the asset protocol) plus an explicit traversal/NUL/relative guard. RemoteFile.fromNativePath() drives the scheme on Android (query-carried range, X-Total-Size for size); nativeAppService.openFile routes Android reads through it with a NativeFile fallback. Verified on-device (Android 16 / WebView 147) via CDP: byte-equal reads at every offset, ~1.8x faster small scattered reads, real book opens/renders; all out-of-scope/traversal/NUL paths rejected 403. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(rust): run cargo unit tests in rust_lint The rust_lint job ran only fmt + clippy, so the crate's ~40 Rust unit tests (parsers, parser_common, and the new range_file tests) never executed in CI. Add `cargo test -p Readest --lib` to rust_lint — the frontend dist is absent there, but generate_context! already compiles without it (clippy proves this) and the unit tests run headless. Also add a `test:rust` pnpm script and document it as verification done-condition #6. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9d8062ae27 |
fix(reader): keep table background matching the page in dark mode (#4419) (#4426)
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> |
||
|
|
df66c63a07 |
fix(txt): recover author for 【】-titled web-novel TXT imports, closes #4390 (#4423)
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>
|