forked from akai/readest
1a85f251c034e0d4acc6a880edba822be8f734eb
938 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1a85f251c0 |
fix(sync): flush pending Readest cloud push when the reader closes (#4535)
Closing a book within the SYNC_PROGRESS_INTERVAL_SEC (3s) auto-sync debounce window saved progress locally but dropped the pending Readest cloud push on teardown. The `sync-book-progress` close handler only reset the pull gate and re-pulled — it never pushed — so other devices stayed on the previous cloud-synced position until the book was reopened (issue #4532). Flush the debounced push at the start of `handleSyncBookProgress`, before the pull gate is reset, so the latest local position reaches the cloud before the view tears down. `syncConfig` reads `configPulled` synchronously, so flushing while the gate is still open takes the push branch. Mirrors the existing KOSync close-time `pushProgress.flush()`. The manual Sync button shares the event and now becomes a true two-way sync (push local, then pull remote). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2ade769956 |
feat(toc): show current reading page under the active item (#4513) (#4525)
Insert a "Current position" row in the TOC sidebar directly under the highlighted section, indented one level deeper, with an open-book icon and the live reading page number. Clicking it navigates to the exact current reading location (progress.location) — distinct from the section header, which jumps to the section start. Implemented via a pure buildTOCDisplayItems() helper that injects the synthetic row after the active item, keeping the active item's index stable so the existing TOC auto-scroll logic stays untouched. The page number uses the same muted color as the other rows. Also fills in the missing "File Path" i18n translations across all locales (surfaced by i18n:extract) and records project memory notes. Closes #4513. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
11d796361e |
perf(import+open): native Rust EPUB/MOBI parser, OPF prefetch, parallel TOC enrichment (#4369)
* perf(epub): add native EPUB parser in Rust
Introduce a Rust-side EPUB pre-parser exposing three Tauri commands:
* parse_epub_metadata - title/author/cover + partialMD5 in one
shot, for the import hot path
* parse_epub_full - OPF + nav.xhtml + toc.ncx bytes plus a
manifest size table, for the reader open
hot path
* extract_epub_cover_full - full-resolution cover bytes, for the
lock-screen wallpaper writer
All three avoid ferrying multi-MB blobs across the JS<->Rust IPC
boundary. Cover bytes returned by parse_epub_metadata are downscaled
to a webview-friendly JPEG when the long edge exceeds the library
thumbnail size.
No JS callers yet -- wired up in the following commits.
* perf(import): use native EPUB parser and downscale covers on Tauri targets
On Tauri (desktop/iOS/Android), importBook now forwards EPUB
metadata + cover extraction to the Rust parse_epub_metadata
command and reuses the partialMD5 it returns, skipping the
foliate-js full archive parse and the second pass over the file
for hashing.
As a side effect, the cover written to cover.png is downscaled
to a webview-friendly JPEG (long edge <= 512px), shrinking the
on-disk thumbnail from multi-MB to ~30-60KB per book. To keep
the lock-screen wallpaper feature unchanged, useAutoSaveBookCover
now pulls the original full-resolution cover via the Rust
extract_epub_cover_full command instead of copying the (now
downscaled) cover.png; falls back to the thumbnail when the
native path is unavailable.
Web targets and non-EPUB formats keep the existing path.
* perf(reader): prefetch EPUB OPF/nav from Rust on book open
When opening an EPUB on Tauri targets, DocumentLoader now calls the
Rust parse_epub_full command up-front to pull the OPF, EPUB3 nav,
NCX and the central-directory size map in a single IPC. The
foliate-js zip loader is wrapped so that loadText() of these
entries (and a synthetic META-INF/container.xml) is served from
that in-memory cache without inflating through zip.js, while
all other assets keep flowing through the original loader.
A small in-flight dedupe is added to the spine-text loader so the
nav pipeline (loadText + createDocument back-to-back on the same
href) doesn't pay for two zip.js inflate calls per chapter on
first open.
Reader store / app service plumbing: readerStore.openBook now
resolves an absolute on-disk path via the new
appService.resolveNativeBookFilePath / bookService.resolveNativeBookFilePath
helper and threads it into DocumentLoader as nativeFilePath so
the prefetch can fire. Web targets, non-EPUB formats and books
without a managed/external on-disk path skip the prefetch and
take the original code path.
* perf(nav): parallelize section scans and memoize fragment lookups
computeBookNav now processes sections via Promise.all instead of
a sequential for-loop, and within each section issues loadText()
and createDocument() concurrently. Combined with the in-flight
loadText dedupe added to the zip loader, each chapter pays for a
single zip inflate per nav build, and the inflates of different
chapters overlap.
enrichTocFromNavElements is restructured into two concurrent
phases: a cheap '<nav' substring filter on the inflated text, and
a parsed-document walk for the survivors. Most chapters fall out
in phase 1 without ever being parsed.
In fragments.ts, calculateFragmentSize now consults a
per-section position cache (makeFragmentPositionCache) so the
N-fragment loop is O(N) over the chapter HTML instead of O(N²).
A small isCfiAddressable guard is added to skip elements that
foliate-js's CFI generator can't address (documentElement, body
itself, detached nodes, nodes outside <body>) — these previously
threw and spammed console.warn for every fragment, now they
silently fall back to the section CFI.
* perf(import): use native MOBI/AZW/AZW3 parser on Tauri targets
On Tauri (desktop/iOS/Android), importBook now forwards
MOBI/AZW/AZW3/PRC metadata + cover extraction to the Rust
parse_mobi_metadata command and reuses the partialMD5 it returns,
skipping the foliate-js full-buffer parse and the second pass over
the file for hashing. Mirrors the existing EPUB native fast-path
added in e3fc4767 — bookService tries EPUB first, then MOBI; both
bridges fall back to the foliate-js DocumentLoader when the native
path is unavailable (web target, parse error, format mismatch).
The new mobi_parser is built on the mobi crate (KF7+KF8 reader,
zero JS-side touch). It reads title, author, publisher, ISBN, ASIN,
publish date, language, subjects and description from the MobiHeader
+ EXTH records, resolves the EXTH 201 cover offset against the PDB
image-record table (with ThumbOffset / first-image fallbacks), and
strips KindleGen's HTML wrapping in EXTH 103 so the description goes
into the library DB as plain text. The parsed cover is funneled
through the same maybe_resize_cover path as EPUB, so MOBI library
thumbnails are also clamped to a 512px-long-edge JPEG.
Cover-resize / partialMD5 / RawCoverImage are extracted into a new
parser_common module shared between epub_parser and mobi_parser, so
a single tweak (e.g. raising the thumbnail target) applies to every
native importer and the partialMD5 implementation can't drift between
the two paths (a divergent algorithm would silently re-import every
existing book under a new hash on the first run).
Web targets and non-Kindle formats keep the existing path.
* test(tauri): verify native Rust EPUB parser parity with foliate-js
Add a Tauri WebView parity suite (epub-parser-parity.tauri.test.ts) that
cross-checks the native Rust parser against foliate-js on the same fixtures:
parse_epub_metadata / parse_epub_full (title, author, language, identifier,
publisher, published, subjects, partialMD5, OPF + per-entry size table), and
that opening with the native prefetch produces the same BookDoc and
computeBookNav (TOC) output as the pure-JS path.
Fix a parity divergence the suite caught: the Rust OPF parser mapped
dcterms:modified onto `published`, but foliate-js keeps them separate and
leaves `published` empty -- so EPUB3 books carrying only the mandatory
dcterms:modified got a bogus publication date on the native import path. Map
only dc:date now; add regression tests.
Test infra:
- vitest.tauri.config.mts: add optimizeDeps (mirroring vitest.browser.config)
so foliate-js-importing tauri tests load -- otherwise esbuild's dep scan
can't resolve '@pdfjs/pdf.min.mjs', pre-bundling is skipped, and the CJS
deps fail to import ("Importing a module script failed").
- capabilities-extra/webdriver.json: fix __test__ -> __tests__ fs scope typo
so import tests can open fixtures under src/__tests__/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(import): foliate-js owns EPUB/MOBI metadata via standalone extractors
Rust contributes only the mechanical work that's expensive on a
WebView — partialMD5, the downscaled cover, and (for EPUB) the raw
OPF bytes Rust already had to read for cover resolution. Metadata
extraction is delegated to foliate-js's two new standalone entry
points (`parseEpubMetadataFromXML`, `readMobiMetadata`) so the
import-path BookDoc and the reader-path BookDoc share a single
parser implementation.
EPUB
- `parse_epub_metadata` returns
`{ partialMd5, cover, coverMime, opfPath, opfBytes }`. OPF bytes
are a free byproduct of the cover-resolution scan.
- `tryNativeParseEpub` runs `parseEpubMetadataFromXML` on the OPF
bytes and assembles a lightweight BookDoc stub (metadata +
getCover). The importer doesn't drive `DocumentLoader.open()`, so
no zip central-directory scan, no nav/ncx inflate, no spine walk.
- `coverMime` is preserved so `bookService.importBook`'s
`cover.type === 'image/svg+xml'` branch still routes SVG covers
through svg2png.
MOBI / AZW / AZW3 / PRC
- `parse_mobi_metadata` returns `{ partialMd5, cover, coverMime }`.
`tryNativeParseMobi` runs foliate's `readMobiMetadata` on the
same File, which uses `MOBI.open(file, { metadataOnly: true })`
to parse PalmDB + MobiHeader + EXTH and short-circuit before the
MOBI6 / KF8 init() that walks every text record.
- `Book.metadata.identifier` is foliate's `mobi.uid.toString()`
(PalmDB UID), the canonical MOBI identifier the reader path uses.
bookService.importBook
- EPUB and MOBI native branches consume the bridge's BookDoc stub
directly. The stub's `getCover()` returns the Rust-downscaled
blob, falling back to foliate's own `getCover` thunk when Rust
didn't extract a cover.
Other
- Drop the unused `base64` Rust dependency: cover bytes go over IPC
as `Vec<u8>` (Tauri 2 transports them natively, like opfBytes /
navBytes / ncxBytes).
- Drop the `nativePrefetch` option on `DocumentLoaderOptions`; no
caller passes it. `nativeFilePath` keeps driving `parse_epub_full`
on the open hot path.
Tests
- vitest.tauri parity test asserts byte-equal partialMD5, cover
presence parity, OPF bytes that decode to a real `<package>`
document, and that `parseEpubMetadataFromXML` on those bytes
produces the same user-visible metadata fields (title / author /
language / identifier / published) as `DocumentLoader.open()`.
* test(tauri): add War and Peace MOBI fixture for native parser parity
The .tauri parser-parity suite previously had no .mobi/.azw3 asset, so the native MOBI parser (metadata + EXTH cover resolution) was uncovered. Adds a real KF8 MOBI ("War and Peace") to enable MOBI parity coverage against foliate-js.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(foliate-js): bump submodule to readest/foliate-js main (91191ca)
Replaces the ad-hoc 02f435a with the merged main commit 91191ca, which lands the standalone OPF/MOBI metadata extractors (parseEpubMetadataFromXML, readMobiMetadata) the import fast-path depends on (foliate#19), plus the RTL multi-view rect-mapper fix (foliate#20). The extractor code is byte-identical to 02f435a, so the bridges are unaffected.
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>
|
||
|
|
ad23fbba9f | fix(reader): dismiss annotation popup when selection clears (#4483) | ||
|
|
c15e850252 |
fix(reader): shrink hidden page-nav buttons on Android so they don't eat long-press (#4501)
On Android, the four 80x80 page-navigation buttons stay mounted on top of the foliate viewer even when hidden (opacity-0). pointer-events:none can't be used on Android (it breaks touch propagation to the iframe), so the prev/next-section buttons already have an h-4 w-4 fallback for the hidden state. The prev-page / next-page buttons were missing this fallback and therefore kept covering ~80x80 hot zones in the lower-left and lower-right of the page, swallowing long-press touches on the first/last words of the bottom two lines so they could neither be highlighted nor open the toolbar. Apply the same h-4 w-4 fallback to those two buttons. |
||
|
|
4d1205fdf5 |
fix(reader): stop zoomed image pan from flickering on desktop, closes #4451 (#4465)
The desktop mouse-drag handlers were bound to the moving <img>, so the cursor crossing the (transition-lagged) image boundary fired onMouseLeave and repeatedly aborted/restarted the drag — the flicker. Touch was fine because it tracks on the full-screen container. Track the drag on `window` while dragging (mirroring the touch path), disable the transform transition during the drag so the pan is 1:1, and set will-change: transform (the transform-gpu class is overridden by the inline transform, so its GPU hint was lost). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4eeed74cdd |
fix(webdav): always sync book covers, not just when syncBooks is on (#4445)
Cover uploads were nested inside the `syncBooks` toggle in both the
batch path (`syncLibrary`) and the per-book reader path
(`pushBookFileNow`). With the default `syncBooks: false`, covers
silently never reached the WebDAV server even though `config.json`
did, so receiving devices ended up with progress + notes synced but
no shelf art.
Covers are conceptually metadata, not bytes:
- they're tiny (~30–60 KB after the import-time downscale);
- they cannot be regenerated on a fresh device that doesn't hold
the book bytes (custom covers from metadata services in
particular are completely unrecoverable without sync);
- cloudService already treats them as metadata-grade in its
download path (`downloadBookCovers`, `downloadBook(onlyCover)`).
Two changes:
1. `WebDAVSync.ts::syncLibrary` — moved `pushBookCover` out of the
`if (options.syncBooks)` block; it now runs alongside
`pushBookConfig`, before `pushBookFile`. Step ordering in the
header doc-comment was updated to match.
2. `useWebDAVSync.ts` — extracted a standalone `pushBookCoverNow`
callback (gated only on `allowPush`, with its own
`coverSyncedRef` for per-instance dedupe), and dropped the cover
ride-along that lived at the tail of `pushBookFileNow`. The
open-book effect now fires `pushBookCoverNow` and
`pushBookFileNow` in parallel via `Promise.all` (different remote
paths, no reason to serialize), and the manual-push event handler
triggers both independently.
The WebDAV pull path was already independent of `syncBooks`, so no
changes are needed there — receiving devices will pick up the newly
mirrored covers automatically.
|
||
|
|
d8fbf5fe08 |
fix(reader): show KOReader progress-synced as a top-right hint (#4461) (#4463)
KOReader sync displayed the "Reading Progress Synced" notification as a centered info toast that blocks the text for fast readers, while Readest's own cloud sync uses an unobtrusive top-right hint. Route the notification through the same 'hint' event (HintInfo, top-right, ~2s auto-dismiss) that useProgressSync uses, instead of the centered 'toast'. This covers both KOSync paths that apply remote progress (the auto-apply receive/silent flow and the conflict-resolved "use remote" flow); the interactive conflict-resolution dialog is unchanged. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
578b7ba14f |
fix(reader): restore annotation list auto-scroll to the nearest item (#4428)
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> |
||
|
|
7128e8964e |
fix(reader): suppress Android image callout freezing the image viewer (#4425)
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> |
||
|
|
4abbc0254c |
fix(reader): stop footer progress info painting a stray focus ring (#4397) (#4418)
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>
|
||
|
|
726f53a64b |
fix(reader): use Tauri clipboard plugin for copy on Android (#4409)
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 |
||
|
|
3a81e09911 |
fix(reader): scroll oversized blocks in-place instead of turning the page (#4400) (#4415)
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> |
||
|
|
458ad7510c |
fix(reader): scroll wide EPUB tables horizontally (#4391)
* 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> |
||
|
|
97191a57c0 |
fix(reader): stop reading ruler creeping down on scroll (#4386) (#4388)
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> |
||
|
|
e8675fb7eb |
fix(reader): inline custom @font-face rules in iframe stylesheet (#4383)
* 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> |
||
|
|
d0071a6bcb |
fix(sync,reader): discard malformed sync CFIs; fix swipe background flash (#4370)
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> |
||
|
|
ef603852b7 |
feat(tts): hotkey to highlight the currently-spoken sentence (#4085) (#4368)
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> |
||
|
|
c23c21d37d |
fix(kosync): reflowable conflict comparison via local CFI; scrolled-mode + library fixes (#4367)
* feat(kosync): compare reflowable conflicts via locally-resolved CFI percentage KOReader reports progress as a percentage from its own pagination, which isn't directly comparable to Readest's progress. For reflowable books, resolve the remote XPointer to a local CFI and compute the equivalent fraction (getRemoteLocalFraction), comparing that against the local percentage and falling back to the reported percentage only when it can't be resolved locally (non-XPointer progress or a missing section). The resolved fraction also drives the conflict-dialog remote preview so the shown value matches what was compared. Loosen the conflict threshold to 0.01 when the remote progress was last pushed from this same device (remote.device_id === local deviceId), so sub-page drift between a push and the next pull doesn't prompt. Render sync percentages with 2 decimals via formatProgressPercentage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): correct scrolled-mode reopen drift over background-image sections Bump the foliate-js submodule to include the scrolled-mode reopen drift fix for sections with background images, and add a browser regression test plus its EPUB fixture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(library): redirect to login on pull-to-refresh when signed out Guard the pull-to-refresh handlers so an unauthenticated user is sent to the login screen instead of attempting a library pull and OPDS subscription check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(memory): add kosync conflict + toc/scrolled-restore notes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): prevent CFI crash on inert-only section bodies Reopening/paginating across a background-image or otherwise content-less section could crash with "Cannot destructure property 'nodeType' of 'param' as it is undefined" in foliate's fromRange, aborting the relocate so the reading position was never saved. Bumps the foliate-js submodule to 569cc06 (visible-range walker skips cfi-inert skip-links; isTextNode/isElementNode are null-safe) and adds a regression test reproducing the exact crash. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(kosync): keep auto-push working when a pull finds no real conflict In the 'prompt' strategy, pullProgress set syncState to 'conflict' unconditionally on every pull that returned remote progress, even when promptedSync found no actual difference. Since auto-push only runs while 'synced', and a pull fires on every book-open and window re-activation, progress stopped being pushed. promptedSync now returns whether a real conflict was surfaced, and pullProgress only stays in 'conflict' for genuine conflicts (otherwise 'synced'). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): show most recent sync time and reorder settings tabs Library settings menu now reports the latest of the book/config/note sync timestamps as "Synced …" instead of only the books timestamp. Reorder the settings tabs so Integrations precedes TTS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
11666be5ee | fix(reader): collapse TOC to the current chapter's path by default (#4366) | ||
|
|
92b3c9db48 |
fix(kosync): resolve progress CFI via its own spine section (#4364)
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>
|
||
|
|
aa318904b5 |
fix(reader): show full bookmark ribbon in scrolled mode header (#4365)
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> |
||
|
|
78794499a2 |
fix(dictionary): correct System Dictionary platform gating on web and iPad (#4362)
* fix(dictionary): keep other dictionaries usable when System Dictionary syncs to an unsupported platform `dictionarySettings.providerEnabled` is whole-field synced across devices, so enabling System Dictionary on macOS/iOS sets the flag on web/Linux/Windows too. There the row is hidden and the feature is a no-op, but the settings UI read the raw flag and locked every other dictionary's toggle read-only. Gate the lock on `isSystemDictionaryEnabled(settings)` — the same platform-aware check the annotator uses — so it matches real lookup behavior and never triggers where the system dictionary can't run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dictionary): dispatch system dictionary handoff by native OS (fixes iPad) iPadOS sends a desktop "Macintosh" user agent, so the UA-based `getOSPlatform()` reported iPad as 'macos' and the handoff invoked the macOS-only `show_lookup_popover` Rust command that iOS never registers ("Command show_lookup_popover not found"). Derive the OS from the app service's `is*App` capability flags (sourced from the Tauri OS plugin, correct on iPad) via a new synchronous `getInitializedAppService()` accessor, so iPad routes to the iOS plugin command path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ui): constrain reader View Options dropdown to h-8 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(agent): note System Dictionary platform-detection and synced-flag patterns Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f1ae050768 |
fix(ui): refine reader side panels and their empty states (#4361)
* 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> |
||
|
|
789d031222 |
feat(reader): line-aware reading ruler (#4358)
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> |
||
|
|
36e11de332 |
feat(reader): swipe-to-adjust brightness gesture on mobile (#3021) (#4356)
* docs: design spec for gesture-based brightness control (#3021) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: revise brightness-gesture spec per /autoplan review (#3021) CEO+Design+Eng dual-voice review. Key fixes: capture-phase listener (bubble-phase could not suppress foliate paginator), opt-out toggle, 18px threshold, selection guard, brightness seed race, rAF teardown, e-ink stepped overlay, contrast capsule, perceptual curve reuse, listener-level test harness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): swipe-to-adjust brightness gesture on mobile (#3021) Left-edge vertical swipe adjusts screen brightness on iOS/Android, with a Sun-icon progress overlay. Capture-phase non-passive listener suppresses the foliate paginator / page-flip / UI-toggle handlers; selection guard, strip reservation in scrolled mode, eager brightness seed, rAF throttle + teardown. Opt-out toggle in Settings > Behavior > Device (default on). Perceptual curve shared with the menu slider. Pure-helper + listener-level tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): detect brightness-swipe edge by screenX; i18n + shorter label (#3021) On-device fix: paginated mode lays the iframe doc out as wide side-by-side columns, so clientX/documentElement.clientWidth are document coordinates and a left-edge touch on a later page never fell inside the strip (armed stayed false). Detect with screenX against the parent window width, matching usePagination. Also: translate the two new setting strings across all locales and shorten the toggle description to 'Slide along the left edge'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7bdd3ecdee |
perf(sidebar): virtualize BooknoteView and memoize derivations (#4352)
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.
|
||
|
|
6405ba31c8 |
fix(reader): keep TOC scrolled to the current chapter on refresh (#4353)
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> |
||
|
|
2f5e583653 |
feat(annotations): configurable export link type + dedicated Import Annotations modal (#4350)
* 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> |
||
|
|
3c134380b7 |
feat: add empty state hints and loading indicators for annotations, bookmarks, notes, font import, and Moon+ Reader import (#4338)
* feat: add empty state hints and loading indicators for annotations, bookmarks, notes, font import, and Moon+ Reader import - BooknoteView: show 'No annotation yet' / 'No bookmark yet' when empty - Notebook: show 'No note yet' when no notes/excerpts exist - Annotator: add loading overlay with spinner during Moon+ Reader import - mrexpt: yield to event loop every 5 entries to keep spinner animating - CustomFonts: show in-place loading card during font import, spinner transitions to font name without layout jump * fix(ui): respect e-ink overlay styling and drop dead className branch - Annotator: use modal-box on the mrexpt import overlay so eink picks up the no-shadow + 1px border override automatically. - CustomFonts: collapse importing-card clsx ternary whose branches were identical into a flat className. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
bb81d6270f |
fix(reader): keep Android paginated text selection from jumping back to first rendered section (#4342)
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> |
||
|
|
e29331bea9 |
fix(sync): prevent cross-device progress overwrite; retry first pull on flaky networks (#4341)
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> |
||
|
|
648c35b334 |
feat(reader): add disableSwipe option to disable swipe-to-paginate (#4335)
Issue #4288: users with hardware page-turn buttons (e.g. e-ink readers) want to disable swipe-to-paginate so accidental finger drags during highlight selection don't flip the page mid-annotation. The existing "Tap to Paginate" toggle only covers taps; swipe was always on. - New `disableSwipe: boolean` on `BookLayout` (default `false`, declared right after `disableClick`). - foliate-js submodule bump: paginator gates `#onTouchMove` and `#onTouchEnd`'s snap-to-page on a new `no-swipe` attribute, so native touch behaviour (text selection) stays intact. - `FoliateViewer` sets/removes the `no-swipe` attribute alongside `animated`, and the `ControlPanel` toggle pushes the change to the live renderer so it takes effect without a viewer reset. - The fixed-layout swipe interceptor in `usePagination` also bails when `disableSwipe` is on, covering both reflowable and fixed- layout books. - New "Swipe to Paginate" UI row directly below "Tap to Paginate"; both can be off simultaneously. - i18n: 33 locales translated. Also polish: rephrase the two helper texts under "Read books in place" in `ImportFromFolderDialog`. The previous copy ("Copy no book into the library to save space.") used an awkward double-negative; the new wording is clearer and the locked variant drops "registered as" / em-dash for a plain two-sentence form. i18n updated. Closes #4288 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4e01e13ee7 |
fix(library): make bookitem-main shrink to match cover in fit mode (#4331)
* 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> |
||
|
|
ff605e000d |
feat(library): in-place import from registered external folders (#4315)
* feat(library): in-place import with cloud sync and symmetric local delete
Adds an `inPlace` option to importBook so a source file inside a
registered external library folder is referenced directly via
`book.filePath` instead of being copied into Books/<hash>/. Sidecars
(cover, config, nav) still live under Books/<hash>/.
ingestService routes through shouldImportInPlace, which marks an
import in-place when the absolute source path lives under any of
`settings.externalLibraryFolders` and is NOT inside a per-root
`Books/` subtree. The Readest data dir (`customRootDir`) is
intentionally excluded — that directory is Readest's home and
should freely hold hash copies; in-place is for user-registered
roots (Duokan, Calibre, Moon+ Reader, an iCloud mirror, …).
Cloud sync treats in-place books as first-class:
- uploadBook reads bytes from (book.filePath, 'None') when set.
The cloud key is unchanged, so a peer downloading the book
lands it under Books/<hash>/ as a normal hash copy.
- useBooksSync strips `book.filePath` before pushing — it is a
device-local path that is meaningless on any other device.
- ingestService no longer skips upload for in-place books;
autoUpload / forceUpload behave like any other book. Only
transient imports opt out.
- deleteBook 'local'/'both' now physically removes the source
file at book.filePath (base 'None'). Local-delete semantics
are symmetric with hash-copy books: the local copy is gone,
the cloud backup remains, a future pull restores under
Books/<hash>/. removeFile errors are swallowed.
New `SystemSettings.externalLibraryFolders?: string[]` (no UI yet;
registration entrypoint lands in a follow-up). Added to
BACKUP_SETTINGS_BLACKLIST alongside `localBooksDir` /
`customRootDir` so device-local paths don't ride cloud backups.
Tests: cloud-service, ingest-service, and backup-settings suites
cover in-place delete, multi-root matching, per-root `Books/`
guard, and the backup-strip.
* feat(library): one-tap "read in place" toggle in folder import
Surface the in-place / copy choice as a single "Read books in place (don't copy)" checkbox in the Import-from-Folder dialog. When the user opts in, the chosen directory is registered in `settings.externalLibraryFolders` and ingestService's `shouldImportInPlace` will route the books straight to importBook with `inPlace: true` — no copy into Books/<hash>/, sync still works, local delete still removes the source file (the symmetry was set up in the previous in-place commit).
User experience:
- First-time users hit the toggle once per library folder. The choice is also persisted to localStorage so subsequent dialog opens default to whatever they picked last.
- Repeat imports from a folder that's already registered as an external library folder force the toggle ON and disable it, with a help line explaining that imports from this folder are always in-place. The check is exact-string (after path normalization) so registering /Users/me/Duokan only locks the toggle for that exact path — picking /Users/me/Downloads after Duokan still shows the toggle in its normal state.
- URL-ingress / drag-drop replays go through `runFolderImport` without the dialog and default `readInPlace: false`. They still benefit from in-place automatically when the dropped path lives under an already-registered root, because that decision is made by `shouldImportInPlace` based on settings, not by the dialog flag.
Mechanics:
- ImportFromFolderResult gains `readInPlace: boolean`. ImportFromFolderDialog gains an `initialReadInPlace` prop (seeded from the new `readest:lastImportFolderReadInPlace` localStorage key) and an `isRegisteredExternalRoot` predicate it uses to render the locked / unlocked toggle.
- runFolderImport calls a new `registerExternalLibraryFolder` helper that appends the chosen directory to `settings.externalLibraryFolders` and persists settings, but only when `result.readInPlace` is true. `isRegisteredExternalRoot` does the inverse lookup the dialog needs. Both helpers normalize paths the same way `shouldImportInPlace` does so the predicate matches the ingest layer.
- The new feature has no effect for users who never flip the toggle: `externalLibraryFolders` stays empty, the path-prefix check in `shouldImportInPlace` returns false for every import, and books continue to be copied into Books/<hash>/ exactly as before.
Self-healing for externally-removed in-place books:
Once the dialog lets users opt their library into in-place mode, the source file becomes a piece of state Readest doesn't control — another app may rewrite it (e.g. Duokan persisting reading progress into the epub), the user may move it in Finder, or an external drive may unmount between sessions. Previously, clicking such a book would navigate into the reader, fail inside loadBookContent's `fs.openFile(book.filePath, 'None')` with a low-level IO error, flash an "Unable to open book" toast, and auto-bounce back to the library — leaving the stale library record in place so the next tap reproduces the same dance.
BookshelfItem.handleBookClick now probes availability before navigating, but only for purely-local in-place books (`book.filePath && !book.uploadedAt && !book.deletedAt`). If `appService.isBookAvailable` returns false — which for in-place books means the recorded `book.filePath` no longer exists at the OS level — we dispatch `delete-books` for that hash and show an info toast explaining the removal, instead of opening the reader.
Scope is intentionally narrow:
- Cloud-synced books still flow through `makeBookAvailable`'s on-demand download path; missing local copies trigger a re-download, not a deletion.
- Hash-copy books (no `filePath` set) are not probed: a missing Books/<hash>/ file under normal use signals a bug or filesystem corruption, not user intent, and silently dropping the record would hide the real problem.
- The dispatched delete-books event reuses the existing Bookshelf deletion path, so sidecar metadata and selection state are cleaned up the same way as a user-initiated delete. For in-place books that path doesn't touch any file outside Books/<hash>/, so the now-missing source location (or whatever the user did with it externally) is left alone — symmetric with 165f15a6.
* fix(library): centralize book content resolution
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
|
||
|
|
e0ce6c8c22 | feat(reedy): Appendix A · Phase 4 — custom thread UI on AgentRuntime (#4308) | ||
|
|
6bc4a96b99 |
feat(reedy): Phase 1B — wire Reedy into the chat, settings, and Sources UI (#4296)
* feat(reedy): wire RetrievalBackend interface + metrics into the chat adapter
Phase 1B backend integration. Adds a RetrievalBackend interface so
TauriChatAdapter holds a uniform reference instead of branching across the
file, with two impls — LegacyIdbBackend (wraps the existing IDB ragService
unchanged) and ReedyBackend (lazy-opens reedy.db, adapts the active
provider's embedding model to Reedy's narrower shape, exposes a Vercel
`lookupPassage` tool). selectBackend() gates Reedy behind both
aiSettings.reedy.enabled AND isTauriAppPlatform() per plan D15 so the MVP
cohort is desktop-only.
The Reedy path streams via streamText({ tools: { lookupPassage }, stopWhen:
stepCountIs(3) }) with a status-aware system prompt that tells the model
how to phrase responses for each RetrieverStatus value.
Replaces the module-global `lastSources` + 500ms poll with a per-instance
ReedySourceStore keyed by a synthetic per-turn id the adapter generates,
so the Sources dropdown stops racing on global state. Both legacy and
Reedy backends now feed citations through the same store; the UI is
backend-agnostic via a shared SourceItem shape both ScoredChunk and
RetrievedChunk satisfy.
Adds the reedy_metrics table to the reedy migration (versioned with
app_version + session_id + turn_id per row) plus a ReedyMetrics writer
that ReedyBackend uses to record indexing-lifecycle and tool-use events.
Always-on local; no network egress. NoopReedyMetrics keeps construction
cheap before the DB opens.
Tests: retrievalBackend selectBackend gates, ReedySourceStore semantics
(append/replace/subscribe/clear), ReedyMetrics debounced batching +
exportBundle, and a TauriChatAdapter contract test that asserts the
Reedy/legacy code-paths pass the right args to streamText.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reedy): UI wiring — settings toggle, clickable sources, feedback bundle
Phase 1B UI integration. AIAssistant now constructs the active backend
(legacy or Reedy) via selectBackend with the platform gate from M1.7,
wires a ReedySourceStore for the chat adapter, and routes Sources-dropdown
clicks to `getView(bookKey)?.goTo(source.cfi)` when the source has a CFI.
Legacy-path sources still render as static rows because they have no CFI.
AIPanel grows a 'Reedy Retrieval (Beta)' BoxedList with the toggle and a
'Send Reedy feedback' button that calls exportReedyMetricsBundle and
triggers a JSON download of the last 90 days of events. The toggle is
disabled on web with an explanatory description per plan D15.
Thread accepts an onSourceClick callback and renders each source as a
button when its source carries a CFI, otherwise as a static div — so the
Sources dropdown is backend-agnostic via the shared SourceItem shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ca17131f2a |
fix(reader): keep New Chat button visible above Android nav bar and force theme contrast (#4287)
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. |
||
|
|
5e366018df |
fix(cbz): ComicInfo metadata + CBZ page count + WebDAV i18n (#4282)
* fix(cbz,i18n): ComicInfo metadata + CBZ page count + WebDAV i18n Closes #4253 (ComicInfo.xml not read) and #4255 (CBZ shows "1 page left"). CBZ / ComicInfo (foliate-js submodule + Readest derivation): - comic-book.js: find ComicInfo.xml in subdirectories too, parse description / subject / identifier / published / series fields beyond the prior name+position pair. Series Count populates the canonical `belongsTo.series.total`; no top-level duplication. - bookService.ts / readerStore.ts: derive `metadata.seriesTotal` from `belongsTo.series.total` in parallel to the existing series / seriesIndex derivation. - ProgressBar / FooterBar / DesktopFooterBar: drop the hard-coded `pagesLeft = 1` for fixed-layout books and compute it from `section.total - section.current`. FooterBar uses `FIXED_LAYOUT_FORMATS.has(bookFormat)` so CBZ picks `section` (correct image count) instead of `pageinfo` (locations). - ProgressBar: switch the remaining-pages text to "in book" for fixed-layout titles (no chapter structure) and keep "in chapter" for reflowable books. WebDAV refactor for translation coverage: - WebDAVBrowsePane / SyncHistoryPanel called `t(...)` (passed as a prop) instead of `_(...)`. The i18next-scanner only looks for `_`, so ~53 strings were unreachable and shipped in English to every locale. Switched both components to call `useTranslation()` themselves; helpers that aren't React FCs take `_: TranslationFunc` so the scanner sees the literal calls. - WebDAVClient.checkConnection now returns a `code` discriminator (`SERVER_URL_REQUIRED` / `AUTH_FAILED` / `ROOT_NOT_FOUND` / `UNEXPECTED_STATUS` / `NETWORK`); raw English `message` is reserved for the dev console. New `formatConnectError` and `formatSyncError` helpers in WebDAVForm translate via a switch where each branch is a literal `_('...')`. Same treatment for the sync-failure path that previously surfaced raw e.message. - "Syncing 0 / {{total}}" is now parameterized as "Syncing {{n}} / {{total}}" with n=0 at startup so the digit formats naturally and the template can be reused mid-sync. - "Cleanup · {{count}} book(s)" hard-coded options used unsupported ternary; rewrote as plural-aware key. i18n scanner fix (i18next-scanner.config.cjs): - vinyl-fs walked into directories whose names end in source-file extensions (Next.js route folder `runtime-config.js/`, Playwright screenshot folder `*.test.tsx/`) and crashed with EISDIR. Resolved by expanding globs via `fs.globSync` and filtering to files only before handing to the scanner. TypeScript-syntax sites that broke esprima during extraction: - WebDAVBrowsePane / WebDAVForm: `(e as Error).message` and `failed[0]!.title` inside `_(..., options)` arguments. Replaced with `e instanceof Error ? e.message : String(e)` and `failed[0]?.title ?? ''` — also runtime-safer. User-facing em-dash cleanup: - Removed em-dashes from translation keys across SyncHistoryPanel / WebDAVForm / WebDAVBrowsePane / SyncPassphraseSection / send/page / replicaCryptoMiddleware / AIPanel. Tagline in `layout.tsx` kept. Locale translations: - ~2400 translations applied across all 33 locales for the keys that were either newly extractable, freshly worded, or pre-existing but untranslated. Zero `__STRING_NOT_TRANSLATED__` remain after the run. Misc: - next.config.mjs: drop `eslint.ignoreDuringBuilds: true` so build runs the same lint as CI. - Collection type: add `total?: string` for ComicInfo series count. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(test): fix vitest invocation, run with 4 workers `pnpm test:pr:web` was chaining `pnpm test -- --watch=false`, which pnpm expanded into: dotenv -e .env -e .env.test.local -- vitest -- --watch=false The second `--` made vitest treat `--watch=false` as a positional file pattern, not a flag. Vitest then fell back to defaults (in CI's non-TTY env that still meant a one-shot run, so the suite passed), but the worker pool was effectively serialized for big chunks of the 243-file run — wall ~90 s on a 4-vCPU runner where the parallel-sum of phases was ~236 s (≈2.6× effective parallelism). Replace the chained pnpm invocation with a direct call to `vitest run --maxWorkers=4`, matching the 4 vCPUs the GH Actions ubuntu-latest runner provides. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5c82351ab9 |
feat(integrations): add WebDAV sync to Reading Sync settings (#4204)
* feat(integrations): add WebDAV sync to Reading Sync settings Adds a WebDAV entry under Settings -> Integrations -> Reading Sync with configure/browse UI, library-wide Sync now, and per-book sync of progress, annotations and (opt-in) book files + covers. Reading progress and annotations are always synced when WebDAV is enabled; only Sync Book Files stays as a toggle since it's bandwidth-heavy. * feat(webdav): add diagnostic sync history panel and document viewSettings invariant Surface a per-run history for the WebDAV "Sync now" button so users can self-triage failures without rummaging through the dev console — a screenshot of the panel is now enough to file a useful bug report. The same change tightens the docs around viewSettings so the "device-local UI preferences" boundary is impossible to misread on the next refactor pass. Sync history panel: * New WebDAVSettings.syncLog ring buffer (cap 10), persisted alongside the rest of settings so a screenshot survives across app restarts. WebDAVSyncLogEntry captures startedAt, finishedAt, status (success / partial / failure), trigger, the eight counters from SyncLibraryResult, the toast text, and an optional per-book failure list with a phase tag (download / upload-config / upload-file). * SyncLibraryResult gains a failedBooks: SyncFailureEntry[] field. The two existing failure points in syncLibrary (download catch, upload catch) now record per-book reason+phase via formatFailureReason(), which keeps the persisted blob small by stripping stacks/whitespace and capping length at 200 chars. * WebDAVForm.handleSyncNow now timestamps the run, builds an entry from the result on success/partial paths and from the caught error on failure paths, and appends through a fresh-read appendSyncLogEntry() so concurrent toggle changes can't clobber the log. * New SyncHistoryPanel + SyncStatusBadge + SyncHistoryDetails components render the log inline in the Settings page. The detail row groups counters into three semantic columns (activity, skipped, outcome) on a six-column grid so labels can wrap freely while numbers stay tabular and right-aligned. Per-book failures render as a separate stack below the counters. viewSettings invariant: * buildRemotePayload and pullBookConfig already implement the right thing — only progress/location/xpointer/booknotes travel; viewSettings stays device-local. Comments now spell out the contract on both sides so future contributors don't reintroduce viewSettings on the wire by mistake. * fix(webdav): preserve prior state across reconnect, drop stale closure in ensureDeviceId Two bugs in the WebDAV sync flow surfaced during review: 1. WebDAVForm.handleConnect rebuilt the entire `webdav` settings block from the four credential fields the user just typed, dropping `deviceId`, `syncBooks`, `strategy`, `syncProgress`, `syncNotes`, `lastSyncedAt`, and `syncLog` on every reconnect. Most concerning is the deviceId rotation: a disconnect + reconnect made the next sync look like a brand-new device, defeating the cross-device clobber detection encoded in `RemoteBookConfig.writerDeviceId`. Extract a pure helper `buildWebDAVConnectSettings` that spreads the previous webdav object first so reconnect is non-destructive, matching the sibling pattern in KOSyncForm. 2. useWebDAVSync.ensureDeviceId merged the new deviceId into the closure variable `settings`, which can be stale when `pullNow → pushNow` fires back-to-back on book open or when the settings panel writes a sibling field concurrently. Read latest settings via `useSettingsStore.getState()` to match the pattern already used in `updateLastSyncedAt` and `persistWebdav`. Adds three unit tests for the new helper, including the reconnect preservation invariant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(webdav): address review observations on encodePath, pull skip, and remote GC Three follow-ups from the review pass on top of 3f721d04. Each one was called out as a smaller observation the reviewer noted but did not push: * WebDAVClient.encodePath silently re-escaped literal % characters despite a comment claiming existing %-escapes are preserved. A caller that pre-encoded a space as %20 would see %20 become %2520 in the request URL, breaking any path that came in already escaped. Tokenise each segment into already-escaped %XX runs and everything-else, and only run encodeURIComponent on the latter. Add four unit tests exercising pure-unicode, pure-pre-escaped, mixed, and root-slash paths. Implementation note: two regexes are needed because a /g RegExp.test is stateful and would skip every other token in this map; the split regex has /g for the iteration, the classifier regex is anchored without /g for the per-token check. * OPEN_PULL_SKIP_MS doc-comment claimed it catches the close-then-reopen flow, but useWebDAVSync unmounts on reader close so lastPulledAtRef resets to 0 — the new instance always passes the cooldown check on remount. The guard actually only fires on re-invocations of the open-book effect inside one hook lifetime (book-to-book navigation, double-render before hasPulledOnce flips). Rewrite both the constant's doc-comment and the call-site comment to match the real semantics. * WebDAVSync push path doesn't DELETE the per-hash directory of a tombstoned book. The deletion *is* propagated through library.json so other devices hide the book, but storage on the WebDAV server grows monotonically. Add a TODO at the pushLibraryIndex call with a sketch of what a future garbage-collection sweep would need (a per- device acknowledgment field on RemoteLibraryIndex so we don't wipe data a peer hasn't seen the deletion for yet). * refactor(webdav): extract WebDAVBrowsePane and SyncHistoryPanel from WebDAVForm The WebDAV settings form was nearing 1500 lines and hosted three loosely related surfaces — credential entry, sync controls + manual trigger, and the in-app file browser — that didn't share much state. Reviewer flagged it as a refactor candidate; this commit does the actual split. * WebDAVBrowsePane (new, 534 lines): owns currentPath, the directory listing, per-entry download status, the navigation handlers and the per-file icon / filename helpers. Reads credentials from the settings prop and otherwise reaches for envConfig / useLibraryStore / useAuth itself rather than threading them through props (matches how the rest of the integrations panels are wired). * SyncHistoryPanel (new, 293 lines): the diagnostic history surface plus its three private helpers (SyncStatusBadge, formatSyncSummary Line, formatSyncTimestamp, SyncHistoryDetails). Moved verbatim from the inline definitions at the bottom of WebDAVForm — the component was already presentation-only and accepting the translation fn as a prop, so no API change. * WebDAVForm (676 lines, down from 1456): keeps the mode switch (configured vs. not), the credential form, the sync sub-controls (Upload Book Files / Sync Strategy / Sync now button), and the large handleSyncNow effect — those last two are intrinsically tied to the settings store and would have just been pushed back up the prop chain by any extraction. The standalone SyncHistoryPanel and WebDAVBrowsePane are now mounted as siblings inside the configured branch. No behavioural change — both new files run the same effects, build the same JSX, and read/write the same store fields as before. All existing webdav-related unit tests still pass. Resolves the last of the reviewer's smaller observations on 3f721d04 (file length). * fix(webdav): stream book uploads to avoid renderer OOM on large files Both syncLibrary (manual Sync now in WebDAVForm) and useWebDAVSync (per-book auto/manual sync triggered on book open) materialised the full book binary as an ArrayBuffer in the V8 heap before PUTting it. With multi-hundred-megabyte PDFs / scanned books, the renderer either accumulates buffers across sequential pushes (library sync) or blows its heap ceiling on a single book (per-book sync), surfacing as a blank white screen on desktop and a binder-OOM kill of the WebView on Android. Add a BookFileStreamingLoader option to pushBookFile that, on Tauri targets, hands the file path off to tauriUpload's Rust-side streamer so bytes never enter JS. The HEAD short-circuit is shared across both paths, so steady-state syncs still cost a single round-trip per book. Web targets keep the buffered fallback (no streaming HTTP primitive available there). Wire the streaming loader through SyncLibraryOptions.loadBookFileStreaming for the library Sync now path, and inline it in useWebDAVSync.pushBookFileNow for the per-book path. Covers stay on the buffered loader — they're capped at a few hundred KB and don't justify widening the API. * fix(webdav): keep Sync now state alive across Settings navigation/close WebDAVForm tracked the library-wide Sync now run in component state, so any navigation that unmounted the form (drilling back to the Integrations list, or closing the SettingsDialog entirely) destroyed the in-flight indicator while syncLibrary's promise kept running off-thread. On return the user saw a re-enabled button with no progress affordance, an empty Sync History (until the run finally finished), and could trigger a second concurrent syncLibrary against the server. Hoist isSyncing / progressLabel into a process-local zustand store (webdavSyncStore) and consume it from WebDAVForm. The store outlives any single mount, so re-mounting the form picks up the running sync's state on first render — button stays disabled, progress label keeps ticking, and the re-entrancy gate (now reading the live store rather than a stale closure) blocks duplicate clicks. Also surface 'Syncing…' in the IntegrationsPanel row so users get the cue without drilling into the sub-page. Not persisted: the store dies with the renderer, which is the right semantic — a sync killed by app exit shouldn't look like it's still going on next launch. * feat(webdav): cleanup mode for orphan book directories on the server WebDAV pushes set Book.deletedAt as a tombstone but never DELETE the per-hash directory on the server, so the remote Readest/books/ tree accumulates dead entries from books the user deleted long ago. Add a dedicated cleanup mode in the WebDAV browser to evict them in batch. Cleanup mode is reached via a new sweep button next to Refresh. Entering it pins the listing to Readest/books/, filters down to directories whose local Book carries deletedAt, and replaces the per-row icon with a checkbox. The footer carries a single right-aligned Delete from server action; selecting one or more rows and clicking it sends a confirm dialog (appService.ask, so it actually blocks on Tauri) and then runs sequential DELETEs against the server. Each row splices out of the listing the moment its DELETE returns, so the listing itself is the progress indicator; the button keeps a stable width by always reserving space for the spinner via the invisible class. The local library is left untouched. Book.deletedAt is the authoritative deletion signal in readest's sync model — clearing or rewriting it here would cause sibling devices to either resurrect the book or lose the deletion event. Restore is therefore not offered: the per-entry download button already provides full recovery (tauriDownload + ingestFile streams the file back, ingestFile clears deletedAt as a side-effect, and the next sync round-trip merges remote progress and notes), and a metadata-only restore would leave users staring at unopenable shelf rows whenever the bytes had been GCed off local disk. Browse mode is friendlier too. Per-hash subdirectory rows under Readest/books/ resolve their hash to the local library's title and short-form hash for skimmability; soft-deleted entries get a folder-off icon plus a 60% dimmed title (a redundant signal for touch platforms where the desktop-only hover tooltip doesn't fire). Cleanup runs are persisted into the existing sync history with a kind: 'cleanup' discriminator and a booksDeleted counter, so destructive batch operations are auditable alongside regular Sync now runs without polluting the common case (the new counter is zero-suppressed on plain sync entries). * test(webdav): cover deleteDirectory and deleteRemoteBookDir Pin the contract of the cleanup-mode delete plumbing: HTTP method, Depth: infinity header, Authorization header and target URL on the low-level deleteDirectory; success/failure/auth-failure routing and per-hash path construction on the high-level deleteRemoteBookDir. Status-code semantics are exercised end to end (200/204 ok, 404 idempotent, 401/403 AUTH_FAILED, 5xx generic, network throw NETWORK), so a future refactor can't silently drop the explicit Depth header or merge the auth-failure path into the per-book result struct without tripping a regression. --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
ae81cd0151 |
feat(annotator): support global highlights that fan out across all matching positions (#4257)
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. |
||
|
|
62b5ed8138 |
feat(send): handle shared URLs from system share sheets (iOS + Android) (#4256)
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> |
||
|
|
dabdcdcc53 |
fix(macos): fix traffic lights position on macOS 26 (#4247)
* 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>
|
||
|
|
97221b8d23 |
fix(ios): suppress native text-selection menu over annotation tools (#4231)
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>
|
||
|
|
05da6bdf43 |
feat(dictionary): add system dictionary provider for macOS, iOS, and Android (#4219)
Hand selected words off to the platform's native dictionary surface when the user opts into the new "System Dictionary" entry under Settings → Languages → Dictionaries. The setting is exclusive: enabling it disables all other providers (and vice versa) so the in-app lookup button either always opens the popup or always invokes the OS — no mixed states. Per platform: - macOS: AppKit's -[NSView showDefinitionForAttributedString:atPoint:] via a top-level Tauri command in src-tauri/src/macos/system_dictionary.rs. Anchored at the selection's bottom-center (CSS pixels mapped into NSView coords), so the inline Lookup HUD appears just below the highlighted text without raising Dictionary.app to the foreground. - iOS: UIReferenceLibraryViewController presented as a half-detent pageSheet on iPhone (medium → large drag-to-expand) and as a formSheet on iPad. Implemented in the native-bridge plugin. - Android: ACTION_PROCESS_TEXT intent with EXTRA_PROCESS_TEXT_READONLY, dispatched without createChooser so users get the standard system disambiguation dialog with "Just once / Always" buttons. Reports unavailable=true when no app handles the intent so the TS layer can silently skip rather than open an empty chooser. Web/Linux/Windows hide the row entirely. The provider is a sentinel — the registry filters it out of the popup tab list (it has no in-popup UI) and the annotator's handleDictionary checks isSystemDictionaryEnabled to dispatch directly to the native bridge before opening the in-app DictionaryPopup. |
||
|
|
952304a956 |
fix(sync): push books row alongside in-reader progress auto-sync (#4209)
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> |
||
|
|
c8fabd331c |
fix(reader): resolve KOReader sync conflict against non-KOReader servers (#4205)
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> |
||
|
|
ba6e5899e5 |
feat(reader): RSVP CJK character mode and whole-word highlight (#4199)
* 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> |
||
|
|
3620c61038 |
feat(reader): import annotations from Moon+ Reader (.mrexpt) (#4174)
* 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>
|
||
|
|
ad1c2d6bb0 |
fix(reader): filter Magic Mouse wheel events to stop accidental page turns (#4195)
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> |