Commit Graph

82 Commits

Author SHA1 Message Date
Huang Xin 48d52ea898 feat(telemetry): opt-out by default for new users; consent prompt for 10% (#4340)
Fixes #4339. PostHog telemetry was previously enabled by default for every
install, which surprised privacy-conscious users on self-hosted setups.

Behavior for new users only (existing users are migrated to a decision that
preserves their current `telemetryEnabled` setting):
  - 90% are silently opted out on first launch.
  - 10% see a one-time consent prompt; accepting opts in, declining opts out.
Decision is persisted via a new `readest-telemetry-decision` localStorage key
so subsequent boots don't re-roll. PostHog now inits with
`opt_out_capturing_by_default` so brand-new users never ping before the
decision is finalized.

New `TelemetryConsentDialog` uses the project's `btn-contrast` (theme-neutral)
CTA and `eink-bordered` chassis so it renders correctly under `[data-eink]`
without color-mode-only assumptions. Strings translated across all 33 locales.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:19:05 +02:00
Huang Xin 8a19c686cb ci: address code scanning scorecard alerts (#4275) 2026-05-22 20:20:10 +02:00
loveheaven ae81cd0151 feat(annotator): support global highlights that fan out across all matching positions (#4257)
Introduces a 'global' annotation flag so a highlight/note created on one occurrence of a phrase is automatically applied to every matching occurrence in the book (and stays applied across reloads). Renders these expansions as transient overlays without creating duplicate persisted notes. This flag will not show when the book is fixed layout like PDF or CBZ.

- types: add 'global?: boolean' to BookNote and DBBookNote; transform layer round-trips the field, with regression coverage ensuring older clients do not clobber it on write-back.

- db: new migration 013_add_book_notes_global.sql adds nullable 'global' column to public.book_notes; init schema.sql updated to match.

- annotator: new utils/globalAnnotations.ts handles cfi expansion / text-match search across the spine and overlay synthesis. Annotator.tsx fans out global notes on load and on overlay creation; AnnotationPopup and HighlightOptions expose a toggle to mark a highlight as global.

- sync path is transparent: a global note created on another device is fanned out locally on next render with no extra UI required.
2026-05-21 18:59:07 +02:00
loveheaven d943a1c146 fix(library): clear nested-folder groups when deleting from bookshelf (#4226)
* fix(library): clear nested-folder groups when deleting from bookshelf

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

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

* refactor: expand group selections into book hashes at intake

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

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

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

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

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 06:31:39 +02:00
Huang Xin fe41c42ec5 chore: switch code formatter from Prettier to Biome (#4223)
Replace Prettier with Biome for formatting JS/TS/JSX/CSS/JSON. The CI
format check drops from ~23s to ~0.4s.

- Unify config into a single root biome.json (formatter + linter); the
  former apps/readest-app/biome.json was linter-only
- Mirror the old .prettierrc.json style: 100 line width, 2-space indent,
  LF, single quotes, trailing commas
- Enable the CSS tailwindDirectives parser for @apply in globals.css
- Convert // prettier-ignore comments to // biome-ignore format:
- Root scripts and lint-staged now run biome; apps/readest-app lint runs
  `biome lint` (lint-only) so formatting stays a separate CI step
- Drop prettier + prettier-plugin-tailwindcss dependencies

Markdown/YAML are no longer format-checked (Biome does not format them)
and Tailwind class sorting is no longer enforced.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:13:36 +02:00
Huang Xin 0fba5b7054 feat(config): version book config schema (#4208) 2026-05-17 18:23:46 +02:00
Huang Xin 1d4b7eed87 fix(txt): merge scene-break sections into the preceding chapter (#4063) (#4207)
The TXT-to-EPUB segment regex splits on dash dividers (`-{8,}`), which
authors commonly use as in-chapter scene breaks. Each heading-less section
after such a divider was emitted as its own chapter — a numbered paragraph
fallback chapter, or a chapter titled after a stray sentence — flooding the
generated TOC with entries that aren't real chapters.

Mark chapters with whether their title came from a detected heading, and
merge heading-less chapters into the preceding detected chapter instead of
pushing them as separate TOC entries. Fully heading-less text still chunks
into numbered fallback chapters as before.

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

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

Fixes #4202

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:07:15 +02:00
leehuazhong 2acd08202b fix(a11y): use position absolute for skip-next-section link to prevent blank page (#4182)
* fix(a11y): use position absolute for skip-next-section link to prevent blank page

* fix(a11y): nest next-section skip link inside last content element

position:absolute alone does not fix the blank-page bug: a full-page
illustration wrapper commonly carries `column-break-after: always`, and
the skip link's static position after that break still renders in a
fresh, blank column. Nest the link inside the deepest last content
element so it shares the final content column, while remaining the last
node in document order for NVDA's virtual cursor. Also use left:auto so
it keeps its static position instead of pinning to the viewport edge.

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

---------

Co-authored-by: leehuazhong <longsiyinyydds@gmail.com>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:13:04 +02:00
Huang Xin 787bbf2103 feat(reader): custom hardware-button page turning (#4177)
* feat(reader): add custom hardware-button page turning (#4139)

Lets users bind hardware remote keys (media keys, D-pad/arrow keys) to
previous/next page via a learn-mode capture UI in reader settings — an
accessibility feature for page-turner remotes.

- New global hardwarePageTurner system setting (enabled + key bindings).
- hardwareKeys.ts: key normalization, matching, and page-turn resolution.
- deviceStore: reference-counted media-key interception + learn mode.
- usePagination: flips pages from bound media keys (native bridge) and
  D-pad/keyboard keys (DOM keydown), scoped to the active book and
  suppressed while the toolbar is visible.
- Page Turner settings section on all platforms; web/desktop bind keys
  via DOM keydown only, native media-key interception stays mobile-only.
- Android: intercept media + learn-mode keys in dispatchKeyEvent.
- iOS: forward media keys via MPRemoteCommandCenter.

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

* chore(i18n): add and translate hardware page turner strings (#4139)

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

* feat(reader): refine hardware page turner (#4139)

- Handle book-iframe key events (iframe-keydown messages) so custom
  bindings work as soon as a book is open, not only after the settings
  panel has been shown.
- Add Previous/Next Section bindings alongside the page bindings.
- Rename the hardwareKeys util to keybinding.
- Wire the Page Turner section into the settings Reset action.
- Drop the focus ring on the capture buttons; BoxedList gains an
  optional description.

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

* chore(i18n): translate page turner section and key strings (#4139)

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-15 19:57:33 +02:00
Huang Xin 708e06a46e fix(opds): show summary as book description, closes #4156 (#4162)
The TypeScript types in `src/types/opds.ts` declared fresh
`Symbol('content')` / `Symbol('summary')` instances. foliate-js's
`opds.js` declared its own distinct ones, and since Symbols are unique
per call, `metadata[SYMBOL.CONTENT]` always returned undefined — even
though the parser had written the value under a same-named Symbol.

This broke silently in 0.11.1 after foliate-js #14 stopped also setting
a plain `content: string` fallback. For OPDS 1.x feeds (e.g. CWA) the
book description lives in `<entry><summary>`, which foliate-js exposes
only via `[SYMBOL.CONTENT]` — so the description vanished.

Re-export the SYMBOL from foliate-js so consumers read the same Symbol
identities the parser writes.
2026-05-14 19:23:32 +02:00
Huang Xin 54aa20d4f8 fix(footnote): don't treat in-book numeric chapter/verse links as footnotes (#4152)
closes #4140

The bare-numeric-text heuristic added in #3894 to detect non-superscript
footnotes (`/^.{0,2}\d+$/` over `anchor.textContent`) was too permissive:
in-book TOCs that list chapter/verse links such as `<a>1</a>, <a>2</a>, ...`
all match the regex, so clicking them sets `check=true` and the footnote
handler renders the destination as a popup instead of letting the link
navigate. The OSB v2 verse-index and OSB v4 chapter-index from the bug
report both hit this.

Reject the `check` heuristic when the clicked link sits inside a numeric
link list (2+ sibling links with the same short-numeric pattern within
three ancestor levels). A real body paragraph with a couple of footnote
markers still passes; a flat TOC of numeric links does not.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:33:26 +02:00
Huang Xin d326e1c73d fix: hide popup triangle when inside popup + EPUB image-only paragraph rendering (#4121)
- Popup: hide the inner triangle when its anchor point lands inside
  the popup body. Extracted as a generic `isPointInRect` helper in
  `sel.ts` (with a default 1px padding so edge cases stay visible).
- style.ts: handle `<p[width][height]><img></p>` (common in some
  MOBI conversions) — clear hardcoded width/height and apply
  multiply blend for dark themes so the image doesn't sit on a
  colored box.
- Annotator: shrink dict popup height from 480 to 360 to fit
  smaller screens.
- foliate-js: submodule bump.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:12:37 +02:00
Huang Xin ae42dcb53a fix(txt): parse author from txt filename and use edited metadata on fallback cover (#4095) (#4102)
When importing a `.txt` file the author field stayed empty unless the
text content itself contained an `作者:…` header, even when the filename
already encoded it. Common Chinese naming patterns like `《书名》作者:张三.txt`,
`《书名》[张三].txt`, or `《书名》张三.txt` now contribute the author when
the file body doesn't.

- Added `extractTxtFilenameMetadata` in `utils/txt.ts` and replaced the
  ad-hoc `extractBookTitle` regex used by both convertSmallFile and
  convertLargeFile. Content-extracted author still wins; the filename
  author is the next fallback before the caller-provided one.
- `BookCover` now reads `book.author || book.metadata?.author` so the
  author typed into the metadata edit dialog shows on auto-generated
  fallback covers when the original `book.author` was empty.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 05:34:56 +02:00
Huang Xin cc8f917cdd fix(layout): silence viewport meta warning on non-Android browsers (#4097)
`interactive-widget=resizes-content` was set in the SSR viewport
metadata so Android Chrome would shrink the layout viewport when
the on-screen keyboard opens (matching iOS default behavior).
Other browsers — Safari on macOS / iOS, desktop Chrome, Firefox —
log a console warning every page load because they don't recognize
the key.

Move the attachment client-side, gated on a UA sniff for Android,
so the meta tag stays clean for everyone else. The Android-specific
behavior (modals centered above the keyboard) is preserved on the
platform that actually needed it.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:16:52 +02:00
Huang Xin 981579c255 feat(sync): cross-device custom font sync (#4077)
* refactor(sync): kind-agnostic replica primitives

Extract dict-only sync into shared primitives (registry, pull/apply
orchestrator, persist env, schema allowlist) so other kinds can plug
in. Companion changes: per-replica Storage Manager grouping,
useReplicaPull boot-race recovery, manifest=null reconciliation on
every boot pull, copyFile takes explicit srcBase + dstBase, settled-
event helpers, lenient webDownload Content-Length (R2/S3 signed URLs
commonly omit it), and generic "File" transfer toast copy any replica
kind can share.

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

* feat(sync): cross-device custom font sync

Plug the font replica adapter into the kind-agnostic primitives:
font store gains replica wiring, custom font import publishes the
replica row + queues a binary upload, and bootstrap registers the
font adapter and download-complete handler.

Includes legacy flat-path migration so pre-existing fonts sync
without re-import, full @font-face activation on auto-download
(load + mount the rule, mirroring manual import), and a fix to
createCustomFont so contentId / bundleDir / byteSize survive the
trip through addFont — otherwise import-time publish silently
no-oped on missing contentId.

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-07 08:28:44 +02:00
Huang Xin c27245e980 feat(reader): support deeplink and web link in annotation export (#4067)
Expose `annotation.appLink` (readest://) and `annotation.webLink`
(https://web.readest.com) as template variables for custom export
templates. The shipped default template now emits the readest:// app
deeplink for the page link so exported notes open the native app.
The non-template export mode keeps the universal https link.

Preview links also gain target="_blank" so they open in a new tab
instead of replacing the dialog.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:39:26 +02:00
Huang Xin d1e7b4902c feat(share): time-limited share links with cfi-aware imports (#4037)
Add a Share Book feature that generates an expiring HTTPS share URL plus a
parallel readest://share/{token} deep link. Recipients land on /s/{token},
where logged-in users can one-tap "Add to my library" (R2 server-side
byte-copy) and anonymous users download the book or open it in the app.
Sharers manage active links from a dedicated "Manage Shared Links" panel
under user settings.

Highlights:
- 9 new App Router endpoints under /api/share (create, [token], cover,
  og.png, download, download/confirm, import, revoke, list).
- /s landing page with branded next/og chat unfurl image and SSR auth-cookie
  detection so logged-in recipients see "Add to my library" as the primary
  action without layout shift.
- Server-side R2 byte-copy for /import preserves the project's existing
  invariant that every files.file_key is prefixed with its row's user_id;
  stats / purge / delete / download routes work unchanged. URL-encodes the
  copy source so titles with spaces or '&' don't break the copy.
- Universal 7-day expiry cap, no tier differentiation, no "never". DMCA-risk
  reduction. Picker defaults to 3 days.
- Position-aware shares: "Share current page" toggle (off by default for
  privacy) attaches the sharer's CFI; recipient lands at the same paragraph.
- Per-user 50-share cap, rate limiting via Cache-Control: no-store on
  token-bearing responses, atomic SQL increment for download_count via a
  SECURITY DEFINER function so the public confirm beacon stays safe under
  concurrent fire.
- Soft revocation: presigned download URLs (5-min TTL) cannot be cancelled
  before TTL; documented as accepted v1 behavior.
- token + token_hash hybrid storage: public endpoints look up by hash and
  never select the raw token, so accidental SELECT-* leakage on a public
  route can't expose the bearer credential.
- Mobile / desktop Tauri share via tauri-plugin-sharekit; web falls back to
  navigator.share with a clipboard fallback when no native share method
  exists. Share-sheet dismissal no longer silently copies.

UI:
- New Dialog with a settings-card group: iOS-style segmented duration picker
  + toggle slider for "Share current page", on a single row each.
- Reader top-bar Share button, library context-menu Share entry, manage-
  shares list with cover thumbnails and overflow menu.
- New <SegmentedControl> primitive in src/components for reuse.

Coverage:
- Unit tests for token utils + URL parser (20 new tests, full suite at 3445).
- 31 locales translated for all new strings; en plurals hand-added per the
  project's hand-curated en convention.

DB migration in docker/volumes/db/migrations/002_add_book_shares.sql adds
the book_shares table, RLS policies, and the increment_book_share_download
RPC. Migration is idempotent.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:03:35 +02:00
Huang Xin eadb355396 fix(txt): recognize 番外/外传 chapter prefixes, closes #4016 (#4025)
Chinese novels commonly use 番外 (bonus), 番外篇, or 外传 as chapter
headings, optionally combined with 第N章. The previous regex only
matched 第N章 at line start, so lines like "番外 第1章 旗开得胜"
were dropped from the TOC. Treat 番外篇/番外/外传 as preface-style
keywords so they match alongside 楔子/前言/etc.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:13:28 +02:00
Huang Xin 5a0a70a30a feat(reader): custom dictionaries (StarDict + MDict) (#4012)
* feat(reader): custom dictionaries (StarDict + MDict)

Adds a pluggable dictionary provider system. Built-in Wiktionary +
Wikipedia (extracted from the legacy single-popup model into a tabbed
shell) plus user-importable StarDict (.ifo/.idx/.dict.dz/.syn) and MDict
(.mdx/.mdd) bundles.

Settings → Language → Dictionaries: import / enable / drag-reorder /
delete (delete-mode toggle mirrors CustomFonts). Drag uses @dnd-kit with
pointer/touch/keyboard sensors. Reader popup: tabbed UI, per-tab lookup
history, scroll-aware back button, last-active tab persists. Tabs grow
to natural width up to a cap, truncate with ellipsis when crowded; phantom
bold layer prevents layout shift on focus.

StarDict reader is self-contained (replaces unused foliate-js/dict.js),
with lazy random-access binary search on .idx + .syn (~420 KB Int32Array
of byte offsets vs ~10 MB of parsed JS objects), lazy DictZip chunk
decompression via fflate streaming Inflate (cmudict/eng-nld both
chunked), and an optional .idx.offsets sidecar generated at import to
skip the init scan. Cmudict 105K-entry init drops from ~10 MB heap and
2 MB IO to ~1.7 MB heap and ~500 KB IO.

MDict uses the readest/js-mdict fork (added as a submodule, consumed via
tsconfig paths so deps stay out of readest's pnpm-lock) which adds a
browser-friendly BlobScanner reading via blob.slice(...).arrayBuffer()
— slices are lazy when the Blob is Readest's NativeFile / RemoteFile.
encrypt=2 (key-info-only) MDX is fully supported via ripemd128-based
mdxDecrypt; encrypt=1 (record-block, needs user passcode) surfaces as
unsupported.

Wikipedia annotation tool removed (Wikipedia is now a tab inside the
unified popup); legacy WiktionaryPopup / WikipediaPopup deleted. Stale
annotationQuickAction === 'wikipedia' coerced to 'dictionary' on settings
load. iOS-friendly external links: skip target="_blank" on Tauri to
avoid the WebView's "open externally" path triggering the shell scope
error; the popup's container click handler routes through openUrl.

i18n: 939 strings translated across 31 locales (30 base keys + CLDR
plural forms for ar/he/sl/pl/ru/uk/ro/it/pt/fr/es).

Test fixtures bundled: cmudict (StarDict, 105K entries), eng-nld
(StarDict, smaller), and a Longman Phrasal Verbs MDX (encrypt=2).
3396 unit tests pass.

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

* fix(stardict): resolve fflate from js-mdict source for vitest + Next

js-mdict is consumed as TypeScript source via tsconfig paths from
packages/js-mdict/src/. Its sources `import 'fflate'` directly, but
fflate is only installed under apps/readest-app/node_modules — so
vite's import-analysis (and Next/Turbopack's resolver) can't find
fflate when it walks up from the redirected js-mdict source location.
CI's fresh checkout exposes this; locally a leftover
packages/js-mdict/node_modules/fflate from the old workspace setup
masked it.

Pin fflate resolution to apps/readest-app/node_modules/fflate in:
- vitest.config.mts (Vite alias)
- next.config.mjs (webpack alias + Turbopack resolveAlias — Turbopack
  rejects absolute paths so use a project-relative form)
- tsconfig.json (paths entry so tsgo / Biome see it)

Verified by deleting packages/js-mdict/node_modules locally and
re-running pnpm test (3396 pass), pnpm lint (clean), and both
pnpm build-web and a tauri-platform Next build (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-04-30 19:16:36 +02:00
Huang Xin a43845b4c5 fix(layout): symmetric margins and gap in 2-column layout, closes #3909 (#4002) 2026-04-29 20:30:48 +02:00
Huang Xin 34f19fd148 fix(annotation): preserve line breaks in selected text across <br> elements, closes #3981 (#3986)
In multi-line PDF selections, pdf.js renders each text run as its own <span>
and inserts <br role="presentation"> at line endings. getTextFromRange only
walked text nodes, so <br>s were dropped and adjacent line-final/line-initial
words glued together (e.g. "lastfirst") in highlights, notes, and AI inputs.

Walk elements alongside text nodes and emit "\n" for <br>, mirroring how
Selection.toString() handles line breaks.
2026-04-28 18:56:55 +02:00
Huang Xin 920627ae59 feat(rsvp): use jieba tokenizer to segment words for Chinese books (#3985) 2026-04-28 18:19:05 +02:00
Zeedif ca8f0fe9f6 feat(opds): add OPDS-PSE streaming support and custom OPDS 2.0 parser (#3951)
* feat(opds): add OPDS-PSE streaming support and custom OPDS 2.0 parser

* refactor(opds): remove custom parser, use updated foliate-js dependency

* fix(opds): resolve PSE auth at fetch time, drop credentials from book.url

Previously the streaming `pse://` virtual file baked the proxy URL with
the basic auth header into `book.url`, which (a) failed on desktop where
no proxy is used because the auth header was never applied to the page
fetch, and (b) leaked the credential to the sync server because transient
books still get pushed on first sync.

Now the `pse://` payload stores only the upstream OPDS template URL plus
the catalog id. A new `createPseStreamPageLoader` looks up the catalog
from settings on each open, probes auth once (cached for the session),
and applies the auth header via `tauriFetch` on desktop or via the proxy
URL on web.

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-04-28 15:14:44 +02:00
Huang Xin 6fcda66b60 fix(reader): close stuck reader window on book load failure, closes #3932 (#3980)
When a book's underlying file is missing, opening it in a dedicated
reader window showed an error toast then navigated that window to
/library, leaving a leftover library-in-reader-window the user had
to close manually. Route the recovery through a new
closeReaderWindowOrGoToLibrary() that closes the dedicated reader
window (after ensuring the main library window is visible) and only
falls back to /library navigation in the main window or on web.

Also fix a related macOS issue: the reader's CloseRequested handler
was running handleCloseBooks and calling currentWindow.destroy() on
the main window, which tore down the active book and bypassed the
Rust close-to-hide handler — making Cmd+W / traffic-light close
quit the app from the reader page (vs. correctly hiding from the
library page) and lose the active book even when the window did
hide. Skip both the cleanup and destroy on macOS for the main
window so the Rust handler hides it with the book intact, matching
the macOS minimize-to-dock convention.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:32:04 +02:00
Huang Xin 4f55920b71 feat(kosync): add metadata hash info dialog to diagnose sync failures (#3978) 2026-04-27 17:04:29 +02:00
Huang Xin 6d798542f6 fix: restore main library window when going to library from reader, closes #3969 (#3973)
When the main window has been destroyed (Windows/Linux default close), the
reader's "go to library" button only closed the reader, leaving no library
visible. Add ensureMainLibraryWindow() that shows an existing main window
or recreates one with the 'main' label so the existing close-reader-window
wiring keeps working. Also grant the cross-window show/unminimize permissions
the call now needs.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:22:28 +02:00
loveheaven ebbbf104b2 feat(cjk): support inline annotation(warichu, Gezhu) layout (#3934)
* feat(warichu): support warichu (割注) inline annotation layout

- Add warichu HTML transformer that converts <span class="warichu"> and
  <warichu> elements into .warichu-pending placeholders during content load
- Implement runtime layout (layoutWarichu / relayoutWarichu) that measures
  column position and splits text into small inline-block chunks (2 chars
  each) so CSS column boundaries can break between them, preventing large
  blank gaps in vertical-rl pagination
- Use column stride (column-width + column-gap) for accurate position
  measurement across column boundaries
- Hook into stabilized event for initial layout and relayout on resize
- Add warichu CSS styles (inline-block chunks, half-size font, vertical align)

* fix(warichu): correct HTML slicing edge cases

- sliceHtml: re-emit tags that were already open before the slice start
  so the result stays well-formed. Previously a slice past an opening
  tag produced an orphan closing tag (e.g. "<b>Hello</b>"[3,5] →
  "lo</b>" instead of "<b>lo</b>").
- sliceHtml / removeFirstVisibleChar / removeLastVisibleChar: treat HTML
  entities (e.g. &amp;) as one visible character so they aren't split or
  truncated mid-entity (e.g. removeFirstVisibleChar("&amp;rest") →
  "amp;rest").
- buildNodes: drop a duplicate chunk.appendChild(l2).
- Add unit tests covering the above for the three pure helpers.

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-04-27 19:12:50 +08:00
Huang Xin 38d7ba80fe feat(opds): support auto-download books from OPDS feeds (#3844)
* feat: auto-download new items from subscribed OPDS catalogs

Add opt-in auto-download per OPDS catalog. When enabled, new publications
are downloaded on app startup and pull-to-refresh. Dedup via Atom <id>.

- Extend foliate-js parser to surface entry id/updated fields
- Add subscription state to OPDSCatalog type
- New headless opdsSyncService with navigation feed crawling
- Auto-download toggle in CatalogManager UI
- Wire sync into startup and pull-to-refresh hooks
- 16 new test cases

Built with an AI code agent.

Closes #3836

* chore: point foliate-js submodule to fork with OPDS id/updated fields

CI needs to fetch the foliate-js commit that adds id and updated field
extraction from OPDS feeds. Point submodule URL to our fork where the
commit is pushed.

* feat(opds): rearchitect auto-download with two-phase OPDS sync

Replaces the monolithic opdsSyncService from #3837 with a separated
discovery + acquisition pipeline. Subscription state lives in
per-catalog JSON files under Data/opds-subscriptions/, decoupled from
catalog config.

Discovery (services/opds/feedChecker.ts) resolves a catalog URL down
to its "by newest" feed via three detection tiers — rel="http://opds-
spec.org/sort/new" (Calibre / Calibre-Web), title heuristics
("Newest", "Recently Added", "Latest"), and href patterns
(?sort_order=release_date for Project Gutenberg, /new-releases for
Standard Ebooks). It then walks only that feed plus rel=next
pagination, never the full navigation tree. When no by-newest feed is
found, the catalog is skipped with a warning instead of silently
mirroring everything.

Acquisition link selection filters to safe rels
(acquisition / acquisition/open-access — drops buy / borrow /
subscribe / sample / indirect), prefers open-access when both rels
are present, then ranks by format tier:
  0  Advanced EPUB / EPUB 3 (link title, .epub3 href, version=3.x)
  1  plain EPUB
  2  MOBI / AZW / AZW3
  3  PDF / CBZ
  4  anything else
Within a tier, ties resolve by feed order. When the upstream omits or
mis-declares (octet-stream) the link's media type, format is inferred
from href extension and link title. Entries that appear in both
feed.publications and a group are deduped within the same batch.

Orchestration (services/opds/autoDownload.ts) runs catalogs
sequentially — per-catalog concurrency already caps parallel downloads
at 3, so a parallel fan-out across catalogs would be N×3 simultaneous
requests on cellular. Pending and retry items are deduped by entryId,
and entries still inside their exponential-backoff window are skipped
instead of re-attempted (avoids appending duplicate failedEntries
records). Filenames come from the last path segment, capped at 200
chars, with a try/catch around decodeURIComponent for malformed
%-sequences.

Hook (hooks/useOPDSSubscriptions.ts): startup, 5-minute interval, and
'check-opds-subscriptions' event triggers. Toggling auto-download on
fires the event so the sync runs immediately. Newly imported books
are queued for cloud upload via transferManager when the user is
logged in and settings.autoUpload is on, mirroring the manual OPDS
download path. 'opds-sync-complete' is dispatched after every sync so
listening UI can refresh without polling. loadSubscriptionState
self-heals duplicate failedEntries from older state files.

The submodule URL is reverted to readest/foliate-js (the changes
needed for OPDS id/updated extraction are already in main), and
OPDSCatalog is trimmed to only the autoDownload field.

Verbose [OPDS]-prefixed debug logs cover the whole pipeline: feed
fetch → resolve → discovery → per-item download (with magic-byte
dump of the saved file) → import → cloud upload queueing.

34 new tests across opds-feed-checker, opds-auto-download,
opds-subscription-state, opds-types.

Closes #3836.
Supersedes #3837.

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

---------

Co-authored-by: Johannes Mauerer <johannes@mauerer.info>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 06:04:32 +02:00
Huang Xin 31e44d2e4d fix(a11y): fixed saving reading progress with screen readers, closes #3864 (#3891) 2026-04-18 14:52:40 +02:00
Huang Xin 3e292af990 refactor(nav): refactor book nav service with TOC enrichment (#3874) 2026-04-15 17:08:17 +02:00
Huang Xin b0cc5461af refactor(toc): cache navigable structure per book (#3869)
* refactor(toc): cache TOC + section fragments per book

Moves the TOC regrouping and section-fragment computation out of
foliate-js/epub.js #updateSubItems into the readest client as
computeBookNav / hydrateBookNav in utils/toc.ts. The result is
persisted to Books/{hash}/nav.json — capturing the book's full
navigable structure (TOC hierarchy + sections with hierarchical
fragments). Compute once, persist locally, hydrate on subsequent
opens. Designed to serve current human-facing navigation (TOC
sidebar, progress math) and future agentic navigation (LLM-driven
seeking by structural location).

Versioned by BOOK_NAV_VERSION for forward invalidation. Existing
books regenerate transparently on next open.

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

* chore: update worktree scripts

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:15:10 +02:00
Huang Xin 73d30c103f fix(toc): fix page number of some TOC items from section fragments (#3867) 2026-04-14 16:22:06 +02:00
Huang Xin e9d71b2936 feat(settings): add an option to avoid overriding paragraph layout, closes #3824 (#3858) 2026-04-13 09:42:53 +02:00
Huang Xin de11511c30 fix(layout): fixed bleed layout of images (#3823) 2026-04-10 19:33:56 +02:00
Lex Moulton baee85e7c8 feat(rsvp): sync reading position to cloud via book_configs (#3801) 2026-04-09 15:52:49 +02:00
Huang Xin 184de9210d fix(security): prevent SSRF in kosync proxy (CWE-918) (#3793)
Validate serverUrl in the kosync API proxy to block requests to
private/internal addresses and non-http(s) schemes. Also fix isLanAddress
to detect 0.0.0.0 and bracket-wrapped IPv6 private addresses.

Closes code-scanning alert #14.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:21:51 +02:00
Huang Xin db35a4e203 fix(style): clamp oversized hardcoded pixel widths and fix browser test flakiness (#3785)
Closes #3770.

Add transformStylesheet rule that clips CSS width declarations exceeding the
viewport with max-width and border-box sizing. Also add @testing-library/react
to vitest browser optimizeDeps.include to prevent mid-test Vite reloads on CI.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:23:30 +02:00
Huang Xin a2d17e6a79 fix: clear highlight overlay when deleting annotation from sidebar, closes #3756 (#3758)
The sidebar delete handler always removed annotations through the note
bubble overlay key (`NOTE_PREFIX + cfi`), but highlights are keyed by
the raw CFI. Deleting a highlight from the sidebar therefore left its
overlay drawn until the book was reopened, while the popup path — which
lets `wrappedFoliateView` default the value to the CFI — worked.

Introduce `removeBookNoteOverlays` that mirrors the draw filters in
`Annotator.tsx` and clears every overlay a BookNote can own (highlight
and/or note bubble), and route the sidebar delete through it.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:25:58 +02:00
Mohammed Efaz d682fcbb44 feat: add named highlight colors with sync and picker ux fixes (#3741)
* fix: add highlight color label fields

* fix: add default highlight label sync fields

* fix: add highlight prefs sync helpers

* fix: add highlight color name inputs

* fix: persist and sync highlight color names

* fix: add highlight color label helpers

* fix: add long press highlight label preview

* fix: pull highlight color prefs during library sync

* test: cover highlight color label helpers

* fix: widen highlight color name inputs

* fix: show highlight names on hover and touch hold

* fix: prevent highlight name input overlap

* fix: improve highlight name input responsiveness

* fix: support drag scrolling for highlight colors

* fix: batch custom color and label updates

* fix: serialize highlight prefs saves

* fix: align color strip drag and restore color clicks

* fix: translate default highlight color labels

* refactor: remove highlight preference sync wiring

* fix: align highlight option i18n with existing pattern

* fix: remove redundant english highlight keys

* fix: support raw and normalized highlight label keys

* fix: use underscore translator in highlight options

* fix: translate custom highlight color labels in editor

* refactor: simplify highlight settings persistence

* fix: maintianer review

* refactor: simplify highlight prefs save and clean up editor

- Drop the skipUserColors/skipLabels options from handleHighlightPrefsChange
  and always persist both arrays; the flags only masked a no-op caller.
- Type handleHighlightColorsChange with Record<HighlightColor, string>
  instead of typeof so the signature reads clearly.
- Remove the always-true `|| true` guard around the custom colors section.
- Stop wrapping user-typed custom color labels with _(), matching the
  built-in color inputs and keeping the input value equal to what the
  user typed.

* refactor: couple highlight labels to their colors

Replace the parallel `highlightColorLabels: Record<string, string>` map
with label storage that lives next to each color. This removes the hex
key normalization layer and its whole class of orphan/case-drift bugs.

Data model:
  userHighlightColors: string[]                    ->  UserHighlightColor[]
                                                       ({ hex, label? })
  highlightColorLabels: Record<string, string>     ->  (removed)
                                                       defaultHighlightLabels:
                                                         Partial<Record<DefaultHighlightColor, string>>

A `migrateHighlightColorPrefs` helper runs during `loadSettings` and
handles both the shipped `string[]` layout and the draft-build
`highlightColorLabels` layout: hex-keyed labels attach to matching user
colors, name-keyed labels move into `defaultHighlightLabels`. Malformed
entries are dropped.

Editor:
  - Label inputs commit on blur (Enter also commits), so typing a long
    label no longer fires `setSettings`/`saveSettings` on every
    keystroke. A small `LabelInput` component owns the draft state and
    syncs if the prop changes externally.
  - Three explicit callbacks (`onCustomHighlightColorsChange`,
    `onUserHighlightColorsChange`, `onDefaultHighlightLabelsChange`)
    replace the previous single callback with opaque skip flags.

Picker:
  - Extracted a reusable `useDragScroll` hook (mouse only, 6px
    threshold, 120ms click suppression) from the inline state machine
    in `HighlightOptions`. The picker drops to ~280 lines.
  - Long-press preview stays touch/pen only. It now reads labels via
    `getHighlightColorLabel` (which returns `undefined` when no user
    label is set), letting the component layer decide whether to fall
    back to a translated default name.

i18n:
  - Default color names ('red' | 'yellow' | 'green' | 'blue' | 'violet')
    are registered once at module scope via `stubTranslation` and
    translated at the picker layer through `useTranslation`. User-typed
    labels are never run through `_()`, so what the user types is what
    the editor shows.

Tests:
  - `annotator-util.test.ts`: rewritten around the new helper contract
    (user label -> undefined fallback) and case-insensitive hex matching.
  - `settings-highlight-migration.test.ts`: new, covers legacy
    `string[]`, already-migrated entries, malformed hex filtering, and
    the two draft-label fold paths.

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-04-05 18:08:55 +08:00
Yi-Shun Wang 45bd355981 feat(opds): support custom catalog headers with web proxy consent (#3740)
* Add custom headers support for OPDS catalogs

* Require web proxy consent for sensitive OPDS credentials

* Strengthen OPDS header forwarding tests

---------

Co-authored-by: Yi-Shun Wang <git@albertwang.dev>
2026-04-04 12:01:16 +02:00
Mohammed Efaz 9ecb9b24d2 feat: make reading ruler selection and step navigation coherent (#3722)
* refactor(reader): extract reading ruler math helpers

* fix(reader): keep text selectable inside reading ruler

* feat(reader): route taps to ruler step navigation

* feat(reader): route keyboard navigation to ruler steps

* fix(reader): animate ruler mask and frame together

* fix(reader): preserve drag anchor for ruler handles

* fix(reader): fall back to page turns at ruler edges
2026-04-01 18:59:44 +02:00
Huang Xin 74401fc1bb fix(library): always sort series books by index ascending, closes #3709 (#3717)
* fix(library): always sort series books by index ascending, closes #3709

When grouping by series, the sort direction (asc/desc) was applied to
within-series book sorting, causing books to appear in reverse series
index order when the library was sorted descending. Now series index
sort is always ascending (1, 2, 3…) regardless of the global sort
direction. Also sort series groups by name when "Sort by Series" is
selected instead of falling back to updatedAt.

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

* fix(library): skip merge sort when inside a series group

The allItems.sort at the end of sortedBookshelfItems was re-sorting
books using the regular bookSorter (e.g. Date Read), which overrode
the within-group sort that correctly prioritized series index. Now
when inside a group, the already-sorted books are returned directly.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:01:48 +02:00
Huang Xin 0e516f6e56 chore(test): add unit tests and enforce dash-case naming for test files (#3715)
Add ~290 new unit tests covering utils (lru, diff, css, a11y, walk,
usage, txt-worker), services (EdgeTTSClient, transformers), and
suppress noisy console output across all test files. Rename 27
camelCase test files to dash-case for consistency.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 09:14:00 +02:00
Huang Xin b71b246601 feat(settings): add TTS settings tab and highlight opacity, closes #3661 (#3712)
Add a new TTS tab in settings with media metadata update frequency control
(sentence/paragraph/chapter) to reduce Bluetooth notification spam, and move
TTS highlight settings from Color tab to the new TTS tab. Also add a highlight
opacity setting with live preview in the Color tab.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 06:35:26 +02:00
Huang Xin 94843902ac fix(annotations): fix all annotations grouped under last chapter for fragment-href TOCs, closes #3688 (#3706)
When TOC entries use fragment-suffixed hrefs (e.g. ch01.xhtml#ch01),
the sectionsMap lookup matched subitems with cfi: undefined instead of
parent sections with valid CFIs. This caused findTocItemBS to map every
annotation to the last TOC entry. Now skip subitems lacking a CFI and
fall back to the base section.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:56:23 +02:00
Huang Xin f67930feb1 fix(opds): fix Copyparty books showing as "Untitled" in mixed feeds, closes #3667 (#3705) 2026-03-31 17:35:14 +02:00
Huang Xin 956c71cd7b chore: migrate from ESLint to Biome for linting (#3694)
Replace ESLint with Biome for ~14x faster linting (~360ms vs ~5s).

- Add biome.json with rules matching ESLint parity (Next.js, a11y,
  TypeScript, unused vars/imports)
- Remove eslint, @typescript-eslint/*, eslint-config-next,
  eslint-plugin-jsx-a11y, @eslint/* from deps
- Remove eslint.config.mjs
- Update lint script to: tsgo --noEmit && biome check .
- Fix 11 real code issues caught by Biome (banned types, explicit any,
  unsafe finally, unreachable code, shadowed names)
- Disable Biome formatter (Prettier stays for Tailwind class sorting)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:25:04 +02:00
Huang Xin 966f5e2acd fix(opds): fixed image download from ODPS server on the web, closes #3649 (#3658) 2026-03-27 13:21:52 +01:00
Huang Xin 3d4d1482aa feat: add keyboard shortcuts help dialog (#3653)
Add a keyboard shortcuts help dialog toggled with '?' that displays all
shortcuts grouped by section with platform-appropriate key rendering.

Restructure DEFAULT_SHORTCUTS to include i18n descriptions and section
metadata. Translate 37 new keys across 29 locales.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:59:38 +08:00