Commit Graph

1241 Commits

Author SHA1 Message Date
Huang Xin 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>
2026-05-30 21:02:52 +02:00
Huang Xin 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>
2026-05-30 20:33:28 +02:00
Huang Xin 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>
2026-05-30 18:07:29 +02:00
Huang Xin 11666be5ee fix(reader): collapse TOC to the current chapter's path by default (#4366) 2026-05-30 17:37:59 +08:00
Huang Xin 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>
2026-05-30 11:17:58 +02:00
Huang Xin 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>
2026-05-30 11:16:07 +02:00
Huang Xin 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>
2026-05-30 09:51:22 +02:00
Huang Xin 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>
2026-05-30 08:29:41 +02:00
Huang Xin bed31e8181 feat(library): add Manage Cache to advanced settings (#4359)
Add a "Manage Cache" item to the library Advanced Settings menu (native
mobile apps only) that opens a modern dialog showing the combined size and
file count of the app's reclaimable storage, with a confirm-gated clear that
reports per-file progress.

- iOS clears Cache + Temp + Documents/Inbox; Android clears Cache + Temp.
- Multi-source helper (getCacheEntries/getCacheStats/clearCacheEntries) with
  unit tests; per-file failures are counted, never abort the run.
- Dialog uses the centered-hero + btn-contrast design language, theme-neutral
  progress, and is e-ink correct.
- i18n: new strings translated across all locales (+ en plural forms).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:57:07 +02:00
Huang Xin 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>
2026-05-29 19:16:53 +02:00
Huang Xin 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>
2026-05-29 15:28:42 +02:00
loveheaven 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.
2026-05-29 10:25:38 +02:00
Huang Xin 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>
2026-05-29 10:22:30 +02:00
Zeedif c5a1a3afeb feat(opds): add facet navigation and quick catalog registration in header (#4348)
* feat(opds): add facet navigation and quick catalog registration to header

- Add an options dropdown in the header to navigate OPDS feed facets on compact viewports. - Implement an "Add to My Catalogs" dialog to save the current feed, inheriting credentials, headers, and config. - Render a standalone shortcut button in the header when no facets are present, hiding the dropdown.

* fix(opds): scope window rounding to full route + guard duplicate add

Reviewing the facet-navigation feature surfaced three issues in the
quick-add flow and an unrelated window-rounding change:

- Restore the standard full-screen-route rounding pattern on the OPDS
  browser. The header change had dropped the `isRoundedWindow` guard
  (rounding maximized/fullscreen windows leaves gaps at the edges) and
  switched to left-only corners (a docked-sidebar pattern). Match the
  library/auth/user/reader pages: `isRoundedWindow && window-border
  rounded-window`.
- Guard "Add to My Catalogs" against re-adding a catalog whose URL is
  already saved. `addCatalog` dedups by contentId and would silently
  overwrite the existing entry while toasting "added successfully"; now
  it detects the duplicate via `findByUrl` and shows an info toast.
- Replace `feed!.facets!` non-null assertions with optional chaining.

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

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 07:58:04 +02:00
Huang Xin 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>
2026-05-29 07:48:00 +02:00
Huang Xin ce0ab5cc61 feat(library): add secondary "Then by" sort with smart defaults (#4347)
Adds a primary/secondary sort pair so users can group by author and have
each author's books drilled-in list sort by series, without touching the
sort menu each time. Closes #4307.

- New "Then by..." picker in the library view menu (None + same keys as
  primary). Secondary acts as tiebreaker for the global sort, and as the
  in-group ordering when the user drills into a non-series group.
- Smart defaults derived from groupBy, surfaced as "(Auto)" in the menu
  and resolved at sort time so user picks are never overwritten:
  - groupBy=Author + secondary=none -> Series
  - groupBy=Series + librarySortByAuto -> primary becomes Series
- librarySortByAuto flips off as soon as the user makes any explicit
  primary pick; subsequent groupBy changes then respect that choice.

Settings: librarySortBy2, librarySortByAuto. URL: ?sort2 syncs the
secondary; auto is settings-only (no URL representation).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:24:49 +02:00
Huang Xin 18c2115cc1 feat(library): import-failure modal + group sort + Android callout fix (#4345)
* fix(library): suppress Android image callout on book covers

Long-pressing a cover on Android could trigger the WebView's native
image callout at the same time as the bookshelf's own 500ms long-press
handler for multi-select, causing apparent freezes. `-webkit-touch-
callout: none` doesn't inherit, so the existing `.no-context-menu`
rule on the item container never reached the cover `<img>`. Apply the
callout suppression to descendant images/anchors and disable native
drag on the cover.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(library): show modal for multi-file import failures

When a batch import yields more than one failure, the previous toast
crammed every filename onto a single line that often overflowed and
truncated. Add a dialog that lists each failed filename with its
error reason, dedupes the message into a header banner when every
file failed for the same reason, and falls back to the existing toast
for single-file failures.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(library): sort manage-group modal by most recent activity

The Group Books modal listed groups in store-insertion order, which made
recently-active groups hard to find in libraries with many groups. Sort
each level desc by the newest `updatedAt` across the group's books,
propagating up the path so a recently-touched book in
`Literature/Fiction` keeps `Literature` fresh too. Extract the index as
`buildGroupNameUpdatedAt` in libraryUtils for reuse and unit testing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(library): tighten select-mode action bar and header polish

- SelectModeActions: switch the narrow-viewport grid from 3 columns to
  4 (with the delete action explicitly placed in column 2) so the icon
  set stops wrapping awkwardly on phones below ~500px.
- LibraryHeader: keep the "Select All" / "Deselect" label on a single
  line so it doesn't wrap and shove the underlying button taller.
- SetStatusAlert: drop the hover bg on the small-screen cancel button
  and rely on text-color contrast so it stops flashing a tinted disc
  on mobile taps.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:46:17 +02:00
loveheaven 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>
2026-05-28 18:37:04 +02:00
Huang Xin 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>
2026-05-28 17:07:49 +02:00
Huang Xin 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>
2026-05-28 15:55:07 +02:00
Huang Xin 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>
2026-05-28 08:38:38 +02:00
Huang Xin 381eed21cc fix(tauri): skip runtime-config.js injection in static export (#4332)
The Tauri build uses `output: 'export'`, so the dynamic `/runtime-config.js`
route handler is never emitted. Requesting it returns the SPA fallback HTML
and crashes with `Unexpected token '<'`. Gate the script tag on
`NEXT_PUBLIC_APP_PLATFORM === 'web'`; Tauri consumers already fall back to
the `NEXT_PUBLIC_*` envs baked in at build time.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 04:14:47 +02:00
Huang Xin 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>
2026-05-27 20:25:18 +02:00
Huang Xin a1cb228d00 fix(library): wrap select-mode action bar on small screens (#4329)
Long translations (e.g. German "Gruppieren", "Löschen") pushed the 6-button
action bar past the right edge on typical phones since the grid fallback
only triggered below 350px. Switch to a 3x2 grid below sm: and clamp the
container to the viewport width.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:53:27 +02:00
loveheaven 315d144d8a fix(library): suppress loading-dots flicker on reader→library return (#4325) 2026-05-27 17:32:53 +02:00
loveheaven 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>
2026-05-27 12:08:36 +02:00
loveheaven 5a092f16f7 feat(ios): folder import with security-scoped bookmark persistence (#4314)
* feat(import): support folder picker on iOS via native-bridge

Tauri's dialog plugin rejects folder picks on both mobile platforms with FolderPickerNotImplemented, so previously only Android could pick an import directory (it already routed through the native-bridge plugin's ACTION_OPEN_DOCUMENT_TREE). iOS users had no working folder-import entry point at all.

Add an iOS implementation of the native-bridge select_directory command using UIDocumentPickerViewController(forOpeningContentTypes: [.folder], asCopy: false), with a dedicated FolderPickerDelegate that:

  - holds a strong reference until the picker dismisses (UIKit keeps the delegate weak), and

  - calls startAccessingSecurityScopedResource on the picked URL and retains it for the app's lifetime so plain Foundation/POSIX reads against url.path work for the rest of the session.

Route NativeAppService.selectDirectory through the bridge for both iOS and Android, then call allowPathsInScopes so the picked directory is reachable via fs_scope and the asset protocol. The library page's pickImportDirectory entry point now also takes the mobile branch on iOS, while keeping the Android-only MANAGE_EXTERNAL_STORAGE prompt gated behind isAndroidApp.

* feat(ios): persist security-scoped bookmarks for picked folders

iOS hands the folder picker back a security-scoped URL whose access
right is granted only to the running process. The previous
implementation kept the URL alive for the lifetime of the process via a
static `urlsToKeepAlive` array, which worked for the current session
but forced the user to re-pick the same folder after every relaunch.

Add a `FolderBookmarkStore` that:

  - Right after the picker returns, calls
    `URL.bookmarkData(.minimalBookmark)` and stashes the bytes in
    `UserDefaults` keyed by the POSIX path.

  - On every `NativeBridgePlugin.load(webview:)`, walks every persisted
    bookmark, resolves it back into a URL, and calls
    `startAccessingSecurityScopedResource`. Holds the URL alive in a
    process-scoped dictionary so subsequent Foundation / POSIX reads
    against `url.path` succeed.

  - Handles `isStale` by re-encoding the bookmark against the resolved
    URL, and drops permanently unresolvable bookmarks (folder gone,
    provider uninstalled) from `UserDefaults` so the next launch
    doesn't re-attempt them.

Pair this with a Tauri-side change so the same paths are reachable
through both `dir_scanner::read_dir` and the fs plugin's `readDir`:

  - `allow_paths_in_scopes` now has an iOS branch that widens
    `fs_scope` / `asset_protocol_scope` for any path the frontend hands
    it, intentionally without the desktop-side "must already be in
    fs_scope" gate. The OS sandbox + bookmark store is the real
    access-control boundary on iOS; widening Tauri's in-memory scope
    set cannot escalate access beyond what the OS already grants. The
    security comment on the command was rewritten to spell this
    contract out.

  - `allow_file_in_scopes` is now compiled for iOS too (previously
    desktop-only) so the file-grant path is available when needed.
2026-05-27 07:36:39 +02:00
loveheaven 4c539e6be1 fix(opds): show 'Open & Read' for publications already in the library (#4313)
* fix(opds): show 'Open & Read' for publications already in the library

PublicationView only flipped the acquisition button to 'Open & Read'
when the user downloaded the book within the current component
lifecycle — the downloadedBook state started as null on every mount
and was never reconciled against the actual library. So reopening the
detail page (after navigating away in the OPDS browser, or coming back
from the reader) always showed 'Download' / 'Open Access' again and
asked the user to re-download a book they already had.

Two real-world feeds (m.gutenberg.org and ManyBooks) exposed two
distinct failure modes that made naive title/author equality unusable:

(1) Gutenberg's OPDS entries never carry <dc:identifier>. The only work
    identifier lives in <atom:id>urn:gutenberg:1342:2</atom:id>, which
    foliate-js's opds.js parses into metadata.id — a field
    getMetadataHashInfo never reads. So the identifier candidate list
    was empty and identifier-overlap matching silently skipped every
    book.

(2) Author strings disagree across the boundary: the feed emits
    '<author><name>Austen, Jane</name>' (Lastname-first) while the EPUB
    inside the same feed ships <dc:creator>Jane Austen</dc:creator>.
    Plain string equality (even after normalization) never matched.

Add findExistingBookForPublication in app/opds/utils/findExistingBook.ts
with a layered matcher:

- Pass 1: full metaHash equality (the strongest signal — same fingerprint
  the bookService uses internally).
- Pass 2: identifier overlap. collectPublicationIdentifiers() splices
  metadata.id into the candidate list alongside metadata.identifier, so
  Gutenberg's 'urn:gutenberg:1342:2' feeds into identifierKeys(), which
  extracts the '1342' digit-tail (>=3 digits, so the trailing ':2'
  version suffix is ignored) and matches the EPUB's
  'http://www.gutenberg.org/1342' → '1342'.
- Pass 3: tolerant title + author match. hasAuthorOverlap() does a
  token-set fallback: each name is split on whitespace/comma/semicolon,
  single-letter tokens (initials) and year-range tokens ('1775-1817')
  are dropped, and we require >=2 shared tokens by default. So
  'Austen, Jane' ↔ 'Jane Austen' match via {austen, jane} but
  'Author A' ↔ 'Author B' don't (both collapse to {author} after
  dropping single-letter tokens — we track raw token count to refuse
  the single-token shortcut when discriminative parts were filtered).
  Genuine mononyms still match on a single shared token because raw
  count is 1 on both sides.

OPDSPerson[] is fed through the library's own formatAuthors so the
produced author string matches Book.author byte-for-byte (Intl.ListFormat
output, locale-aware). Soft-deleted books are skipped so removing a copy
locally reverts the button.

Wire it in opds/page.tsx by subscribing to useLibraryStore.library
(rather than the snapshot reads inside handleDownload) and memoizing
existingBookForPublication. Pass it to PublicationView, which now
seeds downloadedBook from the prop and resyncs when the prop changes
(switching publications inside the browser) — but does not clobber a
download success it observed locally before the parent recomputed.

Covered by unit tests for the helper, including the real Gutenberg
URN-in-atom-id case, ':version' suffix on the URN, 'Lastname, Firstname'
vs 'Firstname Lastname', year-range stripping, and the 'Author A' vs
'Author B' non-match.

* fix(opds): dedupe downloads by source url

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-05-27 07:32:53 +02:00
loveheaven 29c88c0216 fix(opds): make Android Back / Reader→Back behave correctly inside the OPDS browser (#4311) 2026-05-26 20:59:18 +08:00
Huang Xin e0ce6c8c22 feat(reedy): Appendix A · Phase 4 — custom thread UI on AgentRuntime (#4308) 2026-05-26 14:55:20 +08:00
Huang Xin 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>
2026-05-25 20:41:21 +02:00
loveheaven c5384b2a6b fix: respect Android Back / Esc inside Settings sub-pages and Import-from-Folder dialog (#4286)
* fix(library): cancel Import-from-Folder dialog on Android Back / Esc

The dialog was previously relying on <Dialog>'s built-in
`native-key-down` listener to handle Back / Escape, but
`useKeyDownActions` (used here for the Enter-to-confirm shortcut)
registers its own sync listener that returns `true` on every Back
keypress, consuming the event before <Dialog> ever sees it. As a
result Android Back and Escape were silently swallowed inside this
dialog.

Wire `onCancel` so the hook actually performs the cancel itself, and
guard it (like Enter) while a folder pick is in flight to avoid
canceling mid-pick.

* fix(settings): step back to parent panel on Android Back / Esc inside sub-pages

Several settings panels render an in-place sub-view based on local
state (FontPanel -> Custom Fonts, LangPanel -> Custom Dictionaries,
IntegrationsPanel -> KOSync / WebDAV / Readwise / Hardcover / OPDS /
Send-to-Readest). Pressing Android Back (or Escape) while one of
these sub-pages was open used to close the entire Settings dialog
because only <Dialog>'s own `native-key-down` listener handled the
event.

Mount a `useKeyDownActions` hook at each parent panel, gated on the
sub-page being open, that calls the existing "go back" handler and
consumes the event. Because `dispatchSync` walks listeners LIFO, the
panel-level hook (registered after <Dialog>'s) claims Back first
while a sub-page is open; once the sub-page is closed the hook is
disabled and Back falls through to <Dialog> as before, closing the
whole Settings dialog.

This keeps all logic in the three parent panels — no changes needed
to the seven sub-page components — and a single hook in
IntegrationsPanel covers all six integrations sub-pages.
2026-05-25 16:00:33 +02:00
loveheaven 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.
2026-05-25 15:57:55 +02:00
Huang Xin 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>
2026-05-23 15:59:29 +02:00
loveheaven 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>
2026-05-22 18:55:12 +02:00
Amir Pourmand 9ad43aa8b2 feat(docker): add GHCR and Docker Hub image publishing (#4250)
* Add GHCR and Docker Hub image publishing with fully runtime-configurable pull-first Docker setup (#1)

* feat: add container image publishing workflow and pull-based compose setup

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/23c31167-9e15-4d44-ab89-f267b8cd6304

* refine docker publishing workflow and pull-first compose docs

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/23c31167-9e15-4d44-ab89-f267b8cd6304

* chore: temporarily expose docker publish run results for pr branch

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/c946a2f2-2219-4dea-a829-61b287bc4859

* fix: update pinned SHAs for setup-qemu-action and setup-buildx-action to v3 heads

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/0db6957e-476b-48e0-acf3-bee6964c3b32

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* chore: switch all workflow action refs from SHA pins to stable version tags

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/19334b37-9b4c-45df-9c3c-81a497cef8e8

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: restore missing `with:` blocks lost during SHA-to-tag substitution

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/f204f742-5b7d-4f05-9647-03b9db86ea3d

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix(ci): checkout submodules for docker image workflow

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/72309e8a-6c7c-4004-902a-565f67e5c15f

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* feat(docker): support runtime client env for pulled web image

* refactor(web): serve runtime config via script endpoint

* fix(web): escape runtime config script payload

* fix: align docker runtime config with internal/public backend urls

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/3bbcb608-6202-4f9e-b288-5c95a259da93

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* chore: align published workflow tags with docs

* docs: clarify runtime config precedence and linux host mapping

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/3bbcb608-6202-4f9e-b288-5c95a259da93

* refactor: move storage/quota config from build args to runtime env

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/da9b749e-0b1c-47b9-b474-5009765b6ea6

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: load runtime config in pages router app shell

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/b375132c-8317-4c98-b437-ae48c0153e3d

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: apply biome formatting for runtime config quota helpers

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/778f5d75-884e-401b-b7cb-4ab40bc64a11

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>
Co-authored-by: Amir Pourmand <pourmand1376@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: S3_PUBLIC_ENDPOINT, _document.tsx for beforeInteractive, runtimeConfig fallbacks, README port (#2)

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/4f92f818-c008-4caa-9684-d530500b5fb2

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: reformat runtimeConfig.ts to satisfy biome formatter (#3)

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/4eea77f4-67ab-4428-b59e-0b21be988037

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-21 19:57:35 +02:00
loveheaven 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.
2026-05-21 18:59:07 +02:00
Huang Xin 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>
2026-05-21 18:50:33 +02:00
loveheaven 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>
2026-05-21 11:52:43 +02:00
Huang Xin a1279a65ce feat(send): clip web URLs into self-contained EPUBs via Tauri webview (#4241)
Builds the URL-clipping path of the "Send to Readest" feature: paste a
link, the renderer ingests the rendered page, and a self-contained EPUB
lands in the library. No server proxy, no external CDN refs left in the
EPUB once it's saved.

Architecture

- New Rust `clip_url` command spawns a hidden Tauri WebviewWindow at the
  target URL with a real Chrome UA + WebKit fingerprint mask, so TLS-
  fingerprint and JS-challenge walls (Cloudflare, Medium, X, WeChat MP)
  resolve naturally instead of bouncing the server proxy.
- Capture transport is URL-payload navigation to a one-shot
  127.0.0.1:RANDOM_PORT/clip/{token}?d={url-safe-base64} listener.
  Top-level navigation isn't governed by CSP connect-src / form-action /
  WebKit Private Network Access — the four earlier transports
  (fetch, <form>, custom URI scheme, window.name) were each blocked by
  one of those.
- Page-to-EPUB bundler (`assetBundler`) walks <img>/<picture> with
  src → data-src → data-original → data-srcset → srcset fallback so lazy-
  loading sites don't ship a 60px LQIP; fetches assets in parallel with a
  per-asset timeout + per-asset/total caps; failed images degrade to alt-
  text placeholders. A per-site rules table (seeded with WeChat MP) + a
  selector fallback catches articles Readability misextracts. Builder
  prepends the article <h1> + byline so the EPUB has a proper opening.
- Nested EPUB TOC built from h1–h6.

UI surfaces

- "From Web URL" entry in the library Import menu, gated to Tauri; web
  build hides the URL field and points at the browser extension.
- `ImportFromUrlDialog` with auto-height (overrides Dialog's `sm:h-[65%]`
  default) and a dim placeholder for the URL field.
- Clip webview window styled to match Readest's main window — macOS
  decorations + overlay title bar; other desktops decorationless with a
  drop shadow; native background + in-page loading overlay pick up the
  caller's `themeCode.bg`/`fg` so light/dark/eink/custom themes all
  render correctly. Title localised, all five overlay/title strings
  translated across 33 locales.

Notes

- Gates the macOS traffic-light positioner to main/reader-* windows so
  the decorationless clip window no longer null-derefs in
  `position_traffic_lights`.
- Stricter validation across the path: schemes restricted to http/https,
  hex-color parsing rejects malformed values, server endpoint returns
  400 on missing/invalid base64.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:48:09 +02:00
loveheaven 5ac8564e41 feat(library): add Import from Folder dialog with format/size filters (#4229)
* feat(library): add Import from Folder dialog with format/size filters

Replaces the silent "import every supported file recursively" behaviour of the directory import menu item with an explicit dialog that lets users pick which formats to include, set a minimum file size, and choose between mirroring subfolders as nested groups (legacy behaviour) or flattening every match into the current library view.

The folder, the chosen Folder Structure radio, the ticked File Formats and the File Size threshold are all persisted in localStorage so re-opening the dialog seeds every field with the user's last choice. Cancelling the dialog does not write to storage so an aborted pick won't pollute the next session.

Also hides the native number-input spinner via a small .no-spinner utility in globals.css; on macOS WebKit the spin buttons were drawing over the rounded input border and looked broken. The KB suffix now lives inside the input's bordered shell instead of beside it.

Two correctness fixes the dialog flow exposed:

* The library importer + ingestService now treat groupId as a tri-state — undefined means "don't touch the existing group", '' means "explicitly the library root", any other string means a specific group. Previously a falsy check in both layers conflated '' with undefined, so re-importing a deduped book under flatten mode silently kept its stale groupId/groupName from the prior keep-as-groups run, making the book reappear in the old subfolder group instead of moving into the library root. New regression tests in ingest-service.test.ts cover both the empty-string case and the omitted case.

* Imports of arbitrary user paths (e.g. ~/Downloads) now go through a new allow_paths_in_scopes Tauri command that extends both fs_scope and asset_protocol_scope. The dialog plugin only auto-grants fs_scope, so reads through the asset protocol (RemoteFile / convertFileSrc) used to fail with "asset protocol not configured to allow the path". The shim is invoked after every selectFiles / selectDirectory call and once more at the start of runFolderImport so localStorage-restored paths are also covered. Granted scopes persist across restarts via tauri_plugin_persisted_scope.

* fixup(library): harden Import-from-Folder scope grant + RTL/dialog polish

Three review fixes on top of the Import-from-Folder feature:

* lib.rs: refuse to extend asset_protocol_scope for paths not already
  in fs_scope. Without this gate, any frontend code (XSS via book
  content, OPDS HTML, dictionary lookups, or a compromised dependency)
  could call allow_paths_in_scopes with '/' or '~/.ssh' and gain
  persistent read access to arbitrary user files via the asset
  protocol — the grant survives restarts thanks to
  tauri_plugin_persisted_scope. Mirrors the defensive check in
  dir_scanner.rs.

* ImportFromFolderDialog.tsx: migrate from a custom ModalPortal chassis
  to the project's shared <Dialog> primitive so eink mode auto-removes
  shadows, mobile gets the bottom-sheet treatment, RTL direction is
  applied, and focus management is correct.

* ImportFromFolderDialog.tsx: swap directional Tailwind utilities for
  the logical equivalents (text-start, ps-/pe-, rounded-s-, text-end)
  per DESIGN.md §2.8 — Arabic/Hebrew users were getting a mirrored
  number-input row with the KB suffix on the wrong side.

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

* i18n(library): translate Import-from-Folder dialog strings across 33 locales

Translates the 13 new strings introduced with the Import-from-Folder
dialog (folder picker label, format-filter section, size-threshold
input, folder-structure radios, OK button, empty-result toast). All
33 supported locales — including RTL fa/he/ar — are now complete; no
__STRING_NOT_TRANSLATED__ placeholders remain in the catalog.

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

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:51:53 +02:00
loveheaven d943a1c146 fix(library): clear nested-folder groups when deleting from bookshelf (#4226)
* fix(library): clear nested-folder groups when deleting from bookshelf

Deleting a group from the bookshelf right-click menu used to leave the group on screen whenever the import had any sub-directories. The cause: getBooksToDelete matched only `book.groupId === id`, but the bookshelf renders a top-level group with id = md5("MyDir") while books imported from a sub-folder carry groupId = md5("MyDir/sub"). Sub-folder books never got marked for deletion, refreshGroups re-built the parent group from their groupName on the next render, and the user saw an undeletable folder.

Fix: when an id resolves to a known group via getGroupName, also collect every book whose groupName equals that path or starts with `${path}/`. Hash-based dedup keeps a book from being queued twice when both rules match. Single-book deletes and flat-folder group deletes are unaffected.

* refactor: expand group selections into book hashes at intake

Address review feedback on #4226: instead of re-deriving which books
belong to a group inside the deletion path with a path-prefix sweep,
resolve group ids into their constituent book hashes upstream where the
selection enters the deletion pipeline.

* New helper `expandBookshelfSelection(ids, items)` in libraryUtils:
  group ids resolve to every (non-soft-deleted) book in the rendered
  rollup; standalone book hashes pass through. Tested in isolation.
* `Bookshelf.deleteSelectedBooks` runs select-mode picks through the
  helper before populating `bookIdsToDelete`.
* `BookshelfItem` right-click group delete dispatches the
  constituent hashes from `group.books` directly, so the receiver
  is a simple pass-through.
* `getBooksToDelete` collapses to a flat hash lookup — no prefix
  sweep, no `getGroupName` call in the deletion path, no dedup set.

The nested-folder fix still holds because `generateBookshelfItems`
already rolls "MyDir/sub" books into the top-level "MyDir" group;
expanding via the rendered `group.books` picks them up automatically.

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

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 06:31:39 +02:00
Huang Xin 0b18de0581 feat(send): Send to Readest — multi-channel capture into your library (#4230)
* feat(send): Send to Readest — multi-channel capture into your library

A Send-to-Kindle equivalent: email, web-upload, share, or one-click
capture books and articles into the cloud library; they sync to every
device.

Architecture (client-side processing): out-of-app channels drop a raw
payload into a per-user send_inbox; Readest clients drain it through one
shared ingestService.ingestFile(). The server never parses or converts.

- ingestService.ingestFile() — channel-agnostic import orchestration
  extracted from library/page.tsx (DI-based, forceUpload support).
- send_addresses / send_allowed_senders / send_inbox tables + RLS + 4
  SECURITY DEFINER claim/lease RPCs (migration 012_send_to_readest.sql).
- Conversion subsystem (DOCX/RTF/HTML/article/TXT -> EPUB) in a Web Worker.
- send-email Cloudflare Email Worker; inbox-drainer controller +
  useInboxDrainer hook; /api/send/* routes.
- Send to Readest settings panel: inbound address, approved-sender
  allowlist, recent activity, per-device drain toggle.
- /send web page (file drop + article URL) + SSRF-guarded fetch-url proxy.
- OS-shared files routed through ingestFile; Manifest V3 browser extension.

Security: inbox state changes only via SECURITY DEFINER RPCs (clients get
SELECT-only on send_inbox); approved-sender allowlist gates email;
SSRF guard on the one server-side URL fetch; inbox payload signed URLs
authorize against send_inbox.user_id.

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

* chore: run format:check in the pre-push hook

Biome format checking is fast (~0.4s), so gate pushes on it too — catches
mis-formatted files that bypassed the staged-only pre-commit hook before
they reach CI.

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

* fix(send): address CodeQL security findings

- ReDoS (senders.ts): the email regex had ambiguous quantifiers around
  the literal dot. Rewrote it linear-time (domain labels exclude '.')
  and cap the input at 254 chars.
- XSS (convertToEpub.ts): run untrusted HTML through DOMPurify
  (sanitizeForParsing — keeps document structure) before DOMParser, so
  title extraction and Readability never parse executable markup.
- SSRF (fetch-url.ts): harden the host guard — block bare single-label
  hostnames, IPv4-mapped IPv6, CGNAT/benchmark/multicast ranges, and the
  unspecified address. DNS rebinding stays a documented residual risk.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:06:52 +02:00
Huang Xin 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>
2026-05-19 19:49:58 +02:00
loveheaven 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.
2026-05-19 07:03:52 +02:00
Huang Xin 52f9634810 feat(backup): include global settings in backup zip (#4211)
* feat(backup): include global settings in backup zip

Backup zips previously held only book files and library.json. Issue
#4098 asks for app configuration to be backed up too.

A `settings.json` snapshot is now written at the zip root. Restore
deep-merges it onto the current device's settings, so fields the
snapshot omits keep their current values.

`sanitizeSettingsForBackup` strips, via a blacklist, fields that are
device-specific or sync/migration bookkeeping (filesystem paths,
replica/kosync device ids, sync cursors, lastOpenBooks, screen
brightness, schema versions). Account credentials (kosync/Readwise/
Hardcover tokens, AI gateway key, OPDS catalog logins) are stripped
unless the user opts in via a new "Include account credentials"
checkbox in the Backup & Restore dialog — the zip is unencrypted.

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

* fix(backup): keep revived books visible after a cloud-synced restore

When the library is deleted (soft delete) and the deletion has synced
to the cloud, restoring an older backup un-deletes the books locally —
but the next sync's last-writer-wins merge re-applied the cloud's
deletion tombstone, so the restored books vanished again.

The deletion never bumps `updatedAt`, so a restored book and its cloud
tombstone share the same timestamp; `processOldBook` breaks the tie
toward the cloud.

`reviveRestoredBooks` now fixes up books that were soft-deleted locally
but present in the backup:

- Bumps `updatedAt` so the restore out-ranks the cloud tombstone. A
  single uniform offset is applied to every revived book, so their
  relative order — and the library's "Updated" sort — is preserved
  exactly; the newest maps to now, none land in the future.
- Clears `syncedAt` so the next push re-uploads them and corrects the
  cloud rows.
- Restores `downloadedAt` / `coverDownloadedAt` from the backup record
  (the local deletion had cleared them) so revived books are not shown
  as not-downloaded even though their files were re-extracted.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:16:46 +02:00
Huang Xin 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>
2026-05-17 18:44:17 +02:00
Huang Xin 83607d14ea fix(opds): send Basic auth preemptively for optional-auth servers (#4206)
OPDS servers that allow anonymous access (e.g. Calibre-Web) return 200
without a WWW-Authenticate challenge. `fetchWithAuth` only attached
credentials on a 401/403 retry, so a user who configured valid login
details kept seeing guest-only content (own shelves missing).

Send a Basic Authorization header on the first request whenever
credentials are available. Digest auth still falls through to the
challenge-driven retry since it can't be sent preemptively, and the
retry is skipped when it would just repeat the preemptive Basic header.

Fixes #4202

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:07:15 +02:00
Huang Xin 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>
2026-05-17 17:22:34 +02:00
Huang Xin 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>
2026-05-17 15:30:11 +02:00