4eeed74cdd85cb1c02d6af95aef56d02c65075eb
1255 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4eeed74cdd |
fix(webdav): always sync book covers, not just when syncBooks is on (#4445)
Cover uploads were nested inside the `syncBooks` toggle in both the
batch path (`syncLibrary`) and the per-book reader path
(`pushBookFileNow`). With the default `syncBooks: false`, covers
silently never reached the WebDAV server even though `config.json`
did, so receiving devices ended up with progress + notes synced but
no shelf art.
Covers are conceptually metadata, not bytes:
- they're tiny (~30–60 KB after the import-time downscale);
- they cannot be regenerated on a fresh device that doesn't hold
the book bytes (custom covers from metadata services in
particular are completely unrecoverable without sync);
- cloudService already treats them as metadata-grade in its
download path (`downloadBookCovers`, `downloadBook(onlyCover)`).
Two changes:
1. `WebDAVSync.ts::syncLibrary` — moved `pushBookCover` out of the
`if (options.syncBooks)` block; it now runs alongside
`pushBookConfig`, before `pushBookFile`. Step ordering in the
header doc-comment was updated to match.
2. `useWebDAVSync.ts` — extracted a standalone `pushBookCoverNow`
callback (gated only on `allowPush`, with its own
`coverSyncedRef` for per-instance dedupe), and dropped the cover
ride-along that lived at the tail of `pushBookFileNow`. The
open-book effect now fires `pushBookCoverNow` and
`pushBookFileNow` in parallel via `Promise.all` (different remote
paths, no reason to serialize), and the manual-push event handler
triggers both independently.
The WebDAV pull path was already independent of `syncBooks`, so no
changes are needed there — receiving devices will pick up the newly
mirrored covers automatically.
|
||
|
|
d8fbf5fe08 |
fix(reader): show KOReader progress-synced as a top-right hint (#4461) (#4463)
KOReader sync displayed the "Reading Progress Synced" notification as a centered info toast that blocks the text for fast readers, while Readest's own cloud sync uses an unobtrusive top-right hint. Route the notification through the same 'hint' event (HintInfo, top-right, ~2s auto-dismiss) that useProgressSync uses, instead of the centered 'toast'. This covers both KOSync paths that apply remote progress (the auto-apply receive/silent flow and the conflict-resolved "use remote" flow); the interactive conflict-resolution dialog is unchanged. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
578b7ba14f |
fix(reader): restore annotation list auto-scroll to the nearest item (#4428)
Virtualizing BooknoteView (#4352) dropped the auto-scroll that centers the note nearest the current reading position, leaving the list stranded at the top. The single scrollToIndex that replaced the per-item useScrollToItem was missing the machinery TOCView already uses for virtualized auto-scroll: - Re-apply the scroll inside the OverlayScrollbars `initialized` callback (read via a ref): its deferred init resets the viewport scrollTop to 0, and the lastScrolledCfiRef guard otherwise blocked any retry (reload case). - Mount Virtuoso natively centered via initialTopMostItemIndex with a skip-gate, so opening the panel while reading doesn't fire a scrollToIndex that races and wedges the freshly mounted, unmeasured list (tab-switch case). - Jump instantly (behavior 'auto') for far moves and on eink, animating 'smooth' only for short in-session updates — mirroring TOCView. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7128e8964e |
fix(reader): suppress Android image callout freezing the image viewer (#4425)
Long-pressing an image in the zoom viewer on Android triggers the WebView's native image callout (context menu / drag / magnifier) at the same time as the viewer's own pinch/pan/zoom touch handlers, locking up the whole app until restart. Same root cause as the book-cover freeze (PR #4345): `-webkit-touch-callout: none` doesn't inherit, so the class must sit on an ancestor of the `<img>`. Apply the existing `.no-context-menu` class to the viewer container so the `.no-context-menu img` rule reaches the zoomed image and disables the native callout. Harmless on desktop (the property is a no-op there and right-click-save still works). Closes #4420 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e4bb9fc4b7 |
refactor(share): make saveFile content nullable for path-based shares (#4424)
The book "Send" flow had to pass `new ArrayBuffer(0)` to saveFile purely to satisfy the type-checker: the content arg is ignored on the native share path when `options.filePath` points at an already-on-disk file. Widen saveFile's content parameter to `string | ArrayBuffer | null` across the AppService contract so callers can hand off a file by path without buffering it into memory, and pass `null` from the Send flow instead of a throwaway empty buffer. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
963bab0f0f |
fix(library): stop bookshelf context menu shuffling its order (#4389) (#4421)
The bookshelf right-click menu built itself with un-awaited
`Menu.append()` calls. Each append is an async IPC round-trip to the
Tauri backend, so the concurrent fire-and-forget requests resolved in
non-deterministic order on the Rust side and the native menu items
landed shuffled on every open (only reproducible on the native app,
invisible in jsdom).
Build the items in order and create the menu in a single
`await Menu.new({ items })` call for both the book and group handlers.
Order and conditional inclusion are unchanged.
Extract the order/inclusion logic into a pure `getBookContextMenuItemIds`
helper in `libraryUtils.ts` so the deterministic ordering is unit-tested
without mounting the component or mocking Tauri.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
f9ddddb6ac |
fix(library): use ghost cancel buttons in migrate-data dialog for e-ink (#4396) (#4422)
The Change Data Location dialog rendered its Cancel/Close (secondary) buttons with `btn-outline`. Under `[data-eink='true']`, globals.css inverts both `.btn-outline` and `.btn-primary` to the same base-content fill + base-100 text, so Cancel and Start Migration collapsed into two identical black buttons and became indistinguishable on e-ink screens. Switch the secondary buttons to `btn-ghost`, matching the design-system rule (DESIGN.md): the primary CTA keeps its solid fill while the ghost cancel reads as borderless next to it, restoring the hierarchy. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
fe853554a9 |
feat(library): send book file from bookshelf selection popup (#4402)
* feat(library): send book file from bookshelf selection popup
Adds a Send button to the bottom popup that appears when one or more
books are selected on the bookshelf. Hands the actual book file
(epub/pdf/...) to the OS share sheet via tauri-plugin-sharekit
(UIActivityViewController on iOS, Intent.ACTION_SEND on Android,
NSSharingServicePicker on macOS), so users can fire the file off to
Mail / Messages / WeChat / AirDrop / etc.
This is intentionally distinct from the per-item context-menu "Share
Book", which uploads the book to the readest backend and generates a
public link. "Send" is offline file egress; "Share Book" is remote
collaboration. They share zero infra.
Resolution rules mirror bookContent.resolveBookContentSource: managed
copy under Books/<hash>/ first, then the device-local in-place import
path. Cloud-only books warn rather than silently no-op.
Path is handed to shareFile via options.filePath. Without that,
saveFile() falls back to writing a temp copy under BaseDirectory.Temp,
which on Android resolves to /data/local/tmp/ — the app sandbox has
no write permission there and the call fails with EACCES ("failed to
open file at path: /data/local/tmp/...epub Permission denied (os error
13)"). Passing the absolute path also avoids re-buffering the entire
epub/pdf into memory.
On macOS the NSSharingServicePicker is anchored to the selected
book's cover rather than to the Send button — the user's visual
focus is on the cover they just tapped, not on the bottom toolbar.
BookshelfItem stamps a data-book-hash attribute on its root div so
the Send handler can locate the cell via querySelector and pass its
rect through saveFile's sharePosition option. preferredEdge='bottom'
maps to NSMinYEdge, so the popover renders above the cover (and only
auto-flips below when there's no room above). iOS / Android share
sheets are modal and ignore sharePosition, so the same code is a
no-op there.
The button is hidden on Linux (no system share sheet), Windows
(WebView2 share UI deadlocks the main thread, see #4343), and web
browsers (no "send file to <app>" affordance for arbitrary downloads).
* fix(share): don't fall back to saveDialog when shareFile is cancelled
The native saveFile({ share: true }) path used to swallow any error
from sharekit's shareFile() and fall through to saveDialog. The plugin
treats user cancellation the same as a failure (it rejects with
'Share cancelled' on Android when the user dismisses the share sheet),
so cancelling a share popped up an unwanted 'Save As...' dialog right
after the user explicitly chose not to share.
Mirror what webAppService already does for the navigator.share()
AbortError path: once we entered the share branch, return true
regardless of whether the share completed or was cancelled. The
saveDialog path is now reserved for Linux/Windows desktop, which never
hit the share branch in the first place (wantShare gates them out).
If a future caller wants 'try share, fall back to save on hard
failure', that decision belongs to the caller — saveFile shouldn't
silently override an explicit share intent.
|
||
|
|
4abbc0254c |
fix(reader): stop footer progress info painting a stray focus ring (#4397) (#4418)
The always-on page-info footer (`.progressinfo`) is a decorative
`role='presentation'` element, but it carried `tabIndex={-1}`, which made
it focusable. On Android, long-pressing the footer focused the div and the
WebView painted its default focus ring (`outline: auto`). Because the
element is pinned `absolute bottom-0` at book-view width, the ring rendered
as a content-column-wide line across the bottom of every page and persisted
until focus cleared.
Remove the `tabIndex` so the decorative element can no longer receive
focus. The `onClick` (tap-to-cycle progress mode / dismiss popup) still
fires regardless of tabindex, nothing focuses it programmatically, and the
translated `aria-label` keeps it exposed to screen readers unchanged.
Closes #4397
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
726f53a64b |
fix(reader): use Tauri clipboard plugin for copy on Android (#4409)
navigator.clipboard.writeText is unreliable inside the Tauri Android WebView, so tapping Copy in the Reader's selection popup silently no-ops. Route the write through @tauri-apps/plugin-clipboard-manager on Tauri targets (Android, iOS, macOS, Windows, Linux), with a graceful navigator.clipboard / execCommand fallback for the web build and older WebViews. - Add tauri-plugin-clipboard-manager (Rust + JS) - Register the plugin in lib.rs - Grant clipboard-manager:allow-write-text / allow-read-text - New utils/clipboard.ts wrapper with platform-aware fallback chain - Annotator handleCopy and handleConfirmExport use the wrapper |
||
|
|
3a81e09911 |
fix(reader): scroll oversized blocks in-place instead of turning the page (#4400) (#4415)
Wide or tall tables, code blocks and display equations overflowed the reading column and a scroll gesture over them turned the page instead of scrolling the content (#4400). - Wrap tables and display equations in a horizontally/vertically scrollable container; route touch + wheel along the box's scrollable axis so it scrolls the box and never turns the page, even at the edge (both axes). - A box that fits its column is marked fit (overflow:visible) so it never clips or captures gestures; the fit decision is measured once after layout via a self-disconnecting ResizeObserver, so it never relayerizes during a page turn. - The scroll wrapper carries a new cfi-skip attribute that makes it transparent to CFI: epubcfi.js hoists a cfi-skip node's children into its parent (unlike cfi-inert which drops the subtree), and xcfi.ts mirrors this for CFI<->XPointer so existing highlights, bookmarks and KOSync positions inside a wrapped table or equation still resolve. The sanitizer whitelists cfi-skip. - Bump foliate-js submodule (cfi-skip support + raf fallback for large sections). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
458ad7510c |
fix(reader): scroll wide EPUB tables horizontally (#4391)
* fix(reader): scroll wide tables horizontally instead of scaling Why: - Wide EPUB tables (e.g. many columns without cell widths) overflowed the page because CSS scale only applied when widths were known. - Paginated mode stole horizontal swipes for page turns over table content. Refs: - Replaces transform-based applyTableStyle scaling with a scroll wrapper and capture-phase touch routing (same pattern as gesture brightness). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(reader): keep wheel/trackpad table scrolling from turning the page Horizontal scrolling of a wide table in paginated mode also turned the page: - applyTableTouchScroll only routed touch events. Trackpad/mouse wheel (readest forwards iframe wheel -> 'iframe-wheel' -> pagination) was never intercepted, so a horizontal wheel both scrolled the table and flipped the page. - findWrapper used `instanceof Element`, which is always false for iframe event targets because this module runs in the top-window realm. The touch routing therefore never fired either. Add a capture-phase wheel handler that consumes horizontal wheels over a scrollable table -- including at the scroll edge, so the gesture (and trackpad momentum) never chains into a page turn -- and make findWrapper cross-realm safe via duck-typing on `closest`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): don't show a spurious scrollbar on layout tables Wrapping every table for horizontal scrolling made tables that fit the column (e.g. character/glossary layout tables) show a spurious horizontal scrollbar, because the wrapper was always scrollable. Now the wrapper clips (no scrollbar) for a table that fits within a few px of the column, and a ResizeObserver re-evaluates this as the column width settles. A table genuinely wider than the column always scrolls and is never clipped; one that wraps to fit shows no scrollbar. Touch/wheel routing engages only scrollable wrappers. Add a Chromium browser test over sample-table-layout.epub (layout tables must not scroll) and sample-table-wide.epub (a too-wide table must scroll, not clip). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
97191a57c0 |
fix(reader): stop reading ruler creeping down on scroll (#4386) (#4388)
In scrolled mode the ruler's geometry-cache effect re-ran on every relocate — including those fired continuously while scrolling — and re-snapped the band to the next line forward from a screen-fixed anchor, so the band crept down the page as the reader scrolled. Place the band once on mount (and after a viewport-dimension change), but never re-snap on a plain scroll relocate. Click-driven snapping (the reading-ruler-move handler + pendingScrollAlign realign) is unchanged, and paginated mode is unaffected. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e8675fb7eb |
fix(reader): inline custom @font-face rules in iframe stylesheet (#4383)
* fix(reader): inline custom @font-face rules in iframe stylesheet The reader iframe's first paint resolved with the default serif/sans fallback and only swapped to the user's configured custom font a moment later, producing a visible font flash when opening a book. Custom font @font-face rules were registered via mountCustomFont on the host document only, while paginator's setStyles writes its CSS into the iframe synchronously before the first 'load' event. The iframe had no knowledge of the user's custom fonts at that point, so font-family declarations in the stylesheet matched nothing and fell back. Inline the @font-face rules for every loaded custom font (blob URLs already in memory, no network round-trip) at the front of getStyles output, so paginator delivers them to the iframe atomically with the rest of the stylesheet. Defensive try/catch around createFontCSS keeps a single bad font from breaking the whole stylesheet. * refactor(reader): pass custom fonts into getStyles instead of reading the store getStyles lives in src/utils, where every other file is a pure function; it was the only one importing a store (useCustomFontStore). Keep the util pure: accept the loaded custom fonts as a parameter and let the reader components — which already own the font store — supply them. - style.ts drops the useCustomFontStore import and the SSR/store-error guards in getCustomFontFaces; the helper is now a pure CustomFont[] -> CSS transform. - getStyles(viewSettings, themeCode?, customFonts = []) inlines the @font-face rules for the passed fonts. - The first-paint call sites (FoliateViewer, FootnotePopup) pass getLoadedFonts(); settings-panel re-styles keep the default [] since custom fonts are already mounted as persistent <style> elements there. - Add tests that exercise the font-face inlining path (the existing suite never did, since the store is empty under jsdom). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d0071a6bcb |
fix(sync,reader): discard malformed sync CFIs; fix swipe background flash (#4370)
sync: empty-start/end range CFIs left by the cfi-inert skip-link bug (e.g. epubcfi(/6/24!/4,,/20/1:58)) resolve to a section-spanning range and navigate to the wrong end of the section. Add isMalformedLocationCfi and discard such locations on the cloud-sync receive path (useProgressSync) and the kosync push path (useKOSync) so they can't move the reader or propagate to other devices. foliate 569cc06 stops generating them but does not repair already-synced values. reader: bump foliate-js to 167757a to fix the white<->black background flash when swiping between differently-colored pages; add a regression test for the sliding per-view background segments. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ef603852b7 |
feat(tts): hotkey to highlight the currently-spoken sentence (#4085) (#4368)
Add a "Highlight Current Sentence" keyboard action (default Shift+M, in the Text to Speech shortcut section) that persists the sentence TTS is reading aloud as a normal highlight using the user's default style/color — no text selection, eyes-off, silent, and idempotent (a repeat press on the same sentence is a no-op rather than a duplicate). Flow: the shortcut handler in useBookShortcuts dispatches tts-highlight-sentence → useTTSControl (which owns the TTSController) resolves the current sentence via the new TTSController.getSpokenSentence() and relays create-tts-highlight → Annotator builds the BookNote with the pure, unit-tested buildTTSSentenceHighlight helper and persists/renders it like any other highlight. Closes #4085 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c23c21d37d |
fix(kosync): reflowable conflict comparison via local CFI; scrolled-mode + library fixes (#4367)
* feat(kosync): compare reflowable conflicts via locally-resolved CFI percentage KOReader reports progress as a percentage from its own pagination, which isn't directly comparable to Readest's progress. For reflowable books, resolve the remote XPointer to a local CFI and compute the equivalent fraction (getRemoteLocalFraction), comparing that against the local percentage and falling back to the reported percentage only when it can't be resolved locally (non-XPointer progress or a missing section). The resolved fraction also drives the conflict-dialog remote preview so the shown value matches what was compared. Loosen the conflict threshold to 0.01 when the remote progress was last pushed from this same device (remote.device_id === local deviceId), so sub-page drift between a push and the next pull doesn't prompt. Render sync percentages with 2 decimals via formatProgressPercentage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): correct scrolled-mode reopen drift over background-image sections Bump the foliate-js submodule to include the scrolled-mode reopen drift fix for sections with background images, and add a browser regression test plus its EPUB fixture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(library): redirect to login on pull-to-refresh when signed out Guard the pull-to-refresh handlers so an unauthenticated user is sent to the login screen instead of attempting a library pull and OPDS subscription check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(memory): add kosync conflict + toc/scrolled-restore notes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): prevent CFI crash on inert-only section bodies Reopening/paginating across a background-image or otherwise content-less section could crash with "Cannot destructure property 'nodeType' of 'param' as it is undefined" in foliate's fromRange, aborting the relocate so the reading position was never saved. Bumps the foliate-js submodule to 569cc06 (visible-range walker skips cfi-inert skip-links; isTextNode/isElementNode are null-safe) and adds a regression test reproducing the exact crash. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(kosync): keep auto-push working when a pull finds no real conflict In the 'prompt' strategy, pullProgress set syncState to 'conflict' unconditionally on every pull that returned remote progress, even when promptedSync found no actual difference. Since auto-push only runs while 'synced', and a pull fires on every book-open and window re-activation, progress stopped being pushed. promptedSync now returns whether a real conflict was surfaced, and pullProgress only stays in 'conflict' for genuine conflicts (otherwise 'synced'). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): show most recent sync time and reorder settings tabs Library settings menu now reports the latest of the book/config/note sync timestamps as "Synced …" instead of only the books timestamp. Reorder the settings tabs so Integrations precedes TTS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
11666be5ee | fix(reader): collapse TOC to the current chapter's path by default (#4366) | ||
|
|
92b3c9db48 |
fix(kosync): resolve progress CFI via its own spine section (#4364)
generateKOProgress built its XCFI converter from the paginator's
primaryIndex and the rendered primary document, then converted
progress.location. Because #primaryIndex can lag behind the viewport
during scrolling, the CFI's spine section could differ from the
converter's, tripping XCFI's guard ("CFI spine index N does not match
converter spine index M") and silently dropping the progress push.
Route through getXPointerFromCFI, which keys off the CFI's own spine
index and loads the correct section's document from the book when the
rendered index doesn't match. Fall back to the cached config.xpointer
on failure.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
aa318904b5 |
fix(reader): show full bookmark ribbon in scrolled mode header (#4365)
In scrolled mode, SectionInfo paints a solid `bg-base-100` `notch-area` mask over the top safe-area strip at z-10. The Ribbon was also z-10 but rendered earlier in the DOM, so the equal-z mask painted over the ribbon's upper (unsafe-area) half — only the lower 44px showed. In paginated mode the mask has no background, so the ribbon showed fully. Raise the ribbon to z-20 so the whole ribbon stays visible above the mask, and mark it pointer-events-none so taps still fall through to the notch mask's scroll-to-top handler. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
78794499a2 |
fix(dictionary): correct System Dictionary platform gating on web and iPad (#4362)
* fix(dictionary): keep other dictionaries usable when System Dictionary syncs to an unsupported platform `dictionarySettings.providerEnabled` is whole-field synced across devices, so enabling System Dictionary on macOS/iOS sets the flag on web/Linux/Windows too. There the row is hidden and the feature is a no-op, but the settings UI read the raw flag and locked every other dictionary's toggle read-only. Gate the lock on `isSystemDictionaryEnabled(settings)` — the same platform-aware check the annotator uses — so it matches real lookup behavior and never triggers where the system dictionary can't run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dictionary): dispatch system dictionary handoff by native OS (fixes iPad) iPadOS sends a desktop "Macintosh" user agent, so the UA-based `getOSPlatform()` reported iPad as 'macos' and the handoff invoked the macOS-only `show_lookup_popover` Rust command that iOS never registers ("Command show_lookup_popover not found"). Derive the OS from the app service's `is*App` capability flags (sourced from the Tauri OS plugin, correct on iPad) via a new synchronous `getInitializedAppService()` accessor, so iPad routes to the iOS plugin command path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ui): constrain reader View Options dropdown to h-8 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(agent): note System Dictionary platform-detection and synced-flag patterns Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f1ae050768 |
fix(ui): refine reader side panels and their empty states (#4361)
* docs(agent): add agent notes for cache, reading-ruler, foliate touch Add project-memory notes and index entries: - manage-cache-ios-layout: iOS container layout and what Manage Cache clears - reading-ruler-line-aware: line/column-aware reading ruler internals - foliate-touch-listener-capture-phase: capture-phase gesture suppression Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): pad sidebar and notebook for the device status bar (#4089) Top-anchored slide-in panels (sidebar, notebook) only applied status-bar top padding when isFullHeightInMobile was true. On a tablet/desktop (isMobile === false) that gate collapsed the padding to 0, so a visible system status bar overlapped the panel's top toolbar and made its icons inaccessible. Extract the inset math into getPanelTopInset() and gate it on (!isMobile || isFullHeightInMobile) so non-mobile panels clear the status bar like the reader header, while a partial-height mobile bottom sheet (which doesn't reach the top of the screen) stays flush. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): keep footer bar clear of the pinned sidebar On a mobile tablet in portrait, forceMobileLayout renders the footer bar with position: fixed, anchored to the viewport, so left-0 w-full spans the whole window and slides under a pinned sidebar — the progress / font / TTS controls end up obscured. Anchor the footer inside the book's grid cell (position: absolute) when the sidebar is pinned, mirroring the header bar. The flex layout already offsets the grid cell by the sidebar's real rendered width, which honors the sidebar's min-w-60 floor and 45% cap that a stored-width offset would miss. The slide-up panels are absolute within the footer container, so they shift and narrow with it and their animation is unchanged. The switch only happens when the sidebar is pinned, so phone (< 640px) and unpinned tablet-portrait class names stay identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ui): refine reader side panels and their empty states Closes #4089 Add a shared EmptyState component (large muted icon, title, and an optional hint or action) and use it for the empty annotations, bookmarks, and notes panels in the sidebar and notebook, replacing the ad-hoc "No … yet" placeholders. Polish the surrounding chrome: switch the bookmark toggler to the Ri icon set with responsive sizing, crop the HighlighterIcon viewBox to its artwork to remove the asymmetric bottom padding, and tune mobile sizing and spacing across the panel headers, tab navigation, and footer nav bar. Translate the new empty-state strings (No Notes, No Annotations, No Bookmarks, and their hints/action) across all 33 locales and drop the obsolete "No … yet" keys. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
789d031222 |
feat(reader): line-aware reading ruler (#4358)
Snap the reading ruler to real rendered text lines instead of stepping by a fixed arithmetic height, so the band always frames whole lines. - Snap to actual line geometry from the relocate range; the band is sized dynamically to the text block plus symmetric padding (round(fontSize * lineHeight * 0.3)), capped at (lines + 1) line heights so a tall image inside a block can't expand it to cover the whole figure. - Drop block/container rects (Range.getClientRects aggregates multi-line element borders) so paragraphs aren't merged into one giant line and skipped. - Column-aware in multi-column layouts: the band spans one column at a time and advances column by column. - Confine the band to lines at least half visible within the viewport. - Scrolled mode: snap to lines, and at a view edge scroll the view and realign the band to the start/end of the new view (works for vertical-rl too, which scrolls horizontally); paging snaps the view edge between lines so text isn't cut or repeated. - Vertical writing mode: correct band centering and drag direction; Up/Down keys move the ruler while Left/Right turn pages (taps always move the ruler). - Page turns keep the first/last line: forward lands on the first line of the new page, backward on the last line; the relayout re-snap anchors on the band's leading edge so it never skips a line. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
36e11de332 |
feat(reader): swipe-to-adjust brightness gesture on mobile (#3021) (#4356)
* docs: design spec for gesture-based brightness control (#3021) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: revise brightness-gesture spec per /autoplan review (#3021) CEO+Design+Eng dual-voice review. Key fixes: capture-phase listener (bubble-phase could not suppress foliate paginator), opt-out toggle, 18px threshold, selection guard, brightness seed race, rAF teardown, e-ink stepped overlay, contrast capsule, perceptual curve reuse, listener-level test harness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): swipe-to-adjust brightness gesture on mobile (#3021) Left-edge vertical swipe adjusts screen brightness on iOS/Android, with a Sun-icon progress overlay. Capture-phase non-passive listener suppresses the foliate paginator / page-flip / UI-toggle handlers; selection guard, strip reservation in scrolled mode, eager brightness seed, rAF throttle + teardown. Opt-out toggle in Settings > Behavior > Device (default on). Perceptual curve shared with the menu slider. Pure-helper + listener-level tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): detect brightness-swipe edge by screenX; i18n + shorter label (#3021) On-device fix: paginated mode lays the iframe doc out as wide side-by-side columns, so clientX/documentElement.clientWidth are document coordinates and a left-edge touch on a later page never fell inside the strip (armed stayed false). Detect with screenX against the parent window width, matching usePagination. Also: translate the two new setting strings across all locales and shorten the toggle description to 'Slide along the left edge'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7bdd3ecdee |
perf(sidebar): virtualize BooknoteView and memoize derivations (#4352)
Switching the annotation/bookmark sidebar to a flat virtualized list eliminates
the per-item layout reads that caused multi-second jank when toggling tabs on
books with hundreds of notes.
A. Virtualize the list with react-virtuoso
- Flatten group headers + notes into a single FlatBooknoteRow array.
- Embed an OverlayScrollbars instance inside the tab so scrollbar styling
is preserved while Virtuoso owns the viewport (same nested pattern as
TOCView).
- Track the parent scroll-container's height with ResizeObserver to give
Virtuoso a bounded viewport.
- Replace the per-item useScrollToItem (which called getBoundingClientRect
and closest() on every BooknoteItem on every progress tick — O(n) sync
reflow on 1000+ items) with a single virtuosoRef.scrollToIndex driven by
nearestCfi.
B. Stabilize derivations with useMemo / useCallback
- filteredNotes, sortedGroups, flatItems, nearestCfi all useMemo so an
unrelated config change (e.g. viewSettings autosave) no longer triggers
a full sort + group rebuild.
- handleBrowseBookNotes is now useCallback so BooknoteItem's React.memo
can hit on prop equality.
C. Memoize BooknoteItem
- Wrap the component in React.memo. With stable item / onClick references
from the parent, re-renders triggered by sibling progress updates no
longer cascade across every visible row.
- Cache marked.parse(item.note) and dayjs(item.createdAt).fromNow() in
useMemo. marked is the dominant per-render cost for note rows.
- isCurrent moves to a useMemo over isCfiInLocation; the per-item
scrollIntoView is removed since BooknoteView now drives scrolling.
useScrollToItem is intentionally left intact — SearchResults still uses it
and its smaller list does not exhibit the same jank.
|
||
|
|
6405ba31c8 |
fix(reader): keep TOC scrolled to the current chapter on refresh (#4353)
On a hard refresh the TOC sidebar occasionally (~1 in 10) scrolled to and highlighted the current chapter, then rewound to the very top of the list. It is a scroll-position race in TOCView, not a progress/sectionHref reset (the reading position stays correct throughout). OverlayScrollbars resets the wrapped Virtuoso viewport's scrollTop to 0 when it initializes (deferred). Its `initialized` callback re-scrolled only to `initialScrollTarget.index`, captured at mount — and on a fresh refresh `progress` is not available yet, so that index is 0 and the reset is never corrected. Whether OverlayScrollbars initializes before or after the auto-scroll to the reading position is the timing race that made it intermittent. Re-apply the scroll to the current active item (via refs mirroring the live flatItems/activeHref) in the `initialized` callback, falling back to the mount-time index. Refs are used because OverlayScrollbars binds the callback at mount and fires it later, so it must read the latest active item. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
3c134380b7 |
feat: add empty state hints and loading indicators for annotations, bookmarks, notes, font import, and Moon+ Reader import (#4338)
* feat: add empty state hints and loading indicators for annotations, bookmarks, notes, font import, and Moon+ Reader import - BooknoteView: show 'No annotation yet' / 'No bookmark yet' when empty - Notebook: show 'No note yet' when no notes/excerpts exist - Annotator: add loading overlay with spinner during Moon+ Reader import - mrexpt: yield to event loop every 5 entries to keep spinner animating - CustomFonts: show in-place loading card during font import, spinner transitions to font name without layout jump * fix(ui): respect e-ink overlay styling and drop dead className branch - Annotator: use modal-box on the mrexpt import overlay so eink picks up the no-shadow + 1px border override automatically. - CustomFonts: collapse importing-card clsx ternary whose branches were identical into a flat className. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
bb81d6270f |
fix(reader): keep Android paginated text selection from jumping back to first rendered section (#4342)
The Android-only workaround that pinned the container scroll while text was selected saved `renderer.start` (section-relative) and restored it as `renderer.containerPosition` (absolute). On later sections those two diverge — restoring the small `start` value as `containerPosition` snapped the multi-view scroll back to the first rendered section whenever the OS selection handle drag triggered a scroll. Save and restore the same `containerPosition` value instead. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e29331bea9 |
fix(sync): prevent cross-device progress overwrite; retry first pull on flaky networks (#4341)
Closes #4222. Three changes that together stabilize Readest sync across devices: **Stop the artificial updatedAt bump in useProgressAutoSave** saveConfig unconditionally bumps config.updatedAt = Date.now(), and useProgressAutoSave used to fire saveConfig on the very first relocate after book open — even though that relocate just reflects the position loaded from disk, not user action. The stale local config then looked "newer" than a fresher server-side push, so the next auto-push overwrote the other device's real progress via last-writer-wins. The hook now snapshots the loaded location and skips saveConfig when the in-memory location still matches it. **Retry the first config pull with backoff + release the gate** useProgressSync gates pushes behind a successful pull (so a brand-new import can't clobber the server's real progress). But handleAutoSync only re-arms on progress.location changes, so a single failed pull (Android cold-start contention, Wi-Fi/LTE handoff, captive portal) used to block every push for the whole reader session. The new pullWithRetry retries on backoff (1500/4000/10000 ms) and releases the gate after exhaustion — server-side last-writer-wins still protects the cross-device case (a stale local push with an older updated_at loses to a fresher server record). sync-book-progress events reset the chain so manual pull-to-refresh recovers cleanly. Fetch timeout bumped from 8s to 15s to better tolerate slow networks in that cold-start window. **Server piggybacks books.progress off configs push** /api/sync POST now updates books.progress + books.updated_at for each upserted config, gated by .lt('updated_at') so a concurrent newer books push is never downgraded and a missing row is a silent no-op (useBooksSync still seeds new rows from the library page). The in-reader syncBooks round-trip is dropped — the reader now sends one POST per auto-save instead of two, and the books row stays consistent with config pushes even while a reader stays open (#4198). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
648c35b334 |
feat(reader): add disableSwipe option to disable swipe-to-paginate (#4335)
Issue #4288: users with hardware page-turn buttons (e.g. e-ink readers) want to disable swipe-to-paginate so accidental finger drags during highlight selection don't flip the page mid-annotation. The existing "Tap to Paginate" toggle only covers taps; swipe was always on. - New `disableSwipe: boolean` on `BookLayout` (default `false`, declared right after `disableClick`). - foliate-js submodule bump: paginator gates `#onTouchMove` and `#onTouchEnd`'s snap-to-page on a new `no-swipe` attribute, so native touch behaviour (text selection) stays intact. - `FoliateViewer` sets/removes the `no-swipe` attribute alongside `animated`, and the `ControlPanel` toggle pushes the change to the live renderer so it takes effect without a viewer reset. - The fixed-layout swipe interceptor in `usePagination` also bails when `disableSwipe` is on, covering both reflowable and fixed- layout books. - New "Swipe to Paginate" UI row directly below "Tap to Paginate"; both can be off simultaneously. - i18n: 33 locales translated. Also polish: rephrase the two helper texts under "Read books in place" in `ImportFromFolderDialog`. The previous copy ("Copy no book into the library to save space.") used an awkward double-negative; the new wording is clearer and the locked variant drops "registered as" / em-dash for a plain two-sentence form. i18n updated. Closes #4288 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
315d144d8a | fix(library): suppress loading-dots flicker on reader→library return (#4325) | ||
|
|
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>
|
||
|
|
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.
|
||
|
|
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>
|
||
|
|
29c88c0216 | fix(opds): make Android Back / Reader→Back behave correctly inside the OPDS browser (#4311) | ||
|
|
e0ce6c8c22 | feat(reedy): Appendix A · Phase 4 — custom thread UI on AgentRuntime (#4308) | ||
|
|
6bc4a96b99 |
feat(reedy): Phase 1B — wire Reedy into the chat, settings, and Sources UI (#4296)
* feat(reedy): wire RetrievalBackend interface + metrics into the chat adapter
Phase 1B backend integration. Adds a RetrievalBackend interface so
TauriChatAdapter holds a uniform reference instead of branching across the
file, with two impls — LegacyIdbBackend (wraps the existing IDB ragService
unchanged) and ReedyBackend (lazy-opens reedy.db, adapts the active
provider's embedding model to Reedy's narrower shape, exposes a Vercel
`lookupPassage` tool). selectBackend() gates Reedy behind both
aiSettings.reedy.enabled AND isTauriAppPlatform() per plan D15 so the MVP
cohort is desktop-only.
The Reedy path streams via streamText({ tools: { lookupPassage }, stopWhen:
stepCountIs(3) }) with a status-aware system prompt that tells the model
how to phrase responses for each RetrieverStatus value.
Replaces the module-global `lastSources` + 500ms poll with a per-instance
ReedySourceStore keyed by a synthetic per-turn id the adapter generates,
so the Sources dropdown stops racing on global state. Both legacy and
Reedy backends now feed citations through the same store; the UI is
backend-agnostic via a shared SourceItem shape both ScoredChunk and
RetrievedChunk satisfy.
Adds the reedy_metrics table to the reedy migration (versioned with
app_version + session_id + turn_id per row) plus a ReedyMetrics writer
that ReedyBackend uses to record indexing-lifecycle and tool-use events.
Always-on local; no network egress. NoopReedyMetrics keeps construction
cheap before the DB opens.
Tests: retrievalBackend selectBackend gates, ReedySourceStore semantics
(append/replace/subscribe/clear), ReedyMetrics debounced batching +
exportBundle, and a TauriChatAdapter contract test that asserts the
Reedy/legacy code-paths pass the right args to streamText.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reedy): UI wiring — settings toggle, clickable sources, feedback bundle
Phase 1B UI integration. AIAssistant now constructs the active backend
(legacy or Reedy) via selectBackend with the platform gate from M1.7,
wires a ReedySourceStore for the chat adapter, and routes Sources-dropdown
clicks to `getView(bookKey)?.goTo(source.cfi)` when the source has a CFI.
Legacy-path sources still render as static rows because they have no CFI.
AIPanel grows a 'Reedy Retrieval (Beta)' BoxedList with the toggle and a
'Send Reedy feedback' button that calls exportReedyMetricsBundle and
triggers a JSON download of the last 90 days of events. The toggle is
disabled on web with an explanatory description per plan D15.
Thread accepts an onSourceClick callback and renders each source as a
button when its source carries a CFI, otherwise as a static div — so the
Sources dropdown is backend-agnostic via the shared SourceItem shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
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. |
||
|
|
ca17131f2a |
fix(reader): keep New Chat button visible above Android nav bar and force theme contrast (#4287)
The floating 'New Chat' button in the chat history sidebar suffered from two issues on mobile: 1. On Android (e.g. Pixel 9 with the gesture pill) the button rendered underneath the system navigation indicator because its position used plain bottom-4 with no safe-area inset. 2. With bg-base-300 / text-base-content the pill could collapse to a nearly invisible solid black shape under some themes / contexts where text-base-content was inherited as a near-background color, hiding the icon and label entirely. Fixes: - Offset the wrapper by env(safe-area-inset-bottom) + 1rem so the button sits above the Android gesture pill and iOS home indicator. - Switch the button to bg-primary / text-primary-content with shadow-md to guarantee strong contrast across all themes. - Add pointer-events-none on the positioning wrapper and pointer-events-auto on the button itself so the floating layer never blocks list interaction. |
||
|
|
5e366018df |
fix(cbz): ComicInfo metadata + CBZ page count + WebDAV i18n (#4282)
* fix(cbz,i18n): ComicInfo metadata + CBZ page count + WebDAV i18n Closes #4253 (ComicInfo.xml not read) and #4255 (CBZ shows "1 page left"). CBZ / ComicInfo (foliate-js submodule + Readest derivation): - comic-book.js: find ComicInfo.xml in subdirectories too, parse description / subject / identifier / published / series fields beyond the prior name+position pair. Series Count populates the canonical `belongsTo.series.total`; no top-level duplication. - bookService.ts / readerStore.ts: derive `metadata.seriesTotal` from `belongsTo.series.total` in parallel to the existing series / seriesIndex derivation. - ProgressBar / FooterBar / DesktopFooterBar: drop the hard-coded `pagesLeft = 1` for fixed-layout books and compute it from `section.total - section.current`. FooterBar uses `FIXED_LAYOUT_FORMATS.has(bookFormat)` so CBZ picks `section` (correct image count) instead of `pageinfo` (locations). - ProgressBar: switch the remaining-pages text to "in book" for fixed-layout titles (no chapter structure) and keep "in chapter" for reflowable books. WebDAV refactor for translation coverage: - WebDAVBrowsePane / SyncHistoryPanel called `t(...)` (passed as a prop) instead of `_(...)`. The i18next-scanner only looks for `_`, so ~53 strings were unreachable and shipped in English to every locale. Switched both components to call `useTranslation()` themselves; helpers that aren't React FCs take `_: TranslationFunc` so the scanner sees the literal calls. - WebDAVClient.checkConnection now returns a `code` discriminator (`SERVER_URL_REQUIRED` / `AUTH_FAILED` / `ROOT_NOT_FOUND` / `UNEXPECTED_STATUS` / `NETWORK`); raw English `message` is reserved for the dev console. New `formatConnectError` and `formatSyncError` helpers in WebDAVForm translate via a switch where each branch is a literal `_('...')`. Same treatment for the sync-failure path that previously surfaced raw e.message. - "Syncing 0 / {{total}}" is now parameterized as "Syncing {{n}} / {{total}}" with n=0 at startup so the digit formats naturally and the template can be reused mid-sync. - "Cleanup · {{count}} book(s)" hard-coded options used unsupported ternary; rewrote as plural-aware key. i18n scanner fix (i18next-scanner.config.cjs): - vinyl-fs walked into directories whose names end in source-file extensions (Next.js route folder `runtime-config.js/`, Playwright screenshot folder `*.test.tsx/`) and crashed with EISDIR. Resolved by expanding globs via `fs.globSync` and filtering to files only before handing to the scanner. TypeScript-syntax sites that broke esprima during extraction: - WebDAVBrowsePane / WebDAVForm: `(e as Error).message` and `failed[0]!.title` inside `_(..., options)` arguments. Replaced with `e instanceof Error ? e.message : String(e)` and `failed[0]?.title ?? ''` — also runtime-safer. User-facing em-dash cleanup: - Removed em-dashes from translation keys across SyncHistoryPanel / WebDAVForm / WebDAVBrowsePane / SyncPassphraseSection / send/page / replicaCryptoMiddleware / AIPanel. Tagline in `layout.tsx` kept. Locale translations: - ~2400 translations applied across all 33 locales for the keys that were either newly extractable, freshly worded, or pre-existing but untranslated. Zero `__STRING_NOT_TRANSLATED__` remain after the run. Misc: - next.config.mjs: drop `eslint.ignoreDuringBuilds: true` so build runs the same lint as CI. - Collection type: add `total?: string` for ComicInfo series count. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(test): fix vitest invocation, run with 4 workers `pnpm test:pr:web` was chaining `pnpm test -- --watch=false`, which pnpm expanded into: dotenv -e .env -e .env.test.local -- vitest -- --watch=false The second `--` made vitest treat `--watch=false` as a positional file pattern, not a flag. Vitest then fell back to defaults (in CI's non-TTY env that still meant a one-shot run, so the suite passed), but the worker pool was effectively serialized for big chunks of the 243-file run — wall ~90 s on a 4-vCPU runner where the parallel-sum of phases was ~236 s (≈2.6× effective parallelism). Replace the chained pnpm invocation with a direct call to `vitest run --maxWorkers=4`, matching the 4 vCPUs the GH Actions ubuntu-latest runner provides. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5c82351ab9 |
feat(integrations): add WebDAV sync to Reading Sync settings (#4204)
* feat(integrations): add WebDAV sync to Reading Sync settings Adds a WebDAV entry under Settings -> Integrations -> Reading Sync with configure/browse UI, library-wide Sync now, and per-book sync of progress, annotations and (opt-in) book files + covers. Reading progress and annotations are always synced when WebDAV is enabled; only Sync Book Files stays as a toggle since it's bandwidth-heavy. * feat(webdav): add diagnostic sync history panel and document viewSettings invariant Surface a per-run history for the WebDAV "Sync now" button so users can self-triage failures without rummaging through the dev console — a screenshot of the panel is now enough to file a useful bug report. The same change tightens the docs around viewSettings so the "device-local UI preferences" boundary is impossible to misread on the next refactor pass. Sync history panel: * New WebDAVSettings.syncLog ring buffer (cap 10), persisted alongside the rest of settings so a screenshot survives across app restarts. WebDAVSyncLogEntry captures startedAt, finishedAt, status (success / partial / failure), trigger, the eight counters from SyncLibraryResult, the toast text, and an optional per-book failure list with a phase tag (download / upload-config / upload-file). * SyncLibraryResult gains a failedBooks: SyncFailureEntry[] field. The two existing failure points in syncLibrary (download catch, upload catch) now record per-book reason+phase via formatFailureReason(), which keeps the persisted blob small by stripping stacks/whitespace and capping length at 200 chars. * WebDAVForm.handleSyncNow now timestamps the run, builds an entry from the result on success/partial paths and from the caught error on failure paths, and appends through a fresh-read appendSyncLogEntry() so concurrent toggle changes can't clobber the log. * New SyncHistoryPanel + SyncStatusBadge + SyncHistoryDetails components render the log inline in the Settings page. The detail row groups counters into three semantic columns (activity, skipped, outcome) on a six-column grid so labels can wrap freely while numbers stay tabular and right-aligned. Per-book failures render as a separate stack below the counters. viewSettings invariant: * buildRemotePayload and pullBookConfig already implement the right thing — only progress/location/xpointer/booknotes travel; viewSettings stays device-local. Comments now spell out the contract on both sides so future contributors don't reintroduce viewSettings on the wire by mistake. * fix(webdav): preserve prior state across reconnect, drop stale closure in ensureDeviceId Two bugs in the WebDAV sync flow surfaced during review: 1. WebDAVForm.handleConnect rebuilt the entire `webdav` settings block from the four credential fields the user just typed, dropping `deviceId`, `syncBooks`, `strategy`, `syncProgress`, `syncNotes`, `lastSyncedAt`, and `syncLog` on every reconnect. Most concerning is the deviceId rotation: a disconnect + reconnect made the next sync look like a brand-new device, defeating the cross-device clobber detection encoded in `RemoteBookConfig.writerDeviceId`. Extract a pure helper `buildWebDAVConnectSettings` that spreads the previous webdav object first so reconnect is non-destructive, matching the sibling pattern in KOSyncForm. 2. useWebDAVSync.ensureDeviceId merged the new deviceId into the closure variable `settings`, which can be stale when `pullNow → pushNow` fires back-to-back on book open or when the settings panel writes a sibling field concurrently. Read latest settings via `useSettingsStore.getState()` to match the pattern already used in `updateLastSyncedAt` and `persistWebdav`. Adds three unit tests for the new helper, including the reconnect preservation invariant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(webdav): address review observations on encodePath, pull skip, and remote GC Three follow-ups from the review pass on top of 3f721d04. Each one was called out as a smaller observation the reviewer noted but did not push: * WebDAVClient.encodePath silently re-escaped literal % characters despite a comment claiming existing %-escapes are preserved. A caller that pre-encoded a space as %20 would see %20 become %2520 in the request URL, breaking any path that came in already escaped. Tokenise each segment into already-escaped %XX runs and everything-else, and only run encodeURIComponent on the latter. Add four unit tests exercising pure-unicode, pure-pre-escaped, mixed, and root-slash paths. Implementation note: two regexes are needed because a /g RegExp.test is stateful and would skip every other token in this map; the split regex has /g for the iteration, the classifier regex is anchored without /g for the per-token check. * OPEN_PULL_SKIP_MS doc-comment claimed it catches the close-then-reopen flow, but useWebDAVSync unmounts on reader close so lastPulledAtRef resets to 0 — the new instance always passes the cooldown check on remount. The guard actually only fires on re-invocations of the open-book effect inside one hook lifetime (book-to-book navigation, double-render before hasPulledOnce flips). Rewrite both the constant's doc-comment and the call-site comment to match the real semantics. * WebDAVSync push path doesn't DELETE the per-hash directory of a tombstoned book. The deletion *is* propagated through library.json so other devices hide the book, but storage on the WebDAV server grows monotonically. Add a TODO at the pushLibraryIndex call with a sketch of what a future garbage-collection sweep would need (a per- device acknowledgment field on RemoteLibraryIndex so we don't wipe data a peer hasn't seen the deletion for yet). * refactor(webdav): extract WebDAVBrowsePane and SyncHistoryPanel from WebDAVForm The WebDAV settings form was nearing 1500 lines and hosted three loosely related surfaces — credential entry, sync controls + manual trigger, and the in-app file browser — that didn't share much state. Reviewer flagged it as a refactor candidate; this commit does the actual split. * WebDAVBrowsePane (new, 534 lines): owns currentPath, the directory listing, per-entry download status, the navigation handlers and the per-file icon / filename helpers. Reads credentials from the settings prop and otherwise reaches for envConfig / useLibraryStore / useAuth itself rather than threading them through props (matches how the rest of the integrations panels are wired). * SyncHistoryPanel (new, 293 lines): the diagnostic history surface plus its three private helpers (SyncStatusBadge, formatSyncSummary Line, formatSyncTimestamp, SyncHistoryDetails). Moved verbatim from the inline definitions at the bottom of WebDAVForm — the component was already presentation-only and accepting the translation fn as a prop, so no API change. * WebDAVForm (676 lines, down from 1456): keeps the mode switch (configured vs. not), the credential form, the sync sub-controls (Upload Book Files / Sync Strategy / Sync now button), and the large handleSyncNow effect — those last two are intrinsically tied to the settings store and would have just been pushed back up the prop chain by any extraction. The standalone SyncHistoryPanel and WebDAVBrowsePane are now mounted as siblings inside the configured branch. No behavioural change — both new files run the same effects, build the same JSX, and read/write the same store fields as before. All existing webdav-related unit tests still pass. Resolves the last of the reviewer's smaller observations on 3f721d04 (file length). * fix(webdav): stream book uploads to avoid renderer OOM on large files Both syncLibrary (manual Sync now in WebDAVForm) and useWebDAVSync (per-book auto/manual sync triggered on book open) materialised the full book binary as an ArrayBuffer in the V8 heap before PUTting it. With multi-hundred-megabyte PDFs / scanned books, the renderer either accumulates buffers across sequential pushes (library sync) or blows its heap ceiling on a single book (per-book sync), surfacing as a blank white screen on desktop and a binder-OOM kill of the WebView on Android. Add a BookFileStreamingLoader option to pushBookFile that, on Tauri targets, hands the file path off to tauriUpload's Rust-side streamer so bytes never enter JS. The HEAD short-circuit is shared across both paths, so steady-state syncs still cost a single round-trip per book. Web targets keep the buffered fallback (no streaming HTTP primitive available there). Wire the streaming loader through SyncLibraryOptions.loadBookFileStreaming for the library Sync now path, and inline it in useWebDAVSync.pushBookFileNow for the per-book path. Covers stay on the buffered loader — they're capped at a few hundred KB and don't justify widening the API. * fix(webdav): keep Sync now state alive across Settings navigation/close WebDAVForm tracked the library-wide Sync now run in component state, so any navigation that unmounted the form (drilling back to the Integrations list, or closing the SettingsDialog entirely) destroyed the in-flight indicator while syncLibrary's promise kept running off-thread. On return the user saw a re-enabled button with no progress affordance, an empty Sync History (until the run finally finished), and could trigger a second concurrent syncLibrary against the server. Hoist isSyncing / progressLabel into a process-local zustand store (webdavSyncStore) and consume it from WebDAVForm. The store outlives any single mount, so re-mounting the form picks up the running sync's state on first render — button stays disabled, progress label keeps ticking, and the re-entrancy gate (now reading the live store rather than a stale closure) blocks duplicate clicks. Also surface 'Syncing…' in the IntegrationsPanel row so users get the cue without drilling into the sub-page. Not persisted: the store dies with the renderer, which is the right semantic — a sync killed by app exit shouldn't look like it's still going on next launch. * feat(webdav): cleanup mode for orphan book directories on the server WebDAV pushes set Book.deletedAt as a tombstone but never DELETE the per-hash directory on the server, so the remote Readest/books/ tree accumulates dead entries from books the user deleted long ago. Add a dedicated cleanup mode in the WebDAV browser to evict them in batch. Cleanup mode is reached via a new sweep button next to Refresh. Entering it pins the listing to Readest/books/, filters down to directories whose local Book carries deletedAt, and replaces the per-row icon with a checkbox. The footer carries a single right-aligned Delete from server action; selecting one or more rows and clicking it sends a confirm dialog (appService.ask, so it actually blocks on Tauri) and then runs sequential DELETEs against the server. Each row splices out of the listing the moment its DELETE returns, so the listing itself is the progress indicator; the button keeps a stable width by always reserving space for the spinner via the invisible class. The local library is left untouched. Book.deletedAt is the authoritative deletion signal in readest's sync model — clearing or rewriting it here would cause sibling devices to either resurrect the book or lose the deletion event. Restore is therefore not offered: the per-entry download button already provides full recovery (tauriDownload + ingestFile streams the file back, ingestFile clears deletedAt as a side-effect, and the next sync round-trip merges remote progress and notes), and a metadata-only restore would leave users staring at unopenable shelf rows whenever the bytes had been GCed off local disk. Browse mode is friendlier too. Per-hash subdirectory rows under Readest/books/ resolve their hash to the local library's title and short-form hash for skimmability; soft-deleted entries get a folder-off icon plus a 60% dimmed title (a redundant signal for touch platforms where the desktop-only hover tooltip doesn't fire). Cleanup runs are persisted into the existing sync history with a kind: 'cleanup' discriminator and a booksDeleted counter, so destructive batch operations are auditable alongside regular Sync now runs without polluting the common case (the new counter is zero-suppressed on plain sync entries). * test(webdav): cover deleteDirectory and deleteRemoteBookDir Pin the contract of the cleanup-mode delete plumbing: HTTP method, Depth: infinity header, Authorization header and target URL on the low-level deleteDirectory; success/failure/auth-failure routing and per-hash path construction on the high-level deleteRemoteBookDir. Status-code semantics are exercised end to end (200/204 ok, 404 idempotent, 401/403 AUTH_FAILED, 5xx generic, network throw NETWORK), so a future refactor can't silently drop the explicit Depth header or merge the auth-failure path into the per-book result struct without tripping a regression. --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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> |