sync: empty-start/end range CFIs left by the cfi-inert skip-link bug (e.g.
epubcfi(/6/24!/4,,/20/1:58)) resolve to a section-spanning range and navigate
to the wrong end of the section. Add isMalformedLocationCfi and discard such
locations on the cloud-sync receive path (useProgressSync) and the kosync push
path (useKOSync) so they can't move the reader or propagate to other devices.
foliate 569cc06 stops generating them but does not repair already-synced values.
reader: bump foliate-js to 167757a to fix the white<->black background flash
when swiping between differently-colored pages; add a regression test for the
sliding per-view background segments.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Highlight Current Sentence" keyboard action (default Shift+M, in the
Text to Speech shortcut section) that persists the sentence TTS is reading
aloud as a normal highlight using the user's default style/color — no text
selection, eyes-off, silent, and idempotent (a repeat press on the same
sentence is a no-op rather than a duplicate).
Flow: the shortcut handler in useBookShortcuts dispatches tts-highlight-sentence
→ useTTSControl (which owns the TTSController) resolves the current sentence via
the new TTSController.getSpokenSentence() and relays create-tts-highlight
→ Annotator builds the BookNote with the pure, unit-tested buildTTSSentenceHighlight
helper and persists/renders it like any other highlight.
Closes#4085
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(kosync): compare reflowable conflicts via locally-resolved CFI percentage
KOReader reports progress as a percentage from its own pagination, which isn't directly comparable to Readest's progress. For reflowable books, resolve the remote XPointer to a local CFI and compute the equivalent fraction (getRemoteLocalFraction), comparing that against the local percentage and falling back to the reported percentage only when it can't be resolved locally (non-XPointer progress or a missing section). The resolved fraction also drives the conflict-dialog remote preview so the shown value matches what was compared.
Loosen the conflict threshold to 0.01 when the remote progress was last pushed from this same device (remote.device_id === local deviceId), so sub-page drift between a push and the next pull doesn't prompt. Render sync percentages with 2 decimals via formatProgressPercentage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): correct scrolled-mode reopen drift over background-image sections
Bump the foliate-js submodule to include the scrolled-mode reopen drift fix for sections with background images, and add a browser regression test plus its EPUB fixture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): redirect to login on pull-to-refresh when signed out
Guard the pull-to-refresh handlers so an unauthenticated user is sent to the login screen instead of attempting a library pull and OPDS subscription check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(memory): add kosync conflict + toc/scrolled-restore notes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): prevent CFI crash on inert-only section bodies
Reopening/paginating across a background-image or otherwise content-less section could crash with "Cannot destructure property 'nodeType' of 'param' as it is undefined" in foliate's fromRange, aborting the relocate so the reading position was never saved. Bumps the foliate-js submodule to 569cc06 (visible-range walker skips cfi-inert skip-links; isTextNode/isElementNode are null-safe) and adds a regression test reproducing the exact crash.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(kosync): keep auto-push working when a pull finds no real conflict
In the 'prompt' strategy, pullProgress set syncState to 'conflict' unconditionally on every pull that returned remote progress, even when promptedSync found no actual difference. Since auto-push only runs while 'synced', and a pull fires on every book-open and window re-activation, progress stopped being pushed. promptedSync now returns whether a real conflict was surfaced, and pullProgress only stays in 'conflict' for genuine conflicts (otherwise 'synced').
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): show most recent sync time and reorder settings tabs
Library settings menu now reports the latest of the book/config/note sync timestamps as "Synced …" instead of only the books timestamp. Reorder the settings tabs so Integrations precedes TTS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
generateKOProgress built its XCFI converter from the paginator's
primaryIndex and the rendered primary document, then converted
progress.location. Because #primaryIndex can lag behind the viewport
during scrolling, the CFI's spine section could differ from the
converter's, tripping XCFI's guard ("CFI spine index N does not match
converter spine index M") and silently dropping the progress push.
Route through getXPointerFromCFI, which keys off the CFI's own spine
index and loads the correct section's document from the book when the
rendered index doesn't match. Fall back to the cached config.xpointer
on failure.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In scrolled mode, SectionInfo paints a solid `bg-base-100` `notch-area`
mask over the top safe-area strip at z-10. The Ribbon was also z-10 but
rendered earlier in the DOM, so the equal-z mask painted over the
ribbon's upper (unsafe-area) half — only the lower 44px showed. In
paginated mode the mask has no background, so the ribbon showed fully.
Raise the ribbon to z-20 so the whole ribbon stays visible above the
mask, and mark it pointer-events-none so taps still fall through to the
notch mask's scroll-to-top handler.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single MDX commonly ships its resources across several MDD files sharing the MDX's filename prefix (e.g. `Name.mdd` for images, `Name-02.mdd` for scripts, `Name-03.mdd` for audio). The importer only grouped the exact-stem `Name.mdd`, so the other MDDs were dropped as orphans and their resources (notably audio) could never be loaded.
`groupBundlesByStem` now attaches every `.mdd` whose stem starts with an `.mdx` stem at a separator boundary to that MDX bundle (longest prefix wins on overlap); the boundary check prevents false merges like `dict.mdx` claiming `dictionary-words.mdd`. The runtime provider, contentId, and replica sync (binary upload + manifest apply) already treat `files.mdd` as a list, so multi-MDD bundles sync across devices with no further changes.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(dictionary): keep other dictionaries usable when System Dictionary syncs to an unsupported platform
`dictionarySettings.providerEnabled` is whole-field synced across devices, so enabling System Dictionary on macOS/iOS sets the flag on web/Linux/Windows too. There the row is hidden and the feature is a no-op, but the settings UI read the raw flag and locked every other dictionary's toggle read-only. Gate the lock on `isSystemDictionaryEnabled(settings)` — the same platform-aware check the annotator uses — so it matches real lookup behavior and never triggers where the system dictionary can't run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(dictionary): dispatch system dictionary handoff by native OS (fixes iPad)
iPadOS sends a desktop "Macintosh" user agent, so the UA-based `getOSPlatform()` reported iPad as 'macos' and the handoff invoked the macOS-only `show_lookup_popover` Rust command that iOS never registers ("Command show_lookup_popover not found"). Derive the OS from the app service's `is*App` capability flags (sourced from the Tauri OS plugin, correct on iPad) via a new synchronous `getInitializedAppService()` accessor, so iPad routes to the iOS plugin command path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): constrain reader View Options dropdown to h-8
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(agent): note System Dictionary platform-detection and synced-flag patterns
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(agent): add agent notes for cache, reading-ruler, foliate touch
Add project-memory notes and index entries:
- manage-cache-ios-layout: iOS container layout and what Manage Cache clears
- reading-ruler-line-aware: line/column-aware reading ruler internals
- foliate-touch-listener-capture-phase: capture-phase gesture suppression
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): pad sidebar and notebook for the device status bar (#4089)
Top-anchored slide-in panels (sidebar, notebook) only applied status-bar
top padding when isFullHeightInMobile was true. On a tablet/desktop
(isMobile === false) that gate collapsed the padding to 0, so a visible
system status bar overlapped the panel's top toolbar and made its icons
inaccessible.
Extract the inset math into getPanelTopInset() and gate it on
(!isMobile || isFullHeightInMobile) so non-mobile panels clear the status
bar like the reader header, while a partial-height mobile bottom sheet
(which doesn't reach the top of the screen) stays flush.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): keep footer bar clear of the pinned sidebar
On a mobile tablet in portrait, forceMobileLayout renders the footer bar
with position: fixed, anchored to the viewport, so left-0 w-full spans
the whole window and slides under a pinned sidebar — the progress / font
/ TTS controls end up obscured.
Anchor the footer inside the book's grid cell (position: absolute) when
the sidebar is pinned, mirroring the header bar. The flex layout already
offsets the grid cell by the sidebar's real rendered width, which honors
the sidebar's min-w-60 floor and 45% cap that a stored-width offset would
miss. The slide-up panels are absolute within the footer container, so
they shift and narrow with it and their animation is unchanged. The
switch only happens when the sidebar is pinned, so phone (< 640px) and
unpinned tablet-portrait class names stay identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): refine reader side panels and their empty states
Closes#4089
Add a shared EmptyState component (large muted icon, title, and an
optional hint or action) and use it for the empty annotations, bookmarks,
and notes panels in the sidebar and notebook, replacing the ad-hoc
"No … yet" placeholders.
Polish the surrounding chrome: switch the bookmark toggler to the Ri icon
set with responsive sizing, crop the HighlighterIcon viewBox to its
artwork to remove the asymmetric bottom padding, and tune mobile sizing
and spacing across the panel headers, tab navigation, and footer nav bar.
Translate the new empty-state strings (No Notes, No Annotations, No
Bookmarks, and their hints/action) across all 33 locales and drop the
obsolete "No … yet" keys.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Manage Cache" item to the library Advanced Settings menu (native
mobile apps only) that opens a modern dialog showing the combined size and
file count of the app's reclaimable storage, with a confirm-gated clear that
reports per-file progress.
- iOS clears Cache + Temp + Documents/Inbox; Android clears Cache + Temp.
- Multi-source helper (getCacheEntries/getCacheStats/clearCacheEntries) with
unit tests; per-file failures are counted, never abort the run.
- Dialog uses the centered-hero + btn-contrast design language, theme-neutral
progress, and is e-ink correct.
- i18n: new strings translated across all locales (+ en plural forms).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Snap the reading ruler to real rendered text lines instead of stepping by a
fixed arithmetic height, so the band always frames whole lines.
- Snap to actual line geometry from the relocate range; the band is sized
dynamically to the text block plus symmetric padding (round(fontSize *
lineHeight * 0.3)), capped at (lines + 1) line heights so a tall image inside
a block can't expand it to cover the whole figure.
- Drop block/container rects (Range.getClientRects aggregates multi-line element
borders) so paragraphs aren't merged into one giant line and skipped.
- Column-aware in multi-column layouts: the band spans one column at a time and
advances column by column.
- Confine the band to lines at least half visible within the viewport.
- Scrolled mode: snap to lines, and at a view edge scroll the view and realign
the band to the start/end of the new view (works for vertical-rl too, which
scrolls horizontally); paging snaps the view edge between lines so text isn't
cut or repeated.
- Vertical writing mode: correct band centering and drag direction; Up/Down keys
move the ruler while Left/Right turn pages (taps always move the ruler).
- Page turns keep the first/last line: forward lands on the first line of the new
page, backward on the last line; the relayout re-snap anchors on the band's
leading edge so it never skips a line.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: design spec for gesture-based brightness control (#3021)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: revise brightness-gesture spec per /autoplan review (#3021)
CEO+Design+Eng dual-voice review. Key fixes: capture-phase listener
(bubble-phase could not suppress foliate paginator), opt-out toggle,
18px threshold, selection guard, brightness seed race, rAF teardown,
e-ink stepped overlay, contrast capsule, perceptual curve reuse,
listener-level test harness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): swipe-to-adjust brightness gesture on mobile (#3021)
Left-edge vertical swipe adjusts screen brightness on iOS/Android, with a
Sun-icon progress overlay. Capture-phase non-passive listener suppresses the
foliate paginator / page-flip / UI-toggle handlers; selection guard, strip
reservation in scrolled mode, eager brightness seed, rAF throttle + teardown.
Opt-out toggle in Settings > Behavior > Device (default on). Perceptual curve
shared with the menu slider. Pure-helper + listener-level tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): detect brightness-swipe edge by screenX; i18n + shorter label (#3021)
On-device fix: paginated mode lays the iframe doc out as wide side-by-side
columns, so clientX/documentElement.clientWidth are document coordinates and a
left-edge touch on a later page never fell inside the strip (armed stayed false).
Detect with screenX against the parent window width, matching usePagination.
Also: translate the two new setting strings across all locales and shorten the
toggle description to 'Slide along the left edge'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rsvp-controller.test.ts calls controller.start() in ~20 tests but never stops
the controller. start() schedules a countdown (setInterval, 500ms x3) that then
schedules the recurring word-advance (setTimeout). On the real clock those fire
~1.5s later — after the test file's jsdom env is torn down — and
emitStateChange's dispatchEvent(new CustomEvent(...)) runs against a stale realm
and throws. Vitest reports it as an unhandled error and fails the whole run
intermittently on CI:
TypeError: Failed to execute 'dispatchEvent' on 'EventTarget': parameter 1 is
not of type 'Event'.
at RSVPController.advanceToNextWord -> emitStateChange (Timeout._onTimeout)
Fake only the timer functions (setTimeout/setInterval + their clears) for this
suite via vi.useFakeTimers({ toFake: [...] }); useRealTimers in afterEach
discards any still-pending fakes. The tests assert synchronously and never
advance playback, so faking the timers changes no assertion; Date/performance
stay real so CFI/position checks are unaffected. Test-only; production behaviour
is unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switching the annotation/bookmark sidebar to a flat virtualized list eliminates
the per-item layout reads that caused multi-second jank when toggling tabs on
books with hundreds of notes.
A. Virtualize the list with react-virtuoso
- Flatten group headers + notes into a single FlatBooknoteRow array.
- Embed an OverlayScrollbars instance inside the tab so scrollbar styling
is preserved while Virtuoso owns the viewport (same nested pattern as
TOCView).
- Track the parent scroll-container's height with ResizeObserver to give
Virtuoso a bounded viewport.
- Replace the per-item useScrollToItem (which called getBoundingClientRect
and closest() on every BooknoteItem on every progress tick — O(n) sync
reflow on 1000+ items) with a single virtuosoRef.scrollToIndex driven by
nearestCfi.
B. Stabilize derivations with useMemo / useCallback
- filteredNotes, sortedGroups, flatItems, nearestCfi all useMemo so an
unrelated config change (e.g. viewSettings autosave) no longer triggers
a full sort + group rebuild.
- handleBrowseBookNotes is now useCallback so BooknoteItem's React.memo
can hit on prop equality.
C. Memoize BooknoteItem
- Wrap the component in React.memo. With stable item / onClick references
from the parent, re-renders triggered by sibling progress updates no
longer cascade across every visible row.
- Cache marked.parse(item.note) and dayjs(item.createdAt).fromNow() in
useMemo. marked is the dominant per-render cost for note rows.
- isCurrent moves to a useMemo over isCfiInLocation; the per-item
scrollIntoView is removed since BooknoteView now drives scrolling.
useScrollToItem is intentionally left intact — SearchResults still uses it
and its smaller list does not exhibit the same jank.
The two #4112 scrolled-mode preload browser tests asserted that the
previous-previous section (F-1) was not loaded after navigating to a section,
checking it after `waitForFillComplete()`.
The eager backward buffer (minPages) is suppressed while `#display` is
stabilizing, but pulls F-1 (and beyond) in on the first scroll events once the
fill settles. So the post-fill assertion raced the buffer: it passed locally but
failed intermittently on CI (loaded set [1,2,3,4,5]).
Assert the precondition the instant `#display` stabilizes — where the buffer is
provably suppressed and only the immediate-previous section is loaded — before
the fill settles. The eager buffer loading F-1 later is exactly what the second
test wants once it navigates back. Test-only; production behaviour is unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a hard refresh the TOC sidebar occasionally (~1 in 10) scrolled to and
highlighted the current chapter, then rewound to the very top of the list.
It is a scroll-position race in TOCView, not a progress/sectionHref reset (the
reading position stays correct throughout). OverlayScrollbars resets the wrapped
Virtuoso viewport's scrollTop to 0 when it initializes (deferred). Its
`initialized` callback re-scrolled only to `initialScrollTarget.index`, captured
at mount — and on a fresh refresh `progress` is not available yet, so that index
is 0 and the reset is never corrected. Whether OverlayScrollbars initializes
before or after the auto-scroll to the reading position is the timing race that
made it intermittent.
Re-apply the scroll to the current active item (via refs mirroring the live
flatItems/activeHref) in the `initialized` callback, falling back to the
mount-time index. Refs are used because OverlayScrollbars binds the callback at
mount and fires it later, so it must read the latest active item.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract and translate the new strings introduced by the Moon+ Reader
annotation import flow (#4350, #4174) and OPDS facet/catalog navigation
(#4348): import dialogs, link-type labels, catalog actions, empty states,
and the count-pluralized "Failed to import {{count}} books" title.
- 21 singular keys + plural forms translated across all 32 non-en locales
- en/translation.json gains the hand-authored _one/_other plural variants
for "Failed to import {{count}} books"
- plural forms follow each locale's CLDR categories (ar 6, ru/pl/uk/sl 4,
romance 3, etc.); placeholders, "Moon+ Reader" brand, and ".mrexpt" preserved
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(opds): add facet navigation and quick catalog registration to header
- Add an options dropdown in the header to navigate OPDS feed facets on compact viewports. - Implement an "Add to My Catalogs" dialog to save the current feed, inheriting credentials, headers, and config. - Render a standalone shortcut button in the header when no facets are present, hiding the dropdown.
* fix(opds): scope window rounding to full route + guard duplicate add
Reviewing the facet-navigation feature surfaced three issues in the
quick-add flow and an unrelated window-rounding change:
- Restore the standard full-screen-route rounding pattern on the OPDS
browser. The header change had dropped the `isRoundedWindow` guard
(rounding maximized/fullscreen windows leaves gaps at the edges) and
switched to left-only corners (a docked-sidebar pattern). Match the
library/auth/user/reader pages: `isRoundedWindow && window-border
rounded-window`.
- Guard "Add to My Catalogs" against re-adding a catalog whose URL is
already saved. `addCatalog` dedups by contentId and would silently
overwrite the existing entry while toasting "added successfully"; now
it detects the duplicate via `findByUrl` and shows an info toast.
- Replace `feed!.facets!` non-null assertions with optional chaining.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(export): make annotation export link type configurable
Add an Annotation Link selector (App / Web) to the Export Annotations
dialog. Defaults to the app deeplink in the native app and the universal
web link on the web, so web exports no longer emit readest:// links that
only the desktop/mobile app can open. The default markdown template now
uses the configurable annotation.link variable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(annotations): move Moon+ Reader import into a dedicated Import Annotations modal
Replace the single 'Import from Moon+ Reader' menu item with an 'Import
Annotations' entry (below 'Export Annotations') that opens a dedicated
modal listing import sources. Currently lists Moon+ Reader; the boxed-list
layout makes adding future providers a one-row change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump foliate-js: compensate the scroll position when a previous section is prepended above the viewport, so the browser's scroll-anchoring suppression at scrollTop 0 no longer drifts the view into chapter n-1; preload the previous section on backward navigation and eagerly while scrolling toward the top; and stop the blank-screen flash on adjacent navigation in continuous scrolled mode.
Split paginator-multiview.browser.test.ts into paginator-scrolled and paginator-paginated, adding regression tests for the drift, previous-section preloading, the eager backward buffer, and the flash-free transition.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a primary/secondary sort pair so users can group by author and have
each author's books drilled-in list sort by series, without touching the
sort menu each time. Closes#4307.
- New "Then by..." picker in the library view menu (None + same keys as
primary). Secondary acts as tiebreaker for the global sort, and as the
in-group ordering when the user drills into a non-series group.
- Smart defaults derived from groupBy, surfaced as "(Auto)" in the menu
and resolved at sort time so user picks are never overwritten:
- groupBy=Author + secondary=none -> Series
- groupBy=Series + librarySortByAuto -> primary becomes Series
- librarySortByAuto flips off as soon as the user makes any explicit
primary pick; subsequent groupBy changes then respect that choice.
Settings: librarySortBy2, librarySortByAuto. URL: ?sort2 syncs the
secondary; auto is settings-only (no URL representation).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Windows WebView2's native share UI, invoked via tauri-plugin-sharekit,
synchronously blocks the Tauri command thread waiting on
ShareCompleted/ShareCanceled callbacks. When the user dismisses the
picker without those callbacks firing, the app freezes and has to be
killed from Task Manager. Skip the native share path on Windows and
fall through to the save dialog, mirroring Linux.
* fix(library): suppress Android image callout on book covers
Long-pressing a cover on Android could trigger the WebView's native
image callout at the same time as the bookshelf's own 500ms long-press
handler for multi-select, causing apparent freezes. `-webkit-touch-
callout: none` doesn't inherit, so the existing `.no-context-menu`
rule on the item container never reached the cover `<img>`. Apply the
callout suppression to descendant images/anchors and disable native
drag on the cover.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(library): show modal for multi-file import failures
When a batch import yields more than one failure, the previous toast
crammed every filename onto a single line that often overflowed and
truncated. Add a dialog that lists each failed filename with its
error reason, dedupes the message into a header banner when every
file failed for the same reason, and falls back to the existing toast
for single-file failures.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(library): sort manage-group modal by most recent activity
The Group Books modal listed groups in store-insertion order, which made
recently-active groups hard to find in libraries with many groups. Sort
each level desc by the newest `updatedAt` across the group's books,
propagating up the path so a recently-touched book in
`Literature/Fiction` keeps `Literature` fresh too. Extract the index as
`buildGroupNameUpdatedAt` in libraryUtils for reuse and unit testing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(library): tighten select-mode action bar and header polish
- SelectModeActions: switch the narrow-viewport grid from 3 columns to
4 (with the delete action explicitly placed in column 2) so the icon
set stops wrapping awkwardly on phones below ~500px.
- LibraryHeader: keep the "Select All" / "Deselect" label on a single
line so it doesn't wrap and shove the underlying button taller.
- SetStatusAlert: drop the hover bg on the small-screen cancel button
and rely on text-color contrast so it stops flashing a tinted disc
on mobile taps.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat: add empty state hints and loading indicators for annotations, bookmarks, notes, font import, and Moon+ Reader import
- BooknoteView: show 'No annotation yet' / 'No bookmark yet' when empty
- Notebook: show 'No note yet' when no notes/excerpts exist
- Annotator: add loading overlay with spinner during Moon+ Reader import
- mrexpt: yield to event loop every 5 entries to keep spinner animating
- CustomFonts: show in-place loading card during font import, spinner transitions to font name without layout jump
* fix(ui): respect e-ink overlay styling and drop dead className branch
- Annotator: use modal-box on the mrexpt import overlay so eink picks up
the no-shadow + 1px border override automatically.
- CustomFonts: collapse importing-card clsx ternary whose branches were
identical into a flat className.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Android-only workaround that pinned the container scroll while text was
selected saved `renderer.start` (section-relative) and restored it as
`renderer.containerPosition` (absolute). On later sections those two
diverge — restoring the small `start` value as `containerPosition` snapped
the multi-view scroll back to the first rendered section whenever the OS
selection handle drag triggered a scroll. Save and restore the same
`containerPosition` value instead.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#4222.
Three changes that together stabilize Readest sync across devices:
**Stop the artificial updatedAt bump in useProgressAutoSave**
saveConfig unconditionally bumps config.updatedAt = Date.now(), and
useProgressAutoSave used to fire saveConfig on the very first relocate
after book open — even though that relocate just reflects the position
loaded from disk, not user action. The stale local config then looked
"newer" than a fresher server-side push, so the next auto-push
overwrote the other device's real progress via last-writer-wins. The
hook now snapshots the loaded location and skips saveConfig when the
in-memory location still matches it.
**Retry the first config pull with backoff + release the gate**
useProgressSync gates pushes behind a successful pull (so a brand-new
import can't clobber the server's real progress). But handleAutoSync
only re-arms on progress.location changes, so a single failed pull
(Android cold-start contention, Wi-Fi/LTE handoff, captive portal)
used to block every push for the whole reader session. The new
pullWithRetry retries on backoff (1500/4000/10000 ms) and releases
the gate after exhaustion — server-side last-writer-wins still
protects the cross-device case (a stale local push with an older
updated_at loses to a fresher server record). sync-book-progress
events reset the chain so manual pull-to-refresh recovers cleanly.
Fetch timeout bumped from 8s to 15s to better tolerate slow networks
in that cold-start window.
**Server piggybacks books.progress off configs push**
/api/sync POST now updates books.progress + books.updated_at for each
upserted config, gated by .lt('updated_at') so a concurrent newer
books push is never downgraded and a missing row is a silent no-op
(useBooksSync still seeds new rows from the library page). The
in-reader syncBooks round-trip is dropped — the reader now sends one
POST per auto-save instead of two, and the books row stays consistent
with config pushes even while a reader stays open (#4198).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes#4339. PostHog telemetry was previously enabled by default for every
install, which surprised privacy-conscious users on self-hosted setups.
Behavior for new users only (existing users are migrated to a decision that
preserves their current `telemetryEnabled` setting):
- 90% are silently opted out on first launch.
- 10% see a one-time consent prompt; accepting opts in, declining opts out.
Decision is persisted via a new `readest-telemetry-decision` localStorage key
so subsequent boots don't re-roll. PostHog now inits with
`opt_out_capturing_by_default` so brand-new users never ping before the
decision is finalized.
New `TelemetryConsentDialog` uses the project's `btn-contrast` (theme-neutral)
CTA and `eink-bordered` chassis so it renders correctly under `[data-eink]`
without color-mode-only assumptions. Strings translated across all 33 locales.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(dict): restore MDD eager init; CSP-safe Vocabulary.com audio handler
Two MDict fixes:
1. **Regression**: #4334 turned on `MDD.create(..., { lazy: true })` so
the audio / resource side of every MDict would skip the upfront
key-block decode + sort. That's the right trade-off on the MDX side
(millions of headwords, ~80 s init saving), but it doesn't transfer
to MDDs: js-mdict's lazy lookup binary-searches `keyInfoList`
envelopes with `comp` (localeCompare), and MDDs store keys in
byte-sorted order with `\` prefixes and case folding. The two orders
disagree just often enough that `mdd.locateBytes('sound.png')`
misses on resource paths that do exist — the icon's CSS
`background-image: url(sound.png)` then stays unresolved and the
speaker glyph disappears. Bisected to 93abca89 (#4334). Drop the
flag for MDDs; MDXs keep the lazy win.
2. **New**: a CSP-safe replacement for the `onclick="v0r.v(this,'KEY')"`
audio handler used by Vocabulary.com-derived MDicts. The dict's
`j.js` is never loaded inside our shadow root (we don't execute
MDX-supplied JavaScript), so without intervention the inline
handler would throw `ReferenceError: v0r is not defined` on every
click. The new helper parses the audio key from any matching
onclick, strips that one attribute, and binds our own listener that
probes the companion MDD for `<KEY>.mp3` / `<KEY>.m4a` / etc. and
plays the bytes through an `<Audio>` element. Other unrelated
inline handlers are deliberately left alone — they were no-ops
already (their helper JS isn't loaded), and CSS rules sometimes
match on `[onclick]` attribute presence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sync): skip binary upload when sync category is disabled
`publishReplicaUpsert` already gates the metadata row on
`isSyncCategoryEnabled(kind)` so a user who turned dictionary / font /
texture sync off in Manage Sync never publishes a row for that kind.
`queueReplicaBinaryUpload` had no such gate, so the binary side still
fired: a 250 MB MDict bundle or a few hundred MB of font files would
still upload to cloud storage with no replica row to reference them.
Mirror the gate here. The `kind` parameter (`dictionary`, `font`,
`texture`) lines up with `SyncCategory` names verbatim, so a single
`isSyncCategoryEnabled(kind)` check at the top of the function is all
it takes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue #4288: users with hardware page-turn buttons (e.g. e-ink readers)
want to disable swipe-to-paginate so accidental finger drags during
highlight selection don't flip the page mid-annotation. The existing
"Tap to Paginate" toggle only covers taps; swipe was always on.
- New `disableSwipe: boolean` on `BookLayout` (default `false`,
declared right after `disableClick`).
- foliate-js submodule bump: paginator gates `#onTouchMove` and
`#onTouchEnd`'s snap-to-page on a new `no-swipe` attribute, so
native touch behaviour (text selection) stays intact.
- `FoliateViewer` sets/removes the `no-swipe` attribute alongside
`animated`, and the `ControlPanel` toggle pushes the change to the
live renderer so it takes effect without a viewer reset.
- The fixed-layout swipe interceptor in `usePagination` also bails
when `disableSwipe` is on, covering both reflowable and fixed-
layout books.
- New "Swipe to Paginate" UI row directly below "Tap to Paginate";
both can be off simultaneously.
- i18n: 33 locales translated.
Also polish: rephrase the two helper texts under "Read books in
place" in `ImportFromFolderDialog`. The previous copy ("Copy no book
into the library to save space.") used an awkward double-negative;
the new wording is clearer and the locked variant drops "registered
as" / em-dash for a plain two-sentence form. i18n updated.
Closes#4288
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Make the dictionary import path usable on large bundles and bring the
multi-device flow up to par.
Import perf
- Skip `MDX.create()` at import time. The factory triggers full init —
decompresses every key block and sorts millions of keys with
localeCompare just to expose the header. Replace with a tiny
`readMdxHeader()` that only reads the small XML header for Title /
Encoding / Encrypted (saves ~17 s on a 250 MB MDX on web).
- `partialMD5`: read the 9 sample slices in parallel rather than
sequentially. Each freshly-picked-File slice round-trip on Chrome
costs ~100 ms cold; parallelisation collapses 9 of them into one.
- Native fast-path in `nativeAppService.writeFile`: when the source is
a `NativeFile`, delegate to Tauri's `copyFile` rather than streaming
the file through `NativeFile.stream()`. Streaming a 250 MB body
through 1 MB IPC chunks on Android took ~100 s; native copy is bound
by disk throughput instead. Exposes `NativeFile.getNativeLocation()`
for the FS layer to use the underlying path + baseDir directly.
Lazy lookup
- Pass `lazy: true` to `MDX.create` / `MDD.create`. The js-mdict change
in this PR skips the upfront decompress-every-block + sort during
init (~80 s on the same 250 MB bundle) and decodes only the relevant
key block on demand per lookup. First-lookup main-thread block
drops from ~81 s to ~230 ms. (Closes #4228.)
Raw .dict
- Drop the import-time gate that flagged non-gzip dict bodies as
`unsupported`. The runtime body loader (`loadDictBody`) already
probes the gzip header and falls through to a passthrough buffer
for raw files, so the gate was the only thing preventing raw
`.dict` bundles from importing on devices that received them via
cloud sync. (Closes#4179, partially addresses #4248.)
Import-flow UX
- `handleImport` now always surfaces a toast for every non-cancelled
attempt: picker errors, missing app service, no-op imports, and
unsupported-but-imported bundles each get their own message instead
of failing silently.
- Call `markAvailableByContentId(newDict.contentId)` after
add/replace so the "Bundle is missing on this device" warning
clears immediately — no need to close-and-reopen the panel.
System Dictionary
- Drop the cascading toggle behavior in `setEnabled`. Each provider's
enabled flag persists independently; exclusivity is enforced at
lookup time. Toggling System on/off no longer wipes the user's
preferred set of in-app providers.
- Render non-system rows as read-only when System is on (toggle still
shows what's queued to restore; tooltip explains the lock).
- `isSystemDictionaryEnabled` short-circuits to `false` on platforms
where the handoff isn't implemented. `providerEnabled` is whole-
field synced across devices, so a flag set on macOS would otherwise
leak to a Windows device with no way to look up a word.
js-mdict
- Submodule bump to e6dbc99 which adds the opt-in `lazy: true`
`MDictOptions` flag (skip `_readKeyBlocks` + post-init sort; new
`lookupKeyBlockByWordLazy` path on `MDX` and `MDD`). Eager mode is
unchanged and every existing js-mdict test still passes.
i18n
- 208 new translations across 33 locales for the new UX strings.
Closes#4228Closes#4248Closes#4179
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Tauri build uses `output: 'export'`, so the dynamic `/runtime-config.js`
route handler is never emitted. Requesting it returns the SPA fallback HTML
and crashes with `Unexpected token '<'`. Gate the script tag on
`NEXT_PUBLIC_APP_PLATFORM === 'web'`; Tauri consumers already fall back to
the `NEXT_PUBLIC_*` envs baked in at build time.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(library): make bookitem-main shrink to match cover in fit mode
Closes#4234. In fit mode the bookitem-main kept its 28/41 aspect
regardless of the cover image's natural aspect, leaving extra padding
beside (portrait covers) or above (landscape covers) the cover. The
select-mode overlay and icons drifted away from the cover edge.
BookCover now reports the loaded image's natural aspect ratio. BookItem
overrides the bookitem-main's aspect-ratio with the cover's aspect so
the box hugs the cover exactly, and proportionally shrinks book-item
width for portrait covers so the info row icons align with the cover's
right edge.
Also wrap the TTS "Back to TTS Location" pill with whitespace-nowrap so
long translations (e.g. German "Zurück zur TTS-Position") expand the
button width instead of overflowing the fixed height.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(library): scope cover shrink to bookitem-main, leave info row at cell width
Per review, the width shrink should only apply to the cover row so the
title and info icons keep their original cell-wide layout. Move the
width style from .book-item to .bookitem-main alongside its aspectRatio
override.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Long translations (e.g. German "Gruppieren", "Löschen") pushed the 6-button
action bar past the right edge on typical phones since the grid fallback
only triggered below 350px. Switch to a 3x2 grid below sm: and clamp the
container to the viewport width.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#3914. Switch the page-fullscreen image to object-fit: contain
(and SVG preserveAspectRatio meet) so cover images fit the page and
center without cropping. Also adds a duokan-image-gallery-cell layout
helper and drops the mobile marginBottomPx override.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prevents 'Please log in to continue' error when importing fonts or images
locally without logging in. The replica upload is now gated on
getAccessToken() so unauthenticated users are never enqueued.
Non-PDF fixed-layout EPUBs visually scale the iframe via CSS transform
while keeping native dimensions inside, so getClientRects() returns
unscaled positions. The SVG overlayer was sized in CSS pixels with no
viewBox, leaving annotations and TTS highlights drawn at scale-1
displacement from the actual text.
Bumps foliate-js to set a matching viewBox on the overlayer SVG, and
adds a regression test covering spread/scroll/PDF/empty frames.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Moon+ Reader import flow asks for .mrexpt files via the 'generic'
preset. On Android, Tauri routes that to the Storage Access Framework
picker, which filters by MIME type. Extensions without a registered MIME
(such as .mrexpt) end up greyed-out and unselectable.
Books and dictionaries already bypass this by using an unfiltered picker
and re-applying the extension whitelist client-side. Extend the same
treatment to the 'generic' preset so callers can request arbitrary
extensions reliably on Android. iOS already had no SAF restriction.
* feat(library): in-place import with cloud sync and symmetric local delete
Adds an `inPlace` option to importBook so a source file inside a
registered external library folder is referenced directly via
`book.filePath` instead of being copied into Books/<hash>/. Sidecars
(cover, config, nav) still live under Books/<hash>/.
ingestService routes through shouldImportInPlace, which marks an
import in-place when the absolute source path lives under any of
`settings.externalLibraryFolders` and is NOT inside a per-root
`Books/` subtree. The Readest data dir (`customRootDir`) is
intentionally excluded — that directory is Readest's home and
should freely hold hash copies; in-place is for user-registered
roots (Duokan, Calibre, Moon+ Reader, an iCloud mirror, …).
Cloud sync treats in-place books as first-class:
- uploadBook reads bytes from (book.filePath, 'None') when set.
The cloud key is unchanged, so a peer downloading the book
lands it under Books/<hash>/ as a normal hash copy.
- useBooksSync strips `book.filePath` before pushing — it is a
device-local path that is meaningless on any other device.
- ingestService no longer skips upload for in-place books;
autoUpload / forceUpload behave like any other book. Only
transient imports opt out.
- deleteBook 'local'/'both' now physically removes the source
file at book.filePath (base 'None'). Local-delete semantics
are symmetric with hash-copy books: the local copy is gone,
the cloud backup remains, a future pull restores under
Books/<hash>/. removeFile errors are swallowed.
New `SystemSettings.externalLibraryFolders?: string[]` (no UI yet;
registration entrypoint lands in a follow-up). Added to
BACKUP_SETTINGS_BLACKLIST alongside `localBooksDir` /
`customRootDir` so device-local paths don't ride cloud backups.
Tests: cloud-service, ingest-service, and backup-settings suites
cover in-place delete, multi-root matching, per-root `Books/`
guard, and the backup-strip.
* feat(library): one-tap "read in place" toggle in folder import
Surface the in-place / copy choice as a single "Read books in place (don't copy)" checkbox in the Import-from-Folder dialog. When the user opts in, the chosen directory is registered in `settings.externalLibraryFolders` and ingestService's `shouldImportInPlace` will route the books straight to importBook with `inPlace: true` — no copy into Books/<hash>/, sync still works, local delete still removes the source file (the symmetry was set up in the previous in-place commit).
User experience:
- First-time users hit the toggle once per library folder. The choice is also persisted to localStorage so subsequent dialog opens default to whatever they picked last.
- Repeat imports from a folder that's already registered as an external library folder force the toggle ON and disable it, with a help line explaining that imports from this folder are always in-place. The check is exact-string (after path normalization) so registering /Users/me/Duokan only locks the toggle for that exact path — picking /Users/me/Downloads after Duokan still shows the toggle in its normal state.
- URL-ingress / drag-drop replays go through `runFolderImport` without the dialog and default `readInPlace: false`. They still benefit from in-place automatically when the dropped path lives under an already-registered root, because that decision is made by `shouldImportInPlace` based on settings, not by the dialog flag.
Mechanics:
- ImportFromFolderResult gains `readInPlace: boolean`. ImportFromFolderDialog gains an `initialReadInPlace` prop (seeded from the new `readest:lastImportFolderReadInPlace` localStorage key) and an `isRegisteredExternalRoot` predicate it uses to render the locked / unlocked toggle.
- runFolderImport calls a new `registerExternalLibraryFolder` helper that appends the chosen directory to `settings.externalLibraryFolders` and persists settings, but only when `result.readInPlace` is true. `isRegisteredExternalRoot` does the inverse lookup the dialog needs. Both helpers normalize paths the same way `shouldImportInPlace` does so the predicate matches the ingest layer.
- The new feature has no effect for users who never flip the toggle: `externalLibraryFolders` stays empty, the path-prefix check in `shouldImportInPlace` returns false for every import, and books continue to be copied into Books/<hash>/ exactly as before.
Self-healing for externally-removed in-place books:
Once the dialog lets users opt their library into in-place mode, the source file becomes a piece of state Readest doesn't control — another app may rewrite it (e.g. Duokan persisting reading progress into the epub), the user may move it in Finder, or an external drive may unmount between sessions. Previously, clicking such a book would navigate into the reader, fail inside loadBookContent's `fs.openFile(book.filePath, 'None')` with a low-level IO error, flash an "Unable to open book" toast, and auto-bounce back to the library — leaving the stale library record in place so the next tap reproduces the same dance.
BookshelfItem.handleBookClick now probes availability before navigating, but only for purely-local in-place books (`book.filePath && !book.uploadedAt && !book.deletedAt`). If `appService.isBookAvailable` returns false — which for in-place books means the recorded `book.filePath` no longer exists at the OS level — we dispatch `delete-books` for that hash and show an info toast explaining the removal, instead of opening the reader.
Scope is intentionally narrow:
- Cloud-synced books still flow through `makeBookAvailable`'s on-demand download path; missing local copies trigger a re-download, not a deletion.
- Hash-copy books (no `filePath` set) are not probed: a missing Books/<hash>/ file under normal use signals a bug or filesystem corruption, not user intent, and silently dropping the record would hide the real problem.
- The dispatched delete-books event reuses the existing Bookshelf deletion path, so sidecar metadata and selection state are cleaned up the same way as a user-initiated delete. For in-place books that path doesn't touch any file outside Books/<hash>/, so the now-missing source location (or whatever the user did with it externally) is left alone — symmetric with 165f15a6.
* fix(library): centralize book content resolution
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Some EPUBs (e.g. downloaded from Baidu Netdisk) have their first few bytes
corrupted while the rest of the archive is still well-formed. The zip spec
locates archives via the End-of-Central-Directory record at the file tail,
so a corrupted local file header signature does not actually invalidate the
file. When the leading magic check fails, fall back to scanning the last
~64 KiB for the EOCD signature (PK\x05\x06); if present, treat the file
as a zip and let zip.js read it normally.
* feat(import): support folder picker on iOS via native-bridge
Tauri's dialog plugin rejects folder picks on both mobile platforms with FolderPickerNotImplemented, so previously only Android could pick an import directory (it already routed through the native-bridge plugin's ACTION_OPEN_DOCUMENT_TREE). iOS users had no working folder-import entry point at all.
Add an iOS implementation of the native-bridge select_directory command using UIDocumentPickerViewController(forOpeningContentTypes: [.folder], asCopy: false), with a dedicated FolderPickerDelegate that:
- holds a strong reference until the picker dismisses (UIKit keeps the delegate weak), and
- calls startAccessingSecurityScopedResource on the picked URL and retains it for the app's lifetime so plain Foundation/POSIX reads against url.path work for the rest of the session.
Route NativeAppService.selectDirectory through the bridge for both iOS and Android, then call allowPathsInScopes so the picked directory is reachable via fs_scope and the asset protocol. The library page's pickImportDirectory entry point now also takes the mobile branch on iOS, while keeping the Android-only MANAGE_EXTERNAL_STORAGE prompt gated behind isAndroidApp.
* feat(ios): persist security-scoped bookmarks for picked folders
iOS hands the folder picker back a security-scoped URL whose access
right is granted only to the running process. The previous
implementation kept the URL alive for the lifetime of the process via a
static `urlsToKeepAlive` array, which worked for the current session
but forced the user to re-pick the same folder after every relaunch.
Add a `FolderBookmarkStore` that:
- Right after the picker returns, calls
`URL.bookmarkData(.minimalBookmark)` and stashes the bytes in
`UserDefaults` keyed by the POSIX path.
- On every `NativeBridgePlugin.load(webview:)`, walks every persisted
bookmark, resolves it back into a URL, and calls
`startAccessingSecurityScopedResource`. Holds the URL alive in a
process-scoped dictionary so subsequent Foundation / POSIX reads
against `url.path` succeed.
- Handles `isStale` by re-encoding the bookmark against the resolved
URL, and drops permanently unresolvable bookmarks (folder gone,
provider uninstalled) from `UserDefaults` so the next launch
doesn't re-attempt them.
Pair this with a Tauri-side change so the same paths are reachable
through both `dir_scanner::read_dir` and the fs plugin's `readDir`:
- `allow_paths_in_scopes` now has an iOS branch that widens
`fs_scope` / `asset_protocol_scope` for any path the frontend hands
it, intentionally without the desktop-side "must already be in
fs_scope" gate. The OS sandbox + bookmark store is the real
access-control boundary on iOS; widening Tauri's in-memory scope
set cannot escalate access beyond what the OS already grants. The
security comment on the command was rewritten to spell this
contract out.
- `allow_file_in_scopes` is now compiled for iOS too (previously
desktop-only) so the file-grant path is available when needed.
* fix(opds): show 'Open & Read' for publications already in the library
PublicationView only flipped the acquisition button to 'Open & Read'
when the user downloaded the book within the current component
lifecycle — the downloadedBook state started as null on every mount
and was never reconciled against the actual library. So reopening the
detail page (after navigating away in the OPDS browser, or coming back
from the reader) always showed 'Download' / 'Open Access' again and
asked the user to re-download a book they already had.
Two real-world feeds (m.gutenberg.org and ManyBooks) exposed two
distinct failure modes that made naive title/author equality unusable:
(1) Gutenberg's OPDS entries never carry <dc:identifier>. The only work
identifier lives in <atom:id>urn:gutenberg:1342:2</atom:id>, which
foliate-js's opds.js parses into metadata.id — a field
getMetadataHashInfo never reads. So the identifier candidate list
was empty and identifier-overlap matching silently skipped every
book.
(2) Author strings disagree across the boundary: the feed emits
'<author><name>Austen, Jane</name>' (Lastname-first) while the EPUB
inside the same feed ships <dc:creator>Jane Austen</dc:creator>.
Plain string equality (even after normalization) never matched.
Add findExistingBookForPublication in app/opds/utils/findExistingBook.ts
with a layered matcher:
- Pass 1: full metaHash equality (the strongest signal — same fingerprint
the bookService uses internally).
- Pass 2: identifier overlap. collectPublicationIdentifiers() splices
metadata.id into the candidate list alongside metadata.identifier, so
Gutenberg's 'urn:gutenberg:1342:2' feeds into identifierKeys(), which
extracts the '1342' digit-tail (>=3 digits, so the trailing ':2'
version suffix is ignored) and matches the EPUB's
'http://www.gutenberg.org/1342' → '1342'.
- Pass 3: tolerant title + author match. hasAuthorOverlap() does a
token-set fallback: each name is split on whitespace/comma/semicolon,
single-letter tokens (initials) and year-range tokens ('1775-1817')
are dropped, and we require >=2 shared tokens by default. So
'Austen, Jane' ↔ 'Jane Austen' match via {austen, jane} but
'Author A' ↔ 'Author B' don't (both collapse to {author} after
dropping single-letter tokens — we track raw token count to refuse
the single-token shortcut when discriminative parts were filtered).
Genuine mononyms still match on a single shared token because raw
count is 1 on both sides.
OPDSPerson[] is fed through the library's own formatAuthors so the
produced author string matches Book.author byte-for-byte (Intl.ListFormat
output, locale-aware). Soft-deleted books are skipped so removing a copy
locally reverts the button.
Wire it in opds/page.tsx by subscribing to useLibraryStore.library
(rather than the snapshot reads inside handleDownload) and memoizing
existingBookForPublication. Pass it to PublicationView, which now
seeds downloadedBook from the prop and resyncs when the prop changes
(switching publications inside the browser) — but does not clobber a
download success it observed locally before the parent recomputed.
Covered by unit tests for the helper, including the real Gutenberg
URN-in-atom-id case, ':version' suffix on the URN, 'Lastname, Firstname'
vs 'Firstname Lastname', year-range stripping, and the 'Author A' vs
'Author B' non-match.
* fix(opds): dedupe downloads by source url
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>