Commit Graph

553 Commits

Author SHA1 Message Date
Huang Xin 9dc41e7adf feat(reader): reference page numbers from EPUB page-list with manual page count fallback (#4549)
Add a 'Reference Pages' reading progress style that shows physical book
page numbers in the footer progress info:

- When the book carries a page list (EPUB3 nav page-list or EPUB2 NCX
  pageList — foliate-js already parses both and resolves the current
  pageItem on relocate; it was just never consumed), display the current
  page label and use the highest numeric label as the total, so a
  trailing roman-numeral index page can't corrupt the total (#672).
- When the book has none, a per-book 'Reference Page Count' input
  appears; the reading fraction is mapped linearly onto the entered
  count (#4542). The count is saved per book only and never propagates
  to global view settings.
- Falls back to percentage display when neither source is available.

Verified with the sample books from #672: Caleb's Crossing (EPUB3
page-list, 419 pages) and Count Zero (EPUB2 NCX pageList/page-map,
346 pages — chapter 2 lands exactly on page 22 per its page-map), plus
a stripped no-pagelist copy for the manual-count path (175/350 at 50%).

Closes #672
Closes #4542

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:44:18 +02:00
Huang Xin ceddee3793 feat(library): search a book on Goodreads from the library and reader (#4543) (#4548)
Adds a quick "Search on Goodreads" action so readers can jump straight to
Goodreads to track a book instead of retyping the title there.

- Library: a Goodreads button in the Book Details view (works on web,
  desktop and mobile) searching the book's title + author, plus a
  "Search on Goodreads" item in the desktop right-click context menu.
- Reader: Goodreads is added as a built-in web-search provider so
  highlighted text (e.g. a short-story title inside a magazine) can be
  looked up on Goodreads. Disabled by default like the other built-ins;
  enable it in Settings -> Dictionaries.

Both surfaces are used because the native context menu is desktop-only;
the Book Details button covers web and mobile. Adds a shared
openExternalUrl() helper and translates "Search on Goodreads" across all
locales (the Goodreads brand name is kept verbatim).

Closes #4543

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:30:17 +02:00
Huang Xin 390c711070 feat(rsvp): configurable start delay, word stepping, context dictionary lookup, and keyboard shortcut (#4541)
- #4478: add a configurable pre-start countdown (Off / 1s / 2s / 3s, default
  3s) that now ticks at honest one-second intervals and applies to start,
  resume, and page loads; Off starts instantly.
- #4476: add manual next/previous word controls (buttons flanking Play plus the
  "," / "." keys) that pause playback and step exactly one word.
- #4475: allow selecting text in the context panel to look it up in the
  dictionary (anchored popup on desktop, bottom sheet on small screens),
  reusing the reader's dictionary view; auto-scroll/seek are suppressed during
  selection and an outside click dismisses the popup.
- #4473: add a Speed Reading keyboard shortcut (Shift+V), shown on the View
  menu item; ignore repeat triggers while a session is active.

Adds i18n strings for the new UI across all locales.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:52:27 +02:00
loveheaven 1e26c5d765 fix(nav): bound section-scan concurrency to keep zip.js writers from ERRORED-ing (#4528)
On Tauri, section.loadText() drives a plugin-fs open/read/close round trip per call. The unbounded Promise.all in computeBookNav and enrichTocFromNavElements can fire 200+ concurrent IPC chains against a long-spine EPUB, saturate the JS↔Rust bridge or the fd pool, and cause individual reads to reject. The zip.js TextWriter then transitions to ERRORED, surfacing as 'Cannot close a ERRORED writable stream' and silently dropping TOC fragments for the affected sections. In the worst case the rejection propagates through Promise.all and prevents the reader from opening the book.

Hoist the OPDS module's runWithConcurrency to utils/concurrency.ts (zero behaviour change for OPDS) and reuse it in computeBookNav and enrichTocFromNavElements, capped at 128. The cap was binary-searched against the worst-case repro (Android emulator + dev mode + 250-section EPUB): 30/64/128 pass, 200 fails. Section-internal loadText/createDocument dedupe is unchanged.

The worker pool also isolates per-section failures: the outcome shape ({item,result}|{item,error}) lets us log and skip the offending section instead of aborting the entire build as Promise.all did. Even if a future workload pushes past the cap, the reader still opens.
2026-06-11 07:42:39 +02:00
Huang Xin 82bd90afc5 feat(reader): random-access file reads on Android via rangefile scheme (#4534)
* feat(reader): random-access file reads on Android via rangefile scheme

NativeFile's per-chunk Tauri IPC (open+seek+read+close) is slow on Android, and RemoteFile can't replace it because the WebView mishandles Range requests on intercepted custom-protocol responses — it re-applies the offset to the already-sliced body, so any non-zero-start range returns corrupt data or net::ERR_FAILED (Chromium 40739128, tauri-apps/tauri#12019/#3725).

Add a `rangefile` custom URI scheme that carries the byte range in the URL query (?path=&start=&end=) instead of a Range header. With no Range header the WebView delivers the 200 body verbatim, while bytes still stream through the network stack rather than the IPC bridge. The handler is scope-gated by asset_protocol_scope (same boundary as the asset protocol) plus an explicit traversal/NUL/relative guard.

RemoteFile.fromNativePath() drives the scheme on Android (query-carried range, X-Total-Size for size); nativeAppService.openFile routes Android reads through it with a NativeFile fallback. Verified on-device (Android 16 / WebView 147) via CDP: byte-equal reads at every offset, ~1.8x faster small scattered reads, real book opens/renders; all out-of-scope/traversal/NUL paths rejected 403.

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

* ci(rust): run cargo unit tests in rust_lint

The rust_lint job ran only fmt + clippy, so the crate's ~40 Rust unit tests (parsers, parser_common, and the new range_file tests) never executed in CI. Add `cargo test -p Readest --lib` to rust_lint — the frontend dist is absent there, but generate_context! already compiles without it (clippy proves this) and the unit tests run headless.

Also add a `test:rust` pnpm script and document it as verification done-condition #6.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:37:46 +02:00
loveheaven 11d796361e perf(import+open): native Rust EPUB/MOBI parser, OPF prefetch, parallel TOC enrichment (#4369)
* perf(epub): add native EPUB parser in Rust

Introduce a Rust-side EPUB pre-parser exposing three Tauri commands:

  * parse_epub_metadata     - title/author/cover + partialMD5 in one
                              shot, for the import hot path
  * parse_epub_full         - OPF + nav.xhtml + toc.ncx bytes plus a
                              manifest size table, for the reader open
                              hot path
  * extract_epub_cover_full - full-resolution cover bytes, for the
                              lock-screen wallpaper writer

All three avoid ferrying multi-MB blobs across the JS<->Rust IPC
boundary. Cover bytes returned by parse_epub_metadata are downscaled
to a webview-friendly JPEG when the long edge exceeds the library
thumbnail size.

No JS callers yet -- wired up in the following commits.

* perf(import): use native EPUB parser and downscale covers on Tauri targets

On Tauri (desktop/iOS/Android), importBook now forwards EPUB
metadata + cover extraction to the Rust parse_epub_metadata
command and reuses the partialMD5 it returns, skipping the
foliate-js full archive parse and the second pass over the file
for hashing.

As a side effect, the cover written to cover.png is downscaled
to a webview-friendly JPEG (long edge <= 512px), shrinking the
on-disk thumbnail from multi-MB to ~30-60KB per book. To keep
the lock-screen wallpaper feature unchanged, useAutoSaveBookCover
now pulls the original full-resolution cover via the Rust
extract_epub_cover_full command instead of copying the (now
downscaled) cover.png; falls back to the thumbnail when the
native path is unavailable.

Web targets and non-EPUB formats keep the existing path.

* perf(reader): prefetch EPUB OPF/nav from Rust on book open

When opening an EPUB on Tauri targets, DocumentLoader now calls the
Rust parse_epub_full command up-front to pull the OPF, EPUB3 nav,
NCX and the central-directory size map in a single IPC. The
foliate-js zip loader is wrapped so that loadText() of these
entries (and a synthetic META-INF/container.xml) is served from
that in-memory cache without inflating through zip.js, while
all other assets keep flowing through the original loader.

A small in-flight dedupe is added to the spine-text loader so the
nav pipeline (loadText + createDocument back-to-back on the same
href) doesn't pay for two zip.js inflate calls per chapter on
first open.

Reader store / app service plumbing: readerStore.openBook now
resolves an absolute on-disk path via the new
appService.resolveNativeBookFilePath / bookService.resolveNativeBookFilePath
helper and threads it into DocumentLoader as nativeFilePath so
the prefetch can fire. Web targets, non-EPUB formats and books
without a managed/external on-disk path skip the prefetch and
take the original code path.

* perf(nav): parallelize section scans and memoize fragment lookups

computeBookNav now processes sections via Promise.all instead of
a sequential for-loop, and within each section issues loadText()
and createDocument() concurrently. Combined with the in-flight
loadText dedupe added to the zip loader, each chapter pays for a
single zip inflate per nav build, and the inflates of different
chapters overlap.

enrichTocFromNavElements is restructured into two concurrent
phases: a cheap '<nav' substring filter on the inflated text, and
a parsed-document walk for the survivors. Most chapters fall out
in phase 1 without ever being parsed.

In fragments.ts, calculateFragmentSize now consults a
per-section position cache (makeFragmentPositionCache) so the
N-fragment loop is O(N) over the chapter HTML instead of O(N²).
A small isCfiAddressable guard is added to skip elements that
foliate-js's CFI generator can't address (documentElement, body
itself, detached nodes, nodes outside <body>) — these previously
threw and spammed console.warn for every fragment, now they
silently fall back to the section CFI.

* perf(import): use native MOBI/AZW/AZW3 parser on Tauri targets

On Tauri (desktop/iOS/Android), importBook now forwards
MOBI/AZW/AZW3/PRC metadata + cover extraction to the Rust
parse_mobi_metadata command and reuses the partialMD5 it returns,
skipping the foliate-js full-buffer parse and the second pass over
the file for hashing. Mirrors the existing EPUB native fast-path
added in e3fc4767 — bookService tries EPUB first, then MOBI; both
bridges fall back to the foliate-js DocumentLoader when the native
path is unavailable (web target, parse error, format mismatch).

The new mobi_parser is built on the mobi crate (KF7+KF8 reader,
zero JS-side touch). It reads title, author, publisher, ISBN, ASIN,
publish date, language, subjects and description from the MobiHeader
+ EXTH records, resolves the EXTH 201 cover offset against the PDB
image-record table (with ThumbOffset / first-image fallbacks), and
strips KindleGen's HTML wrapping in EXTH 103 so the description goes
into the library DB as plain text. The parsed cover is funneled
through the same maybe_resize_cover path as EPUB, so MOBI library
thumbnails are also clamped to a 512px-long-edge JPEG.

Cover-resize / partialMD5 / RawCoverImage are extracted into a new
parser_common module shared between epub_parser and mobi_parser, so
a single tweak (e.g. raising the thumbnail target) applies to every
native importer and the partialMD5 implementation can't drift between
the two paths (a divergent algorithm would silently re-import every
existing book under a new hash on the first run).

Web targets and non-Kindle formats keep the existing path.

* test(tauri): verify native Rust EPUB parser parity with foliate-js

Add a Tauri WebView parity suite (epub-parser-parity.tauri.test.ts) that
cross-checks the native Rust parser against foliate-js on the same fixtures:
parse_epub_metadata / parse_epub_full (title, author, language, identifier,
publisher, published, subjects, partialMD5, OPF + per-entry size table), and
that opening with the native prefetch produces the same BookDoc and
computeBookNav (TOC) output as the pure-JS path.

Fix a parity divergence the suite caught: the Rust OPF parser mapped
dcterms:modified onto `published`, but foliate-js keeps them separate and
leaves `published` empty -- so EPUB3 books carrying only the mandatory
dcterms:modified got a bogus publication date on the native import path. Map
only dc:date now; add regression tests.

Test infra:
- vitest.tauri.config.mts: add optimizeDeps (mirroring vitest.browser.config)
  so foliate-js-importing tauri tests load -- otherwise esbuild's dep scan
  can't resolve '@pdfjs/pdf.min.mjs', pre-bundling is skipped, and the CJS
  deps fail to import ("Importing a module script failed").
- capabilities-extra/webdriver.json: fix __test__ -> __tests__ fs scope typo
  so import tests can open fixtures under src/__tests__/.

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

* refactor(import): foliate-js owns EPUB/MOBI metadata via standalone extractors

Rust contributes only the mechanical work that's expensive on a
WebView — partialMD5, the downscaled cover, and (for EPUB) the raw
OPF bytes Rust already had to read for cover resolution. Metadata
extraction is delegated to foliate-js's two new standalone entry
points (`parseEpubMetadataFromXML`, `readMobiMetadata`) so the
import-path BookDoc and the reader-path BookDoc share a single
parser implementation.

EPUB
- `parse_epub_metadata` returns
  `{ partialMd5, cover, coverMime, opfPath, opfBytes }`. OPF bytes
  are a free byproduct of the cover-resolution scan.
- `tryNativeParseEpub` runs `parseEpubMetadataFromXML` on the OPF
  bytes and assembles a lightweight BookDoc stub (metadata +
  getCover). The importer doesn't drive `DocumentLoader.open()`, so
  no zip central-directory scan, no nav/ncx inflate, no spine walk.
- `coverMime` is preserved so `bookService.importBook`'s
  `cover.type === 'image/svg+xml'` branch still routes SVG covers
  through svg2png.

MOBI / AZW / AZW3 / PRC
- `parse_mobi_metadata` returns `{ partialMd5, cover, coverMime }`.
  `tryNativeParseMobi` runs foliate's `readMobiMetadata` on the
  same File, which uses `MOBI.open(file, { metadataOnly: true })`
  to parse PalmDB + MobiHeader + EXTH and short-circuit before the
  MOBI6 / KF8 init() that walks every text record.
- `Book.metadata.identifier` is foliate's `mobi.uid.toString()`
  (PalmDB UID), the canonical MOBI identifier the reader path uses.

bookService.importBook
- EPUB and MOBI native branches consume the bridge's BookDoc stub
  directly. The stub's `getCover()` returns the Rust-downscaled
  blob, falling back to foliate's own `getCover` thunk when Rust
  didn't extract a cover.

Other
- Drop the unused `base64` Rust dependency: cover bytes go over IPC
  as `Vec<u8>` (Tauri 2 transports them natively, like opfBytes /
  navBytes / ncxBytes).
- Drop the `nativePrefetch` option on `DocumentLoaderOptions`; no
  caller passes it. `nativeFilePath` keeps driving `parse_epub_full`
  on the open hot path.

Tests
- vitest.tauri parity test asserts byte-equal partialMD5, cover
  presence parity, OPF bytes that decode to a real `<package>`
  document, and that `parseEpubMetadataFromXML` on those bytes
  produces the same user-visible metadata fields (title / author /
  language / identifier / published) as `DocumentLoader.open()`.

* test(tauri): add War and Peace MOBI fixture for native parser parity

The .tauri parser-parity suite previously had no .mobi/.azw3 asset, so the native MOBI parser (metadata + EXTH cover resolution) was uncovered. Adds a real KF8 MOBI ("War and Peace") to enable MOBI parity coverage against foliate-js.

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

* chore(foliate-js): bump submodule to readest/foliate-js main (91191ca)

Replaces the ad-hoc 02f435a with the merged main commit 91191ca, which lands the standalone OPF/MOBI metadata extractors (parseEpubMetadataFromXML, readMobiMetadata) the import fast-path depends on (foliate#19), plus the RTL multi-view rect-mapper fix (foliate#20). The extractor code is byte-identical to 02f435a, so the bridges are unaffected.

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

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:58:25 +02:00
Huang Xin 8425d0b91f fix(opds): render HTML in publication descriptions (#4510)
* fix(opds): render HTML in publication descriptions, closes #4503

OPDS publication descriptions showed raw HTML tags (literal `<p>`,
`&quot;`, `&#x27;`) instead of rendering them. Some aggregator feeds
serve the description as an Atom `type="text"` summary whose HTML has
been escaped twice; foliate's getContent only un-escapes `type="html"`/
`"xhtml"`, so the markup survives parsing as entity text and the detail
view dumped it straight into an unsanitized `dangerouslySetInnerHTML`
(also an XSS sink for untrusted feed content).

Add `getOPDSDescriptionHtml`: decode one extra entity level only when the
value is entirely escaped markup (mixed content like `<p>see &lt;code&gt;`
is left literal), then sanitize with the shared DOMPurify sanitizer.
Wire it into PublicationView and render the sanitized HTML.

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

* refactor: consolidate HTML sanitizers into @/utils/sanitize

sanitizeHtml/sanitizeForParsing are generic DOMPurify wrappers, not
specific to Send-to-Readest. Now that OPDS description rendering also
needs sanitizeHtml, move them out of services/send/conversion into the
shared @/utils/sanitize module (alongside sanitizeString) so neither
consumer reaches across the other's feature boundary.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 06:52:34 +02:00
Huang Xin 1eaf16ffc2 fix(opds): tolerate junk after document element in feeds (#4479) (#4506)
The Hungarian MEK catalog (a PHP backend) returns a valid Atom feed
followed by trailing junk after </feed> — a stray PHP warning, an extra
tag, or text. Chrome's DOMParser ignores it, but Firefox's strict parser
fails with "junk after document element" and replaces the whole document
with a <parsererror>. The reader then sees a non-feed root, treats the
response as HTML, finds no OPDS link, and silently navigates back, so
browsing the catalog on Firefox web is broken on nearly every subpage.

Add parseOPDSXML(): on a parser error, re-parse the slice from the root
element's start tag to its last matching end tag, dropping any leading
prolog and trailing junk. If recovery still fails the original error
document is returned, so callers fall through to their existing
HTML/non-OPDS handling. Wire it into the three OPDS XML parse sites:
the reader (page.tsx), validateOPDSURL (adding a catalog), and the
subscription/auto-download feed checker. feedChecker also switches its
text.startsWith('<') detection to looksLikeXMLContent so the MEK feed's
leading newlines (no <?xml?> declaration, #4181) are recognized.

jsdom mirrors Firefox's strict behavior (same parsererror namespace), so
the regression tests run in the normal unit suite.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:26:47 +02:00
loveheaven 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.
2026-06-04 18:23:53 +02:00
Huang Xin 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>
2026-06-02 16:49:47 +02:00
loveheaven 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.
2026-06-02 16:09:29 +02:00
loveheaven 7283a8ac21 fix(android): open book directly when launched via 'Open with' (#4407)
On Android, tapping an EPUB/MOBI/AZW3 in the system file browser and choosing Readest only opens the library — the book itself never opens in the reader. Now Readest can open the book now.
2026-06-02 16:07:23 +02:00
wfjack 5ff18b8f32 fix(readwise): use Tauri HTTP transport in desktop app (#4413)
Switch ReadwiseClient to @tauri-apps/plugin-http when running in
Tauri desktop mode, matching the pattern already used by WebDAV,
Hardcover, AI providers, translators, OPDS, and KOReader sync.

In a Tauri webview context the standard window.fetch is still subject
to CORS preflight rules and—on Android—the platform's cleartext-traffic
policy. @tauri-apps/plugin-http sends the request from the Rust side
via reqwest, bypassing the renderer entirely (no Origin header, no
preflight, no cleartext block). ReadwiseClient was one of the few
remaining services that had not yet adopted this transport, so users
on the desktop build could hit CORS or network errors when validating
tokens or pushing highlights.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 15:59:17 +02:00
Huang Xin 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>
2026-06-02 14:45:18 +02:00
Huang Xin ef603852b7 feat(tts): hotkey to highlight the currently-spoken sentence (#4085) (#4368)
Add a "Highlight Current Sentence" keyboard action (default Shift+M, in the
Text to Speech shortcut section) that persists the sentence TTS is reading
aloud as a normal highlight using the user's default style/color — no text
selection, eyes-off, silent, and idempotent (a repeat press on the same
sentence is a no-op rather than a duplicate).

Flow: the shortcut handler in useBookShortcuts dispatches tts-highlight-sentence
→ useTTSControl (which owns the TTSController) resolves the current sentence via
the new TTSController.getSpokenSentence() and relays create-tts-highlight
→ Annotator builds the BookNote with the pure, unit-tested buildTTSSentenceHighlight
helper and persists/renders it like any other highlight.

Closes #4085

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:33:28 +02:00
Huang Xin 6605ae8242 feat(dictionary): import companion MDD files that share the MDX prefix (#4363)
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>
2026-05-30 10:30:54 +02:00
Huang Xin 78794499a2 fix(dictionary): correct System Dictionary platform gating on web and iPad (#4362)
* fix(dictionary): keep other dictionaries usable when System Dictionary syncs to an unsupported platform

`dictionarySettings.providerEnabled` is whole-field synced across devices, so enabling System Dictionary on macOS/iOS sets the flag on web/Linux/Windows too. There the row is hidden and the feature is a no-op, but the settings UI read the raw flag and locked every other dictionary's toggle read-only. Gate the lock on `isSystemDictionaryEnabled(settings)` — the same platform-aware check the annotator uses — so it matches real lookup behavior and never triggers where the system dictionary can't run.

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

* fix(dictionary): dispatch system dictionary handoff by native OS (fixes iPad)

iPadOS sends a desktop "Macintosh" user agent, so the UA-based `getOSPlatform()` reported iPad as 'macos' and the handoff invoked the macOS-only `show_lookup_popover` Rust command that iOS never registers ("Command show_lookup_popover not found"). Derive the OS from the app service's `is*App` capability flags (sourced from the Tauri OS plugin, correct on iPad) via a new synchronous `getInitializedAppService()` accessor, so iPad routes to the iOS plugin command path.

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

* fix(ui): constrain reader View Options dropdown to h-8

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

* docs(agent): note System Dictionary platform-detection and synced-flag patterns

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:51:22 +02:00
Huang Xin 36e11de332 feat(reader): swipe-to-adjust brightness gesture on mobile (#3021) (#4356)
* docs: design spec for gesture-based brightness control (#3021)

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

* docs: revise brightness-gesture spec per /autoplan review (#3021)

CEO+Design+Eng dual-voice review. Key fixes: capture-phase listener
(bubble-phase could not suppress foliate paginator), opt-out toggle,
18px threshold, selection guard, brightness seed race, rAF teardown,
e-ink stepped overlay, contrast capsule, perceptual curve reuse,
listener-level test harness.

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

* feat(reader): swipe-to-adjust brightness gesture on mobile (#3021)

Left-edge vertical swipe adjusts screen brightness on iOS/Android, with a
Sun-icon progress overlay. Capture-phase non-passive listener suppresses the
foliate paginator / page-flip / UI-toggle handlers; selection guard, strip
reservation in scrolled mode, eager brightness seed, rAF throttle + teardown.
Opt-out toggle in Settings > Behavior > Device (default on). Perceptual curve
shared with the menu slider. Pure-helper + listener-level tests.

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

* fix(reader): detect brightness-swipe edge by screenX; i18n + shorter label (#3021)

On-device fix: paginated mode lays the iframe doc out as wide side-by-side
columns, so clientX/documentElement.clientWidth are document coordinates and a
left-edge touch on a later page never fell inside the strip (armed stayed false).
Detect with screenX against the parent window width, matching usePagination.

Also: translate the two new setting strings across all locales and shorten the
toggle description to 'Slide along the left edge'.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:28:42 +02:00
Huang Xin 2f5e583653 feat(annotations): configurable export link type + dedicated Import Annotations modal (#4350)
* feat(export): make annotation export link type configurable

Add an Annotation Link selector (App / Web) to the Export Annotations
dialog. Defaults to the app deeplink in the native app and the universal
web link on the web, so web exports no longer emit readest:// links that
only the desktop/mobile app can open. The default markdown template now
uses the configurable annotation.link variable.

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

* feat(annotations): move Moon+ Reader import into a dedicated Import Annotations modal

Replace the single 'Import from Moon+ Reader' menu item with an 'Import
Annotations' entry (below 'Export Annotations') that opens a dedicated
modal listing import sources. Currently lists Moon+ Reader; the boxed-list
layout makes adding future providers a one-row change.

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

---------

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

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

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:24:49 +02:00
Huang Xin 10a223b0e2 fix(export): export uses save dialog on Windows to avoid share UI freeze (#4343) (#4346)
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.
2026-05-28 19:09:39 +02:00
loveheaven 3c134380b7 feat: add empty state hints and loading indicators for annotations, bookmarks, notes, font import, and Moon+ Reader import (#4338)
* feat: add empty state hints and loading indicators for annotations, bookmarks, notes, font import, and Moon+ Reader import

- BooknoteView: show 'No annotation yet' / 'No bookmark yet' when empty
- Notebook: show 'No note yet' when no notes/excerpts exist
- Annotator: add loading overlay with spinner during Moon+ Reader import
- mrexpt: yield to event loop every 5 entries to keep spinner animating
- CustomFonts: show in-place loading card during font import, spinner transitions to font name without layout jump

* fix(ui): respect e-ink overlay styling and drop dead className branch

- Annotator: use modal-box on the mrexpt import overlay so eink picks up
  the no-shadow + 1px border override automatically.
- CustomFonts: collapse importing-card clsx ternary whose branches were
  identical into a flat className.

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

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:37:04 +02:00
Huang Xin 744d5b3a03 fix(dict): restore MDD eager init; CSP-safe audio handler; gate binary uploads on sync category (#4337)
* 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>
2026-05-28 10:13:18 +02:00
Huang Xin 648c35b334 feat(reader): add disableSwipe option to disable swipe-to-paginate (#4335)
Issue #4288: users with hardware page-turn buttons (e.g. e-ink readers)
want to disable swipe-to-paginate so accidental finger drags during
highlight selection don't flip the page mid-annotation. The existing
"Tap to Paginate" toggle only covers taps; swipe was always on.

- New `disableSwipe: boolean` on `BookLayout` (default `false`,
  declared right after `disableClick`).
- foliate-js submodule bump: paginator gates `#onTouchMove` and
  `#onTouchEnd`'s snap-to-page on a new `no-swipe` attribute, so
  native touch behaviour (text selection) stays intact.
- `FoliateViewer` sets/removes the `no-swipe` attribute alongside
  `animated`, and the `ControlPanel` toggle pushes the change to the
  live renderer so it takes effect without a viewer reset.
- The fixed-layout swipe interceptor in `usePagination` also bails
  when `disableSwipe` is on, covering both reflowable and fixed-
  layout books.
- New "Swipe to Paginate" UI row directly below "Tap to Paginate";
  both can be off simultaneously.
- i18n: 33 locales translated.

Also polish: rephrase the two helper texts under "Read books in
place" in `ImportFromFolderDialog`. The previous copy ("Copy no book
into the library to save space.") used an awkward double-negative;
the new wording is clearer and the locked variant drops "registered
as" / em-dash for a plain two-sentence form. i18n updated.

Closes #4288

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:38:38 +02:00
Huang Xin 93abca8960 feat(dict): faster MDict/StarDict import + lazy lookup; raw .dict; UX (#4334)
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 #4228
Closes #4248
Closes #4179

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:48:16 +02:00
Huang Xin cf44e85180 fix(reader): fit duokan-page-fullscreen cover image without cropping (#4328)
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>
2026-05-27 18:34:55 +02:00
loveheaven 64651a65ef fix(sync): skip replica upload when not authenticated (#4327)
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.
2026-05-27 17:34:07 +02:00
loveheaven ff605e000d feat(library): in-place import from registered external folders (#4315)
* feat(library): in-place import with cloud sync and symmetric local delete

Adds an `inPlace` option to importBook so a source file inside a
registered external library folder is referenced directly via
`book.filePath` instead of being copied into Books/<hash>/. Sidecars
(cover, config, nav) still live under Books/<hash>/.

ingestService routes through shouldImportInPlace, which marks an
import in-place when the absolute source path lives under any of
`settings.externalLibraryFolders` and is NOT inside a per-root
`Books/` subtree. The Readest data dir (`customRootDir`) is
intentionally excluded — that directory is Readest's home and
should freely hold hash copies; in-place is for user-registered
roots (Duokan, Calibre, Moon+ Reader, an iCloud mirror, …).

Cloud sync treats in-place books as first-class:

  - uploadBook reads bytes from (book.filePath, 'None') when set.
    The cloud key is unchanged, so a peer downloading the book
    lands it under Books/<hash>/ as a normal hash copy.

  - useBooksSync strips `book.filePath` before pushing — it is a
    device-local path that is meaningless on any other device.

  - ingestService no longer skips upload for in-place books;
    autoUpload / forceUpload behave like any other book. Only
    transient imports opt out.

  - deleteBook 'local'/'both' now physically removes the source
    file at book.filePath (base 'None'). Local-delete semantics
    are symmetric with hash-copy books: the local copy is gone,
    the cloud backup remains, a future pull restores under
    Books/<hash>/. removeFile errors are swallowed.

New `SystemSettings.externalLibraryFolders?: string[]` (no UI yet;
registration entrypoint lands in a follow-up). Added to
BACKUP_SETTINGS_BLACKLIST alongside `localBooksDir` /
`customRootDir` so device-local paths don't ride cloud backups.

Tests: cloud-service, ingest-service, and backup-settings suites
cover in-place delete, multi-root matching, per-root `Books/`
guard, and the backup-strip.

* feat(library): one-tap "read in place" toggle in folder import

Surface the in-place / copy choice as a single "Read books in place (don't copy)" checkbox in the Import-from-Folder dialog. When the user opts in, the chosen directory is registered in `settings.externalLibraryFolders` and ingestService's `shouldImportInPlace` will route the books straight to importBook with `inPlace: true` — no copy into Books/<hash>/, sync still works, local delete still removes the source file (the symmetry was set up in the previous in-place commit).

User experience:

  - First-time users hit the toggle once per library folder. The choice is also persisted to localStorage so subsequent dialog opens default to whatever they picked last.

  - Repeat imports from a folder that's already registered as an external library folder force the toggle ON and disable it, with a help line explaining that imports from this folder are always in-place. The check is exact-string (after path normalization) so registering /Users/me/Duokan only locks the toggle for that exact path — picking /Users/me/Downloads after Duokan still shows the toggle in its normal state.

  - URL-ingress / drag-drop replays go through `runFolderImport` without the dialog and default `readInPlace: false`. They still benefit from in-place automatically when the dropped path lives under an already-registered root, because that decision is made by `shouldImportInPlace` based on settings, not by the dialog flag.

Mechanics:

  - ImportFromFolderResult gains `readInPlace: boolean`. ImportFromFolderDialog gains an `initialReadInPlace` prop (seeded from the new `readest:lastImportFolderReadInPlace` localStorage key) and an `isRegisteredExternalRoot` predicate it uses to render the locked / unlocked toggle.

  - runFolderImport calls a new `registerExternalLibraryFolder` helper that appends the chosen directory to `settings.externalLibraryFolders` and persists settings, but only when `result.readInPlace` is true. `isRegisteredExternalRoot` does the inverse lookup the dialog needs. Both helpers normalize paths the same way `shouldImportInPlace` does so the predicate matches the ingest layer.

  - The new feature has no effect for users who never flip the toggle: `externalLibraryFolders` stays empty, the path-prefix check in `shouldImportInPlace` returns false for every import, and books continue to be copied into Books/<hash>/ exactly as before.

Self-healing for externally-removed in-place books:

  Once the dialog lets users opt their library into in-place mode, the source file becomes a piece of state Readest doesn't control — another app may rewrite it (e.g. Duokan persisting reading progress into the epub), the user may move it in Finder, or an external drive may unmount between sessions. Previously, clicking such a book would navigate into the reader, fail inside loadBookContent's `fs.openFile(book.filePath, 'None')` with a low-level IO error, flash an "Unable to open book" toast, and auto-bounce back to the library — leaving the stale library record in place so the next tap reproduces the same dance.

  BookshelfItem.handleBookClick now probes availability before navigating, but only for purely-local in-place books (`book.filePath && !book.uploadedAt && !book.deletedAt`). If `appService.isBookAvailable` returns false — which for in-place books means the recorded `book.filePath` no longer exists at the OS level — we dispatch `delete-books` for that hash and show an info toast explaining the removal, instead of opening the reader.

  Scope is intentionally narrow:
  - Cloud-synced books still flow through `makeBookAvailable`'s on-demand download path; missing local copies trigger a re-download, not a deletion.
  - Hash-copy books (no `filePath` set) are not probed: a missing Books/<hash>/ file under normal use signals a bug or filesystem corruption, not user intent, and silently dropping the record would hide the real problem.
  - The dispatched delete-books event reuses the existing Bookshelf deletion path, so sidecar metadata and selection state are cleaned up the same way as a user-initiated delete. For in-place books that path doesn't touch any file outside Books/<hash>/, so the now-missing source location (or whatever the user did with it externally) is left alone — symmetric with 165f15a6.

* fix(library): centralize book content resolution

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-05-27 12:08:36 +02:00
loveheaven 5a092f16f7 feat(ios): folder import with security-scoped bookmark persistence (#4314)
* feat(import): support folder picker on iOS via native-bridge

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

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

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

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

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

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

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

Add a `FolderBookmarkStore` that:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(opds): dedupe downloads by source url

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-05-27 07:32:53 +02:00
Huang Xin 64492d6551 feat(reedy): wire MemoryConsolidator + live memory providers into ReedyAssistant (#4310) 2026-05-26 18:02:03 +08:00
Huang Xin 6be7606da4 feat(reedy): wire Phase 5 skills + Phase 3 memory tools into the agent runtime (#4309) 2026-05-26 15:31:19 +08:00
Huang Xin e0ce6c8c22 feat(reedy): Appendix A · Phase 4 — custom thread UI on AgentRuntime (#4308) 2026-05-26 14:55:20 +08:00
Huang Xin 4aaf416c43 feat(reedy): Appendix A · Phase 5.1 — SkillRegistry + 3 seed skills (#4306) 2026-05-26 14:53:49 +08:00
Huang Xin 49664ecb75 feat(reedy): Appendix A · Phase 3.2 — MemoryConsolidator (#4304) 2026-05-26 13:46:11 +08:00
Huang Xin 568b8c0a88 feat(reedy): Appendix A · Phase 3.1 — Memory services + memory tools (#4302) 2026-05-26 10:31:07 +08:00
Huang Xin df2698fa0e feat(reedy): Appendix A · Phase 2.6 — AgentRuntime + abort helper (#4301) 2026-05-26 10:14:53 +08:00
Huang Xin a86b09dba5 feat(reedy): Appendix A · Phase 2.5 — PromptContextBuilder + layers + tokenBudget (#4300) 2026-05-26 10:01:57 +08:00
Huang Xin 5c71ccb908 feat(reedy): Appendix A · Phase 2.4 — built-in tools (non-memory families) (#4299) 2026-05-26 09:41:17 +08:00
Huang Xin 4d96c0d54a feat(reedy): Appendix A · Phase 2.1–2.3 — agent runtime foundation (#4298)
* feat(reedy): Appendix A · Phase 2.1 — ChatModel + model registry

First piece of the deferred agent runtime, building alongside the shipped
Phase 1 MVP per the locked decision (no rip-and-replace; legacy + Reedy
MVP + agent paths coexist).

ChatModel is a thin interface over Vercel SDK's LanguageModel that
surfaces contextWindow / reservedOutput / supportsTools — the metadata
the M2.5 PromptContextBuilder and M2.6 AgentRuntime need for prompt
budgeting and tool-calling decisions.

createReedyModels(settings) returns the active (chat, embedding) pair from
the user's currently configured AIProvider, rewrapped in the Reedy
interfaces without touching the legacy AIProvider. CONTEXT_WINDOW_TABLE
hardcodes per-model metadata (Gemini 2.5 → 2M, GPT-5/Llama-4 → 128K,
local Ollama llama → 4K with tools off, etc.) with a conservative 8K
fallback for unknown ids.

No runtime wired yet — Phase 2.6 consumes this.

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

* feat(reedy): Appendix A · Phases 2.2–2.3 — runtime event/error taxonomy + ToolRegistry

Phase 2.2 — pure types and factories for everything AgentRuntime.runTurn()
will yield:

  turn_start | text_delta | tool_call | tool_result(ok/err)
    | citation | memory_write | step_finish | usage | error | abort | done

ReedyError covers turn-level failures (context_overflow, turn_timeout,
model_error, provider_unavailable, stream_parse, abort, ...) with default
retryability per kind so the M2.6 streamLoop's retry-with-backoff can
decide without string-matching. ReedyToolError covers tool-execution
failures with a narrower kind union so the streamLoop can re-emit them as
{ tool_result, ok: false, error } events without losing structure.

Phase 2.3 — ToolRegistry that holds the runtime's tool catalog and adapts
each tool to the Vercel ai-SDK ToolSet streamText consumes. Every
registered ReedyTool gets wrapped with:

  - Zod input validation     → tool_invalid_args on mismatch
  - Permission gate          → tool_permission_denied (read auto-approves)
  - Per-call wall-clock      → tool_timeout (default 10s)
  - AbortSignal propagation  → tool_aborted on turn cancel
  - Per-tool serialization   → parallelSafe=false tools queue

The local AbortController per call composes the turn-level signal with
the per-tool timeout so we classify timeout-vs-abort correctly based on
which controller fired first, and listener cleanup avoids leaks.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 02:30:34 +02:00
Huang Xin 6bc4a96b99 feat(reedy): Phase 1B — wire Reedy into the chat, settings, and Sources UI (#4296)
* feat(reedy): wire RetrievalBackend interface + metrics into the chat adapter

Phase 1B backend integration. Adds a RetrievalBackend interface so
TauriChatAdapter holds a uniform reference instead of branching across the
file, with two impls — LegacyIdbBackend (wraps the existing IDB ragService
unchanged) and ReedyBackend (lazy-opens reedy.db, adapts the active
provider's embedding model to Reedy's narrower shape, exposes a Vercel
`lookupPassage` tool). selectBackend() gates Reedy behind both
aiSettings.reedy.enabled AND isTauriAppPlatform() per plan D15 so the MVP
cohort is desktop-only.

The Reedy path streams via streamText({ tools: { lookupPassage }, stopWhen:
stepCountIs(3) }) with a status-aware system prompt that tells the model
how to phrase responses for each RetrieverStatus value.

Replaces the module-global `lastSources` + 500ms poll with a per-instance
ReedySourceStore keyed by a synthetic per-turn id the adapter generates,
so the Sources dropdown stops racing on global state. Both legacy and
Reedy backends now feed citations through the same store; the UI is
backend-agnostic via a shared SourceItem shape both ScoredChunk and
RetrievedChunk satisfy.

Adds the reedy_metrics table to the reedy migration (versioned with
app_version + session_id + turn_id per row) plus a ReedyMetrics writer
that ReedyBackend uses to record indexing-lifecycle and tool-use events.
Always-on local; no network egress. NoopReedyMetrics keeps construction
cheap before the DB opens.

Tests: retrievalBackend selectBackend gates, ReedySourceStore semantics
(append/replace/subscribe/clear), ReedyMetrics debounced batching +
exportBundle, and a TauriChatAdapter contract test that asserts the
Reedy/legacy code-paths pass the right args to streamText.

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

* feat(reedy): UI wiring — settings toggle, clickable sources, feedback bundle

Phase 1B UI integration. AIAssistant now constructs the active backend
(legacy or Reedy) via selectBackend with the platform gate from M1.7,
wires a ReedySourceStore for the chat adapter, and routes Sources-dropdown
clicks to `getView(bookKey)?.goTo(source.cfi)` when the source has a CFI.
Legacy-path sources still render as static rows because they have no CFI.

AIPanel grows a 'Reedy Retrieval (Beta)' BoxedList with the toggle and a
'Send Reedy feedback' button that calls exportReedyMetricsBundle and
triggers a JSON download of the last 90 days of events. The toggle is
disabled on web with an explanatory description per plan D15.

Thread accepts an onSourceClick callback and renders each source as a
button when its source carries a CFI, otherwise as a static div — so the
Sources dropdown is backend-agnostic via the shared SourceItem shape.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:41:21 +02:00
Huang Xin a1046f5684 feat(reedy): Phase 1A — MVP retrieval primitives (#4293)
* feat(db): add reedy schema migration with Tantivy FTS + lazy embeddings

Registers a new `reedy` migration set bound to reedy.db. Creates
reedy_book_meta + reedy_book_chunks with a Tantivy FTS index on
chunks.text (ngram tokenizer) and a per-book position index used by
BookRetriever. The vector embeddings table is intentionally NOT created
here — the indexer creates it lazily on first index so the vector32(<dim>)
column matches the active embedding model. Tests cover the migration
applies cleanly, is idempotent, and that the FTS index is queryable.

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

* feat(reedy): retrieval primitives — DB, chunker, indexer, retriever, lookupPassage tool

Wires the MVP retrieval pipeline behind reedy.db, all under src/services/reedy/
and with no integration into the existing AI module yet (Phase 1B will do that).

- ReedyDb wrapper over DatabaseService: book-meta CRUD, lazy embeddings
  table at the active model's dim, bulk chunk + embedding writes via batch(),
  hybridSearch (brute-force cosine + Tantivy FTS + reciprocal-rank fusion
  with 3× per-path over-fetch), per-book and global wipe. Internal write
  queue serializes batch() calls so Turso's single-writer transaction guard
  doesn't trip when BookIndexer runs across books in parallel.
- CfiChunker: TreeWalker over the section's DOM, ~maxChunkSize windows with
  paragraph > sentence > word break-points, full epubcfi(/6/N!/…) anchors,
  round-trip verified via CFI.toRange before each chunk lands.
- BookIndexer: per-book mutex, lazy embeddings-table creation, model.batchSize
  embedding batches with dim assertion, terminal status transitions
  (indexed | empty_index | failed). Re-indexing clears prior chunks via a new
  ReedyDb.clearBookChunks helper.
- BookRetriever: status-typed results (ok | not_indexed | empty_index |
  stale_index | degraded). Embedding has a 5s wall-clock budget; on timeout
  it falls through to FTS-only with status=degraded.
- lookupPassage Vercel ai-SDK tool: Zod-validated query/topK, per-turn
  composite-key dedupe, parallel-call serialization, 10s per-turn budget,
  6000-char result clamp, status-with-hint passthrough, and a separate
  serializeForModel that wraps each passage in <retrieved trust="untrusted">
  with XML-escaped content so book text cannot escape the envelope.

59 unit tests; pnpm test + pnpm lint clean.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:00:20 +02:00
Huang Xin 2d819b476c fix(db): forward DatabaseOpts to tauri-plugin-turso (#4292)
* fix(db): forward DatabaseOpts to tauri-plugin-turso

NativeDatabaseService.open ignored its opts parameter, dropping any
experimental feature flags (e.g. 'index_method' needed for FTS / vector
indexes) and any encryption config before they could reach the Tauri
plugin. Translate DatabaseOpts to the plugin's LoadOptions shape and
forward as the single argument Database.load accepts. Skip translation
when no relevant opts are set so the existing path-string call shape is
preserved for callers without experimental/encryption needs.

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

* test(db): cover Turso vector primitives + add benchmark harness

Verifies the Turso functions Reedy retrieval depends on, with a
brute-force per-book kNN test that runs against every DatabaseService
backend (node, native, WASM):

  SELECT vector_distance_cos(embedding, vector32(?)) AS d
    FROM book_chunks
   WHERE book_hash = ?
   ORDER BY d ASC LIMIT k

This is the path Turso's own founder recommended in
tursodatabase/turso#3778 ("First, focus on efficient SIMD-accelerated
brute-force search") and what shipped at commit 1aba105df4f. Native
vector index modules don't exist in this engine: `libsql_vector_idx`,
`vector_top_k`, and `USING vector/hnsw/diskann/ivfflat` all parse-error
against @tursodatabase/database@0.6.0-pre.28 (libsql_vector_idx is a
libSQL/sqld fork feature; DiskANN was closed not-planned upstream in
#832). The test asserts cross-book isolation and nearest-first ordering
using only `WHERE book_hash = ?` and `ORDER BY` — no DDL, no identifier
interpolation, no index plumbing.

Also adds bench/ harness for manual perf checks:

  pnpm bench [name]            run benchmarks (refuses in CI)
  pnpm bench --list            list available benchmarks
  pnpm bench --no-record       skip results.jsonl append
  pnpm bench --force           override the CI guard

Uses Node 24's --experimental-strip-types so no tsx devDep is needed.
Appends one JSON line per run to bench/results.jsonl (gitignored, local
history; share by pasting tabular stdout into PRs/issues). Explicitly
NOT in CI — shared-tenant variance makes synthetic-benchmark regression
detection unreliable; production telemetry (reedy_metrics, plan §M1.9)
is the right tool for that.

First benchmark: vector-retrieval. Measured on M1 Pro:
  400 chunks ×  384 dim  →  0.35 ms / query
  400 chunks ×  768 dim  →  0.45 ms / query
  2000 chunks × 768 dim  →  2.23 ms / query
  10000 chunks × 768 dim → 14.00 ms / query
  400 chunks × 1536 dim  →  0.70 ms / query

Per-chunk cost ~1.1 µs at 768 dim = ~1.4 ns/dim. NEON-class on
Apple Silicon, ~50× faster than scalar — confirms SIMD acceleration is
active in 0.6.0-pre.28. Per-query latency stays sub-ms at Reedy MVP
corpus sizes; the ceiling is ~10K chunks per book before phone-class
hardware notices.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:51:49 +02:00
loveheaven 98049282eb feat(ai): add OpenRouter provider and unify provider HTTP transport (#4289)
* OpenRouter: new OpenAI-compatible provider with chat + embedding model
  routing, health check via /models, and a fetchOpenRouterModels() helper
  for the settings UI. API key, base URL and model fields are persisted in
  AISettings, surfaced in AIPanel, indexed by commandRegistry, and added
  to backupService's credential allow-list so the key round-trips through
  encrypted backups.

* utils/httpFetch: introduce getAIFetch() as the single decision point for
  outbound AI traffic. In Tauri it returns @tauri-apps/plugin-http's fetch
  (Rust/reqwest transport, no renderer CORS preflight, no Android cleartext
  block); on the web build it falls back to window.fetch. OllamaProvider
  is migrated end-to-end — both ai-sdk-ollama streaming and the /api/tags
  health probe — and the new OpenRouterProvider uses the same path, so any
  future provider only has to call getAIFetch().

* Tests: unit tests for OpenRouter provider behavior (model selection,
  availability, health check) and a backup-settings round-trip test
  ensuring openrouterApiKey is treated as a credential field.
2026-05-25 18:32:07 +02:00
loveheaven 7324b2b2bf fix(ios): don't crash app init when Storefront region code is unavailable (#4291)
`getStorefrontRegionCode` rejects when `Storefront.current` is nil
(unsigned simulator, signed-out device, StoreKit not yet ready,
parental controls, etc.). The call sits in NativeAppService startup
before `prepareBooksDir`, so an unhandled rejection here aborts the
rest of init and surfaces as a top-level unhandledRejection in the
webview console.

Wrap the call in try/catch and treat failure as 'unknown region',
leaving `storefrontRegionCode` null so downstream region-gated
features degrade gracefully. Also guard against an empty resolve
shape with optional chaining on `res?.regionCode`.
2026-05-25 18:28:26 +02:00
Huang Xin 5e366018df fix(cbz): ComicInfo metadata + CBZ page count + WebDAV i18n (#4282)
* fix(cbz,i18n): ComicInfo metadata + CBZ page count + WebDAV i18n

Closes #4253 (ComicInfo.xml not read) and #4255 (CBZ shows "1 page left").

CBZ / ComicInfo (foliate-js submodule + Readest derivation):
- comic-book.js: find ComicInfo.xml in subdirectories too, parse
  description / subject / identifier / published / series fields
  beyond the prior name+position pair. Series Count populates the
  canonical `belongsTo.series.total`; no top-level duplication.
- bookService.ts / readerStore.ts: derive `metadata.seriesTotal`
  from `belongsTo.series.total` in parallel to the existing
  series / seriesIndex derivation.
- ProgressBar / FooterBar / DesktopFooterBar: drop the hard-coded
  `pagesLeft = 1` for fixed-layout books and compute it from
  `section.total - section.current`. FooterBar uses
  `FIXED_LAYOUT_FORMATS.has(bookFormat)` so CBZ picks `section`
  (correct image count) instead of `pageinfo` (locations).
- ProgressBar: switch the remaining-pages text to "in book" for
  fixed-layout titles (no chapter structure) and keep
  "in chapter" for reflowable books.

WebDAV refactor for translation coverage:
- WebDAVBrowsePane / SyncHistoryPanel called `t(...)` (passed as a
  prop) instead of `_(...)`. The i18next-scanner only looks for
  `_`, so ~53 strings were unreachable and shipped in English to
  every locale. Switched both components to call
  `useTranslation()` themselves; helpers that aren't React FCs
  take `_: TranslationFunc` so the scanner sees the literal calls.
- WebDAVClient.checkConnection now returns a `code` discriminator
  (`SERVER_URL_REQUIRED` / `AUTH_FAILED` / `ROOT_NOT_FOUND` /
  `UNEXPECTED_STATUS` / `NETWORK`); raw English `message` is
  reserved for the dev console. New `formatConnectError` and
  `formatSyncError` helpers in WebDAVForm translate via a switch
  where each branch is a literal `_('...')`. Same treatment for
  the sync-failure path that previously surfaced raw e.message.
- "Syncing 0 / {{total}}" is now parameterized as
  "Syncing {{n}} / {{total}}" with n=0 at startup so the digit
  formats naturally and the template can be reused mid-sync.
- "Cleanup · {{count}} book(s)" hard-coded options used unsupported
  ternary; rewrote as plural-aware key.

i18n scanner fix (i18next-scanner.config.cjs):
- vinyl-fs walked into directories whose names end in source-file
  extensions (Next.js route folder `runtime-config.js/`, Playwright
  screenshot folder `*.test.tsx/`) and crashed with EISDIR.
  Resolved by expanding globs via `fs.globSync` and filtering to
  files only before handing to the scanner.

TypeScript-syntax sites that broke esprima during extraction:
- WebDAVBrowsePane / WebDAVForm: `(e as Error).message` and
  `failed[0]!.title` inside `_(..., options)` arguments. Replaced
  with `e instanceof Error ? e.message : String(e)` and
  `failed[0]?.title ?? ''` — also runtime-safer.

User-facing em-dash cleanup:
- Removed em-dashes from translation keys across SyncHistoryPanel /
  WebDAVForm / WebDAVBrowsePane / SyncPassphraseSection / send/page /
  replicaCryptoMiddleware / AIPanel. Tagline in `layout.tsx` kept.

Locale translations:
- ~2400 translations applied across all 33 locales for the keys
  that were either newly extractable, freshly worded, or
  pre-existing but untranslated. Zero `__STRING_NOT_TRANSLATED__`
  remain after the run.

Misc:
- next.config.mjs: drop `eslint.ignoreDuringBuilds: true` so build
  runs the same lint as CI.
- Collection type: add `total?: string` for ComicInfo series count.

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

* ci(test): fix vitest invocation, run with 4 workers

`pnpm test:pr:web` was chaining `pnpm test -- --watch=false`, which
pnpm expanded into:

    dotenv -e .env -e .env.test.local -- vitest -- --watch=false

The second `--` made vitest treat `--watch=false` as a positional
file pattern, not a flag. Vitest then fell back to defaults (in CI's
non-TTY env that still meant a one-shot run, so the suite passed),
but the worker pool was effectively serialized for big chunks of the
243-file run — wall ~90 s on a 4-vCPU runner where the parallel-sum
of phases was ~236 s (≈2.6× effective parallelism).

Replace the chained pnpm invocation with a direct call to
`vitest run --maxWorkers=4`, matching the 4 vCPUs the GH Actions
ubuntu-latest runner provides.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:59:29 +02:00
loveheaven 5c82351ab9 feat(integrations): add WebDAV sync to Reading Sync settings (#4204)
* feat(integrations): add WebDAV sync to Reading Sync settings

Adds a WebDAV entry under Settings -> Integrations -> Reading Sync with configure/browse UI, library-wide Sync now, and per-book sync of progress, annotations and (opt-in) book files + covers.

Reading progress and annotations are always synced when WebDAV is enabled; only Sync Book Files stays as a toggle since it's bandwidth-heavy.

* feat(webdav): add diagnostic sync history panel and document viewSettings invariant

Surface a per-run history for the WebDAV "Sync now" button so users can self-triage failures without rummaging through the dev console — a screenshot of the panel is now enough to file a useful bug report. The same change tightens the docs around viewSettings so the "device-local UI preferences" boundary is impossible to misread on the next refactor pass.

Sync history panel:

  * New WebDAVSettings.syncLog ring buffer (cap 10), persisted alongside the rest of settings so a screenshot survives across app restarts. WebDAVSyncLogEntry captures startedAt, finishedAt, status (success / partial / failure), trigger, the eight counters from SyncLibraryResult, the toast text, and an optional per-book failure list with a phase tag (download / upload-config / upload-file).

  * SyncLibraryResult gains a failedBooks: SyncFailureEntry[] field. The two existing failure points in syncLibrary (download catch, upload catch) now record per-book reason+phase via formatFailureReason(), which keeps the persisted blob small by stripping stacks/whitespace and capping length at 200 chars.

  * WebDAVForm.handleSyncNow now timestamps the run, builds an entry from the result on success/partial paths and from the caught error on failure paths, and appends through a fresh-read appendSyncLogEntry() so concurrent toggle changes can't clobber the log.

  * New SyncHistoryPanel + SyncStatusBadge + SyncHistoryDetails components render the log inline in the Settings page. The detail row groups counters into three semantic columns (activity, skipped, outcome) on a six-column grid so labels can wrap freely while numbers stay tabular and right-aligned. Per-book failures render as a separate stack below the counters.

viewSettings invariant:

  * buildRemotePayload and pullBookConfig already implement the right thing — only progress/location/xpointer/booknotes travel; viewSettings stays device-local. Comments now spell out the contract on both sides so future contributors don't reintroduce viewSettings on the wire by mistake.

* fix(webdav): preserve prior state across reconnect, drop stale closure in ensureDeviceId

Two bugs in the WebDAV sync flow surfaced during review:

1. WebDAVForm.handleConnect rebuilt the entire `webdav` settings block
   from the four credential fields the user just typed, dropping
   `deviceId`, `syncBooks`, `strategy`, `syncProgress`, `syncNotes`,
   `lastSyncedAt`, and `syncLog` on every reconnect. Most concerning is
   the deviceId rotation: a disconnect + reconnect made the next sync
   look like a brand-new device, defeating the cross-device clobber
   detection encoded in `RemoteBookConfig.writerDeviceId`. Extract a
   pure helper `buildWebDAVConnectSettings` that spreads the previous
   webdav object first so reconnect is non-destructive, matching the
   sibling pattern in KOSyncForm.

2. useWebDAVSync.ensureDeviceId merged the new deviceId into the closure
   variable `settings`, which can be stale when `pullNow → pushNow`
   fires back-to-back on book open or when the settings panel writes a
   sibling field concurrently. Read latest settings via
   `useSettingsStore.getState()` to match the pattern already used in
   `updateLastSyncedAt` and `persistWebdav`.

Adds three unit tests for the new helper, including the reconnect
preservation invariant.

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

* fix(webdav): address review observations on encodePath, pull skip, and remote GC

Three follow-ups from the review pass on top of 3f721d04. Each one was
called out as a smaller observation the reviewer noted but did not push:

* WebDAVClient.encodePath silently re-escaped literal % characters
  despite a comment claiming existing %-escapes are preserved. A caller
  that pre-encoded a space as %20 would see %20 become %2520 in the
  request URL, breaking any path that came in already escaped. Tokenise
  each segment into already-escaped %XX runs and everything-else, and
  only run encodeURIComponent on the latter. Add four unit tests
  exercising pure-unicode, pure-pre-escaped, mixed, and root-slash
  paths.

  Implementation note: two regexes are needed because a /g RegExp.test
  is stateful and would skip every other token in this map; the split
  regex has /g for the iteration, the classifier regex is anchored
  without /g for the per-token check.

* OPEN_PULL_SKIP_MS doc-comment claimed it catches the
  close-then-reopen flow, but useWebDAVSync unmounts on reader close so
  lastPulledAtRef resets to 0 — the new instance always passes the
  cooldown check on remount. The guard actually only fires on
  re-invocations of the open-book effect inside one hook lifetime
  (book-to-book navigation, double-render before hasPulledOnce flips).
  Rewrite both the constant's doc-comment and the call-site comment to
  match the real semantics.

* WebDAVSync push path doesn't DELETE the per-hash directory of a
  tombstoned book. The deletion *is* propagated through library.json
  so other devices hide the book, but storage on the WebDAV server
  grows monotonically. Add a TODO at the pushLibraryIndex call with a
  sketch of what a future garbage-collection sweep would need (a per-
  device acknowledgment field on RemoteLibraryIndex so we don't wipe
  data a peer hasn't seen the deletion for yet).

* refactor(webdav): extract WebDAVBrowsePane and SyncHistoryPanel from WebDAVForm

The WebDAV settings form was nearing 1500 lines and hosted three
loosely related surfaces — credential entry, sync controls + manual
trigger, and the in-app file browser — that didn't share much state.
Reviewer flagged it as a refactor candidate; this commit does the
actual split.

* WebDAVBrowsePane (new, 534 lines): owns currentPath, the directory
  listing, per-entry download status, the navigation handlers and the
  per-file icon / filename helpers. Reads credentials from the
  settings prop and otherwise reaches for envConfig / useLibraryStore
  / useAuth itself rather than threading them through props (matches
  how the rest of the integrations panels are wired).

* SyncHistoryPanel (new, 293 lines): the diagnostic history surface
  plus its three private helpers (SyncStatusBadge, formatSyncSummary
  Line, formatSyncTimestamp, SyncHistoryDetails). Moved verbatim from
  the inline definitions at the bottom of WebDAVForm — the component
  was already presentation-only and accepting the translation fn as a
  prop, so no API change.

* WebDAVForm (676 lines, down from 1456): keeps the mode switch
  (configured vs. not), the credential form, the sync sub-controls
  (Upload Book Files / Sync Strategy / Sync now button), and the
  large handleSyncNow effect — those last two are intrinsically tied
  to the settings store and would have just been pushed back up the
  prop chain by any extraction. The standalone SyncHistoryPanel and
  WebDAVBrowsePane are now mounted as siblings inside the configured
  branch.

No behavioural change — both new files run the same effects, build
the same JSX, and read/write the same store fields as before. All
existing webdav-related unit tests still pass.

Resolves the last of the reviewer's smaller observations on
3f721d04 (file length).

* fix(webdav): stream book uploads to avoid renderer OOM on large files

Both syncLibrary (manual Sync now in WebDAVForm) and useWebDAVSync (per-book auto/manual sync triggered on book open) materialised the full book binary as an ArrayBuffer in the V8 heap before PUTting it. With multi-hundred-megabyte PDFs / scanned books, the renderer either accumulates buffers across sequential pushes (library sync) or blows its heap ceiling on a single book (per-book sync), surfacing as a blank white screen on desktop and a binder-OOM kill of the WebView on Android.

Add a BookFileStreamingLoader option to pushBookFile that, on Tauri targets, hands the file path off to tauriUpload's Rust-side streamer so bytes never enter JS. The HEAD short-circuit is shared across both paths, so steady-state syncs still cost a single round-trip per book. Web targets keep the buffered fallback (no streaming HTTP primitive available there).

Wire the streaming loader through SyncLibraryOptions.loadBookFileStreaming for the library Sync now path, and inline it in useWebDAVSync.pushBookFileNow for the per-book path. Covers stay on the buffered loader — they're capped at a few hundred KB and don't justify widening the API.

* fix(webdav): keep Sync now state alive across Settings navigation/close

WebDAVForm tracked the library-wide Sync now run in component state, so any navigation that unmounted the form (drilling back to the Integrations list, or closing the SettingsDialog entirely) destroyed the in-flight indicator while syncLibrary's promise kept running off-thread. On return the user saw a re-enabled button with no progress affordance, an empty Sync History (until the run finally finished), and could trigger a second concurrent syncLibrary against the server.

Hoist isSyncing / progressLabel into a process-local zustand store (webdavSyncStore) and consume it from WebDAVForm. The store outlives any single mount, so re-mounting the form picks up the running sync's state on first render — button stays disabled, progress label keeps ticking, and the re-entrancy gate (now reading the live store rather than a stale closure) blocks duplicate clicks. Also surface 'Syncing…' in the IntegrationsPanel row so users get the cue without drilling into the sub-page.

Not persisted: the store dies with the renderer, which is the right semantic — a sync killed by app exit shouldn't look like it's still going on next launch.

* feat(webdav): cleanup mode for orphan book directories on the server

WebDAV pushes set Book.deletedAt as a tombstone but never DELETE the per-hash directory on the server, so the remote Readest/books/ tree accumulates dead entries from books the user deleted long ago. Add a dedicated cleanup mode in the WebDAV browser to evict them in batch.

Cleanup mode is reached via a new sweep button next to Refresh. Entering it pins the listing to Readest/books/, filters down to directories whose local Book carries deletedAt, and replaces the per-row icon with a checkbox. The footer carries a single right-aligned Delete from server action; selecting one or more rows and clicking it sends a confirm dialog (appService.ask, so it actually blocks on Tauri) and then runs sequential DELETEs against the server. Each row splices out of the listing the moment its DELETE returns, so the listing itself is the progress indicator; the button keeps a stable width by always reserving space for the spinner via the invisible class.

The local library is left untouched. Book.deletedAt is the authoritative deletion signal in readest's sync model — clearing or rewriting it here would cause sibling devices to either resurrect the book or lose the deletion event. Restore is therefore not offered: the per-entry download button already provides full recovery (tauriDownload + ingestFile streams the file back, ingestFile clears deletedAt as a side-effect, and the next sync round-trip merges remote progress and notes), and a metadata-only restore would leave users staring at unopenable shelf rows whenever the bytes had been GCed off local disk.

Browse mode is friendlier too. Per-hash subdirectory rows under Readest/books/ resolve their hash to the local library's title and short-form hash for skimmability; soft-deleted entries get a folder-off icon plus a 60% dimmed title (a redundant signal for touch platforms where the desktop-only hover tooltip doesn't fire). Cleanup runs are persisted into the existing sync history with a kind: 'cleanup' discriminator and a booksDeleted counter, so destructive batch operations are auditable alongside regular Sync now runs without polluting the common case (the new counter is zero-suppressed on plain sync entries).

* test(webdav): cover deleteDirectory and deleteRemoteBookDir

Pin the contract of the cleanup-mode delete plumbing: HTTP method, Depth: infinity header, Authorization header and target URL on the low-level deleteDirectory; success/failure/auth-failure routing and per-hash path construction on the high-level deleteRemoteBookDir. Status-code semantics are exercised end to end (200/204 ok, 404 idempotent, 401/403 AUTH_FAILED, 5xx generic, network throw NETWORK), so a future refactor can't silently drop the explicit Depth header or merge the auth-failure path into the per-book result struct without tripping a regression.

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:55:12 +02:00
Huang Xin f4de55e8f3 feat(send): twitter/x site rule + meta-tag fallback for stale rules (#4270)
Two layered additions to the clip-to-Readest pipeline:

* **Twitter / X article rule** — `[data-testid="twitterArticleReadView"]`
  for the body, `User-Name`/`UserAvatar-Container-*` testids for byline
  and avatar. Readability can't latch onto X's class-mangled CSS-in-JS,
  so the testid hooks are the only reliable anchors.

* **Universal meta-tag fallback** — new `META_FALLBACK` constant in
  `siteRules.ts` holding OpenGraph / Twitter Card selectors for title,
  byline, and author image. Site rules are now hints, not contracts:
  when their selectors miss (after a frontend redesign) the pipeline
  drops down to meta tags, then to Readability for content, before
  giving up. The fallback also fires when no site rule matched at all,
  so previously-unruled sites pick up byline + cover image they used
  to lack.

Wiring in `convertToEpub.ts`:
  - `extractWithSiteRule` consults `META_FALLBACK.{title,byline}` when
    rule selectors return empty.
  - The Readability branch does the same so non-ruled sites get a real
    byline/title instead of `''`.
  - `buildArticleCover` tries `og:image` / `twitter:image` between the
    rule's `authorImage` and the favicon fallback.
  - `extractHtmlTitle` prepends `og:title` before `<title>` / `<h1>`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:44:19 +02:00
Huang Xin 81bd5ee6b8 fix(send): address extension review findings (#4271) 2026-05-22 17:31:04 +02:00
loveheaven 9fa7cb266c fix(migration): skip migrate20251029 silently on fresh installs (#4268)
migrate20251029 unconditionally calls copyFiles() and deleteDir() on the legacy Images/Readest/Images path. On a fresh install where that directory never existed, both calls bubble up an OS error ("os error 2" / ENOENT) which is then caught one frame up by runMigrations() and printed as an Error migrating to version 20251029 in the console. Functionally harmless (migrationVersion is still advanced afterwards), but a misleading red herring for new users and anyone debugging startup logs.

Check fs.exists(oldDir) up front and return early if absent; also guard the deleteDir call in case copyFiles partially completed and the cleanup target is gone. No behavior change on machines that actually have the legacy layout.
2026-05-22 17:06:36 +02:00