Compare commits

...

50 Commits

Author SHA1 Message Date
Huang Xin 1088af023b release: version 0.10.6 (#3861) 2026-04-13 12:55:13 +02:00
Huang Xin 011ad18a02 fix(android): use stable safe area insets to avoid unnecessary layout shift, closes #3670 (#3859) 2026-04-13 12:04:41 +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 ec32614539 fix(settings): fixed color picker for custom highlight colors, closes #3796 (#3857) 2026-04-13 08:25:48 +02:00
Huang Xin 96678d85ec refactor(settings): persist the apply-globally toggle per book (#3856) 2026-04-13 07:45:24 +02:00
Huang Xin 41b5e92563 feat(annotator): support instant copy operation for selected text, closes #3828 (#3854) 2026-04-13 06:00:05 +02:00
Huang Xin ef97a8ed02 fix(ux): optimize scrolling UX for the bookshelf and sidebar content (#3849) 2026-04-12 20:52:12 +02:00
Huang Xin 8df8bc8b4a chore(agent): use claude in chrome for web based qa (#3847) 2026-04-12 12:26:20 +02:00
DrSheppard f0e23a1503 fix(linux): update package installation for Linux-x64 (#3845) 2026-04-12 11:01:00 +02:00
Huang Xin 4e1464ef17 fix(macOS): don't show window button when traffic lights are on the header, closes #3831 (#3843) 2026-04-12 10:05:27 +02:00
Huang Xin 7b60b1bb0c fix(ios): reduce GPU memory pressure to prevent WebKit GPU process crash (#3842)
On iOS, navigating to a book group in the library caused the WebKit GPU
process to exceed its 300 MB jetsam limit (peaking at ~328 MB), resulting
in a blank screen flash and broken scroll state.

Three changes reduce peak GPU memory usage:

- Add overscan={200} to VirtuosoGrid/Virtuoso so only items within 200px
  of the viewport are rendered, limiting simultaneous image decoding
- Add loading="lazy" to both Image components in BookCover so the browser
  defers decoding offscreen cover images
- Conditionally mount the <video> and <audio> elements in AtmosphereOverlay
  only when atmosphere mode is active, eliminating idle H.264 decoder
  memory overhead

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:06:16 +02:00
Huang Xin cc780712b9 fix(deps): add pnpm override for qs >=6.14.2 (Dependabot #71) (#3841)
Fixes DoS vulnerability from arrayLimit bypass in comma parsing.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 06:58:31 +02:00
Huang Xin 95ff526140 fix(deps): bump dependencies to resolve 13 Dependabot security alerts (#3840)
Update next (16.2.3), vite (7.3.2+), react/react-dom (19.2.5),
@vitejs/plugin-rsc (0.5.23), react-server-dom-webpack (19.2.5),
and add overrides for lodash (4.18.0), lodash-es (4.18.0),
basic-ftp (5.2.2) to fix high/medium severity vulnerabilities
including DoS, code injection, prototype pollution, CRLF injection,
arbitrary file read, and path traversal.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 06:44:30 +02:00
Huang Xin f86bbbcc22 perf(library): virtualize grid and list of book items when rendering library page (#3835) 2026-04-11 21:25:06 +02:00
Huang Xin 20940105fb feat(library): navigate to previous group with the Back button on Android, closes #2675 (#3833) 2026-04-11 20:08:00 +02:00
Huang Xin 2a49e93cf7 fix(library): fixed the All button in groups breadcrumbs navigation bar, closes #3782 (#3832) 2026-04-11 19:55:08 +02:00
Lex Moulton 030a7c0823 perf: optimize library operations for large collections (#3827)
* perf(store): decouple page turn from full library rewrite for large collections

Previously every page turn triggered setLibrary() which copied the entire
library array, ran refreshGroups() with MD5 hashing over all books, and
caused cascading re-renders. With ~2800 books this made reading unusable.

- Add hash-indexed Map to libraryStore for O(1) book lookups
- Add lightweight updateBookProgress() that skips array copy and refreshGroups
- Use hash index in setProgress, saveConfig, and initViewState
- Batch cover URL generation with concurrency limit on library load

Addresses #3714

* perf(import): replace filter()[0] with find() to short-circuit on first match

* fix(store): replace Object.assign state mutation with immutable spread in setConfig

* perf(persistence): remove JSON pretty-printing to reduce serialization overhead

* fix(reader): stabilize debounce reference in useIframeEvents to prevent timer reset on re-render

* perf(context): memoize provider values to prevent unnecessary consumer re-renders

* perf(store): cache visible library to avoid refiltering on every access

* perf(library): remove redundant refreshGroups call already triggered by setLibrary

* perf(import): replace O(n) splice(0,0) with O(1) push for new book insertion

* perf(import): defer library persistence to end of import batch instead of every 4 books

* perf(library): skip full library reload on reader close since store is already in sync

* fix: address PR review feedback for library perf optimizations

Correctness fixes for issues found in code review:

- fix(library): restore library reload on close-reader-window. Reader
  windows are independent Tauri webviews with their own libraryStore
  instance, so progress / readingStatus / move-to-front updates from
  the reader do not propagate to the main window. Reload from disk
  so the library reflects the changes the reader just persisted.

- perf(import): wire BookLookupIndex into importBooks. The lookupIndex
  parameter on bookService.importBook had no caller, leaving the
  Map-based dedup path dead. Build the index once per import batch
  in app/library/page.tsx and thread it through appService.importBook
  so the O(1) dedup path is actually exercised.

- perf(import): defer library save to end of batch. Add a skipSave
  option to libraryStore.updateBooks and call appService.saveLibraryBooks
  once after the entire import loop, instead of once per concurrency-4
  sub-batch.

- fix(store): make updateBookProgress immutable. The previous in-place
  mutation reused the same library array reference, bypassing Zustand
  change detection AND leaving the visibleLibrary cache holding stale
  Book references. Now slice the array, update the entry, and refresh
  visibleLibrary. Also make readingStatus a required parameter so
  future callers cannot accidentally clear it by omitting the argument.

- fix(store): make saveConfig immutable. It previously mutated the Book
  object's progress / timestamps in place and used splice/unshift on
  the shared library array. Now spread to a new book object and rebuild
  via setLibrary. Also corrects the interface signature to return
  Promise<void> (the implementation was already async).

- fix(store): make updateBook immutable for the same reason — it was
  mutating the previous-state library array before spreading.

- fix(context): wrap AuthContext login/logout/refresh in useCallback.
  Without this, the useMemo deps array changed every render and the
  memo was a no-op, defeating the optimization the PR was trying to
  add.

- fix(reader): use a ref for handlePageFlip in useMouseEvent's debounce.
  The empty-deps useMemo froze the first-render handler; with the ref
  the debounced wrapper always invokes the latest closure.

Test coverage added:
- library-store: immutable updateBookProgress, visibleLibrary cache
  refresh, deleted-book filtering, updateBooks skipSave option
- book-data-store: immutable saveConfig, move-to-front correctness,
  visibleLibrary order, persistence behavior
- import-metahash: BookLookupIndex update on new import, lookup-index
  consultation before scanning books array
- auth-context (new file): context value identity stability across
  re-renders, callback identity stability
- useIframeEvents (new file): debounced wheel handler dispatches to
  the latest handlePageFlip after re-render

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

* refactor(types): move BookLookupIndex to types/book.ts

Avoids the inline `import('@/services/bookService').BookLookupIndex`
type annotation in types/system.ts. Both the AppService interface and
the bookService implementation now import BookLookupIndex from the
canonical location alongside Book.

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

* refactor(import): convert importBook params to options object

Replace the long positional-argument list on appService.importBook
(saveBook, saveCover, overwrite, transient, lookupIndex) with a
single options object so callers no longer need to pad with
`undefined, undefined, undefined, undefined` to reach the parameter
they actually want to set.

Before:
  await appService.importBook(file, library, undefined, undefined,
                              undefined, undefined, lookupIndex);

After:
  await appService.importBook(file, library, { lookupIndex });

The underlying bookService.importBook is also refactored to take an
options object: required AppService callbacks (saveBookConfig,
generateCoverImageUrl) are bundled with the optional flags via an
ImportBookInternalOptions interface that extends the public
ImportBookOptions defined in types/book.ts.

All existing call sites updated to the new shape.

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

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:32:35 +02:00
Huang Xin 7bf4822b27 fix(library): restore breadcrumb 'All' navigation by bypassing next-view-transitions, closes #3782 (#3829)
The breadcrumb "All" button was broken on first click after entering a
group because next-view-transitions@0.3.5's useTransitionRouter wraps
router.replace() in startTransition + document.startViewTransition, and
this combination is incompatible with Next.js 16.2 RSC navigation when
only the search params change for the same pathname (e.g.
/library?group=foo -> /library). The navigation silently never commits.

Extract the library navigation logic into a useLibraryNavigation hook
that uses plain useRouter from next/navigation. The data-nav-direction
attribute is still set so existing directional CSS keeps working when
view transitions fire via popstate.

See https://github.com/shuding/next-view-transitions/issues/65 for the
upstream incompatibility.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:04:37 +02:00
Huang Xin de11511c30 fix(layout): fixed bleed layout of images (#3823) 2026-04-10 19:33:56 +02:00
Huang Xin ab7da981da fix(eink): remove scroll animation in eink mode and optimize eink detection (#3822)
* fix(eink): remove scroll animation in eink mode

* fix(android): fix startup ANR on e-ink devices from getprop subprocesses
2026-04-10 18:22:06 +02:00
Huang Xin 3df75a67f9 feat(tts): support edge tts on cloudflare worker (#3819) 2026-04-10 15:32:06 +02:00
Huang Xin 07e3248780 fix: apply disable click to paginate also for non-iframe clicks (#3818) 2026-04-10 12:27:17 +02:00
Lex Moulton 23d5f3363d fix(rtl): fix page navigation for Arabic books (#3817)
* fix(rtl): fix page navigation for Arabic books

* fix(rtl): unified navigation handlers for rtl and ltr

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-04-10 11:54:30 +02:00
Zeedif c6daf72da9 feat(opds): allow editing of registered catalogs (#3814)
* feat(opds): allow editing of registered catalogs

* chore(i18n): add translations for catalog edit feature

Translate "Remove", "Edit OPDS Catalog", and "Save Changes" across all
31 locales.

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

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 06:09:30 +02:00
Zeedif bd866cb049 fix(opds): harden Content-Disposition filename parsing for complex names and encoding (#3816) 2026-04-10 06:08:57 +02:00
Huang Xin a5690e9a84 fix(tts): skip br elements in PDF text layer to prevent TTS interruptions at line breaks, closes #3771 (#3811)
PDF.js TextLayer renders <br> between text spans for visual line wrapping.
The TTS SSML generator was converting these to <break> elements, causing
TTS engines to pause at every PDF line break within paragraphs. Fix by
rejecting <br> (along with canvas and annotationLayer) via the node filter
when the document is detected as a PDF.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:34:38 +02:00
Huang Xin ed7cfc31fc fix(layout): fix off-by-one page count on fractional DPR devices, closes #3787 (#3813)
The `pages` getter used Math.ceil(viewSize / containerSize) which inflates
the count by 1 when floating-point drift makes the ratio slightly above an
integer (e.g. 4.00000001 → 5). Use Math.round to absorb sub-pixel drift,
matching the approach already used in #getPagesBeforeView.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:08:55 +02:00
Huang Xin a07bf23e18 chore(docs): add worktree management for isolated PR review and feature work (#3810)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:42:47 +02:00
Zeedif 41d014914b fix(opds): handle spaces and quotes in Content-Disposition filename parsing (#3812) 2026-04-09 19:42:29 +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 1e259e87b2 refactor(reader): introduce priority-based touch interceptor for gesture handling (#3809)
Replace the inline touch-swipe event dispatching with a module-level
interceptor registry. This lets the reading ruler (priority 10) claim
drag gestures before the swipe-to-flip handler (priority 0), preventing
conflicts when dragging the ruler on touch devices.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:48:21 +02:00
Huang Xin 4abbc17f66 fix(annotator): fixed instant annotation in scrolled mode, closes #3769 (#3808) 2026-04-09 10:34:28 +02:00
Huang Xin d7fd06ca82 chore: add explicit permissions to GitHub Actions workflows (#3807)
Fixes code scanning alerts #1, #2, #3, #4
(actions/missing-workflow-permissions).

- retry-workflow.yml rerun job: actions: write (to rerun workflows)
- upload-to-r2.yml upload-to-r2 job: contents: read (to download releases)
- upload-to-r2.yml retry-on-failure job: actions: write (to trigger retry)
- release.yml upload-to-r2 job: contents: read, actions: write

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 07:33:25 +02:00
Huang Xin e43e533aca security: fix complete multi-character sanitization for HTML comments in txt.ts (#3806)
* security: fix for code scanning alert no. 11: Incomplete multi-character sanitization

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: use dotAll flag to match multi-line HTML comments

Add the 's' flag to the comment-stripping regex so '.' matches
newlines, ensuring comments spanning multiple lines are also removed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: iterative dotAll sanitization in extractChaptersFromSegment

Fixes code scanning alert #10 (incomplete multi-character sanitization).
Apply the same fix as alert #11: replace one-shot comment stripping
with an iterative loop using the 's' (dotAll) flag so nested and
multi-line HTML comments are fully removed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: iterative HTML tag sanitization in cleanDescription

Fixes code scanning alert #9 (incomplete multi-character sanitization).
Replace one-shot tag stripping with an iterative loop so crafted inputs
like nested/overlapping tags cannot leave '<script' behind after a single
replacement pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 07:20:56 +02:00
Huang Xin dc788283ad security: fix for code scanning alert no. 11: Incomplete multi-character sanitization (#3804)
* security: fix for code scanning alert no. 11: Incomplete multi-character sanitization

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: use dotAll flag to match multi-line HTML comments

Add the 's' flag to the comment-stripping regex so '.' matches
newlines, ensuring comments spanning multiple lines are also removed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 06:54:20 +02:00
dependabot[bot] 373420bb0c chore(deps): bump actions/checkout in the github-actions group (#3805)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 4 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-09 06:47:57 +02:00
Huang Xin 6072b0dcbd security: fix for code scanning alert no. 12: Use of externally-controlled format string (#3803)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-09 06:23:08 +02:00
Huang Xin 13ff96db85 security: potential fix for code scanning alert no. 19: DOM text reinterpreted as HTML (#3802)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-09 06:10:49 +02:00
Lex Moulton bfbe92f355 refactor(sidebar): replace react-window and OverlayScrollbars with react-virtuoso and CSS scrollbars (#3798)
* feat: Add option to split words in RSVP mode

* fix(rsvp): replace lookbehind regex with lookahead-only split in getHyphenParts

* feat: Add option to split words in RSVP mode

* fix(theme): update data-theme and themeCode when system theme changes

* feat(rsvp): split on ellipsis between letters and preserve delimiter type

* fix(rsvp): fix incorrect merged line

* feat(rsvp): insert blank frame between consecutive identical words

* refactor(sidebar): replace react-window and OverlayScrollbars with react-virtuoso and CSS scrollbars

* feat(toc): smooth scroll to active chapter on sidebar open

* test(theme-store): expand dark theme palette fixture with full color tokens

* refactor: remove dead code and consolidate duplicate CSS scrollbar rules

* fix(toc): fix auto-scroll on sidebar open and improve scroll behavior

- Add isSideBarVisible to scroll effect deps so it fires on sidebar open
- Use setTimeout delay for Virtuoso to finish layout before scrolling
- Add 10s cooldown on user scroll to re-enable auto-scroll
- Use smooth scroll for short distances (<16 items), instant for longer
- Track visible center via rangeChanged for accurate distance calculation
- Fix tab navigation background opacity (bg-base-200 instead of /20)

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

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:44:45 +02:00
Huang Xin 799db40763 fix(pdf): add an option to apply theme colors to PDF, closes #3778 (#3799) 2026-04-08 07:29:44 +02:00
Huang Xin 932c82aa49 chore(security): update CodeQL workflow to remove languages (#3794)
* chore(security): update CodeQL workflow to remove languages

Removed unsupported languages from CodeQL workflow.

* style: format codeql.yml with Prettier

Fix CI format check failure by applying Prettier formatting to the
CodeQL workflow file.

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-07 21:36:46 +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
Zach Bean 50a2957e35 feat(kosync): support HTTP Basic auth for CWA KOSync servers (#3792)
* Support HTTP Basic auth for kosync connections

* fix(kosync): use separate password field for HTTP Basic auth

- Add `password?` field to KOSyncSettings to store the plain password
  alongside the existing `userkey` (md5 hash), preserving backward
  compatibility for existing users
- Replace Buffer.from() with btoa() for browser-compatible Base64 encoding
- Simplify buildHeaders() to use config fields directly instead of
  the useAuth union type

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

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:07:01 +02:00
Zach Bean 637b7462c4 fix(kosync): use Fragment keys to prevent form field issues (#3791)
* Use key attrs to prevent form field issues

Fixes #3790.

* PR feedback: use Fragment keys instead of individual element keys
2026-04-07 21:05:14 +02:00
Huang Xin 82deb85c64 docs: add threat model and incident response plan to SECURITY.md (#3788)
- Add Threat Model section covering assets, threat actors, attack
  surfaces, and mitigations for ebook parsing, cloud sync, OPDS
  integration, rendered HTML/JS, supply chain, and Tauri IPC
- Add Incident Response Plan with triage, containment, remediation,
  disclosure, and post-incident review steps
- Add severity definitions table

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 19:17:56 +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
pythontyphon 017a9338b3 fix(dictionary): add Chinese dictionary lookup with pinyin support (#3784)
The Wiktionary REST API does not support Chinese entries — simplified
characters return 404, traditional characters omit Chinese results, and
no pronunciation data is ever included. Switch Chinese lookups to the
Wiktionary Action API which returns full wikitext with pinyin and
definitions. Also add encodeURIComponent to the REST API URL for all
other languages.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 06:16:26 +02:00
AK Venugopal cf21a752c6 Updating Hardcover progress sync logic (Issue #3775) (#3777)
* feat(hardcover): add one-way Hardcover sync integration

- Add HardcoverClient with ISBN/title-based book lookup, progress push, and note sync
- Add HardcoverSyncMapStore for persistent local mapping of note IDs to Hardcover journal IDs
- Add GraphQL queries/mutations for Hardcover API (insert/update/recreate journal entries)
- Add DB migration for hardcover_note_mappings table (schema: hardcover-sync)
- Add HardcoverSettings dialog component (connect/disconnect/enable toggle)
- Add useHardcoverSync hook wired in Annotator for push-notes and push-progress events
- Add Hardcover Sync section in BookMenu with per-book toggle (persisted in BookConfig)
- Add HardcoverSettings type and DEFAULT_HARDCOVER_SETTINGS to system settings
- Add hardcoverSyncEnabled per-book flag to BookConfig
- Mount HardcoverSettingsWindow in Reader alongside KOSync and Readwise windows

Sync is one-way (Readest → Hardcover), manual-action only, per-book opt-in.
Supports idempotent note sync: insert new, skip unchanged, update changed, recreate on stale remote ID.

* fix(hardcover): proxy API calls server-side to fix CORS, normalize Bearer token

- Add /api/hardcover/graphql Next.js route that proxies POST requests server-side,
  bypassing CORS restrictions (Hardcover API has no Access-Control-Allow-Origin header)
- On Tauri (desktop), calls Hardcover directly; on web, routes through the proxy
- Normalize token in HardcoverClient: accept raw JWT or 'Bearer <jwt>' format
- Update helper text to point to hardcover.app → Settings → API

* fix(hardcover): surface note-sync no-ops and harden book resolution

- Show toast feedback when book data is still loading, Hardcover is not configured,
  or the current book has no annotations/excerpts to sync
- Show an explicit info toast when note sync finds no new changes
- Parse Hardcover search results in the current hits/document response shape
- Resolve note sync through ensureBookInLibrary for parity with progress sync
- Add console logging for note/progress sync failures

* debug(hardcover): add runtime instrumentation for note sync

* feat(metadata): keep identifier stable and store ISBN separately

- Add dedicated metadata.isbn field
- Expose ISBN as its own editable field in book metadata
- Preserve identifier semantics for existing source IDs and hashes
- Route metadata auto-retrieval ISBN handling through the new field
- Prefer metadata.isbn for Hardcover matching

* fix(hardcover): avoid wasm sqlite for note mappings on web

- Store Hardcover note sync mappings in localStorage on web
- Keep sqlite-backed mappings for desktop/native environments
- Remove the web-only database dependency from manual note sync

* fix(hardcover): dedupe notes by payload hash across unstable note IDs

- Add payload-hash lookup in HardcoverSyncMapStore
- Reuse existing journal mapping when payload already synced
- Prevent duplicate insertions when note IDs change or duplicate locally

* fix(hardcover): avoid duplicate quote export when annotation note exists

- Detect excerpt+annotation pairs for the same highlight
- Skip standalone excerpt export when annotation has note text
- Keep annotation export as the single source of truth

* docs(hardcover): add consolidated change summary for review

* fix(hardcover): suppress excerpt export by CFI when annotation note exists

* fix(hardcover): suppress empty-note annotation duplicates when note exists

* fix(hardcover): deduplicate notes by text and cfi base node

* refactor(hardcover): optimize sync performance, add rate limiting and clean up debug tools

* chore: remove dev-only change log from main

* test(hardcover): add unit tests for sync mapping and client logic

* chore: custom deployment and UI fixes

* fix(hardcover): use timestamptz for accurate annotation time

* fix(hardcover): use date scalar and RFC 3339 formatting for journal entries

* Revert "chore: custom deployment and UI fixes"

This reverts commit 0329aba7129d1e1ebf2c663804b8fba9a9f87b91.

* Fix hardcover progress dates and surface imported ISBNs

* Fix Hardcover currently reading sync

* fix(hardcover): avoid promoting note sync status

* style(hardcover): apply prettier formatting

* test(hardcover): fix strict TypeScript assertions

* test(hardcover): harden sync regression coverage

* test(hardcover): fix lint and formatting regressions

* fix(hardcover): narrow note dedupe range matching

* refactor(hardcover): extract note dedupe helpers

* refactor(isbn): extract metadata normalization helpers

* feat(hardcover): improve synced quote formatting

* feat(hardcover): sync progress by edition percentage

* fix(hardcover): reuse active read on first sync

* style(hardcover): apply formatting fixes

* fix(hardcover): address adversarial review findings

- null-guard insert_user_book response: throw with error message
  when the API returns a null user_book instead of crashing on
  property access
- skip progress push when Hardcover edition page count is unknown:
  getHardcoverProgressPages now returns null when context.pages and
  context.bookPages are both null (title-search path); pushProgress
  exits early instead of silently sending an inaccurate local page
  number
- apply edition preference for title-search books: after
  searchBookByTitle resolves a book ID, fetch QUERY_GET_BOOK_USER_DATA
  via editions(where: { book_id: ... }) to retrieve the user's active
  read edition and user_book, then apply the same active-read-edition
  -> user_book-edition waterfall used for ISBN lookups
- add regression tests for all three fixes
- verify all query/mutation field names against hardcover schema.graphql
2026-04-06 17:59:51 +02:00
Mohammed Efaz 16adf11258 fix(library): align grid hover highlight corner radius (#3774) 2026-04-06 17:24:48 +02:00
Huang Xin ae2c421938 fix(ui): restore highlight options layout and clean up color name editing (#3776)
* fix(ui): restore highlight options layout and clean up color name editing

Restore the pre-#3741 layout for both the AnnotationPopup highlight
options row and the HighlightColorsEditor settings panel.

HighlightOptions: re-add `justify-between` on the outer container and
remove `flex-1` from the color strip so the gap between the style box
and color strip responds to the number of colors.

HighlightColorsEditor: restore `grid-cols-3 sm:grid-cols-5` grid,
remove always-visible name inputs, and add a click-to-edit popover on
each color circle with hover tooltip for the label.

Add a browser screenshot test that renders the real AnnotationPopup
component with Tailwind CSS and compares against baseline PNGs for
5, 10, and 15 colors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:48:12 +02:00
177 changed files with 6969 additions and 2209 deletions
+103
View File
@@ -0,0 +1,103 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: 'CodeQL Advanced'
on:
push:
branches: ['main']
pull_request:
branches: ['main']
schedule:
- cron: '38 20 * * 4'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
- language: rust
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v6
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- name: Run manual build steps
if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: '/language:${{matrix.language}}'
+4 -1
View File
@@ -191,7 +191,7 @@ jobs:
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android' && matrix.config.arch != 'armhf'
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1 libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev gir1.2-javascriptcoregtk-4.1 gir1.2-webkit2-4.1 libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: install dependencies (ubuntu only - armhf specific)
if: contains(matrix.config.os, 'ubuntu') && matrix.config.arch == 'armhf'
@@ -391,6 +391,9 @@ jobs:
upload-to-r2:
needs: [get-release, build-tauri]
permissions:
contents: read
actions: write
uses: ./.github/workflows/upload-to-r2.yml
with:
tag: ${{ needs.get-release.outputs.release_tag }}
+2
View File
@@ -8,6 +8,8 @@ on:
jobs:
rerun:
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: rerun ${{ inputs.run_id }}
env:
+4
View File
@@ -17,6 +17,8 @@ on:
jobs:
upload-to-r2:
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 3
strategy:
fail-fast: false
@@ -70,6 +72,8 @@ jobs:
if: failure() && fromJSON(github.run_attempt) < 3
needs: [upload-to-r2]
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- env:
GH_REPO: ${{ github.repository }}
+1
View File
@@ -49,6 +49,7 @@ fastlane/metadata/android/en-US/changelogs
result*
.playwright-mcp/
.gstack
.claude/worktrees
.claude/settings.local.json
Generated
+21
View File
@@ -27,6 +27,7 @@ dependencies = [
"rand 0.8.5",
"read-progress-stream",
"reqwest 0.12.28",
"rsproperties",
"serde",
"serde_json",
"tauri",
@@ -5080,6 +5081,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "pretty-hex"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a65843dfefbafd3c879c683306959a6de478443ffe9c9adf02f5976432402d7"
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -5835,6 +5842,20 @@ dependencies = [
"byteorder",
]
[[package]]
name = "rsproperties"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b98d55abde44982a00d67a84809166ea8f5c143abc86818f555485f19766cfe4"
dependencies = [
"log",
"pretty-hex",
"rustix 1.1.4",
"thiserror 2.0.18",
"zerocopy",
"zerocopy-derive",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
+2
View File
@@ -178,6 +178,7 @@ For Android:
# Initialize the Android environment (run once)
rm apps/readest-app/src-tauri/gen/android
pnpm tauri android init
pnpm tauri icon ../../data/icons/readest-book.png
git checkout apps/readest-app/src-tauri/gen/android
pnpm tauri android dev
@@ -190,6 +191,7 @@ For iOS:
```bash
# Set up the iOS environment (run once)
pnpm tauri ios init
pnpm tauri icon ../../data/icons/readest-book.png
pnpm tauri ios dev
# or if you want to dev on a real device
+105
View File
@@ -1,5 +1,69 @@
# Security Policy
## Threat Model
### Overview
Readest is a cross-platform e-reader (macOS, Windows, Linux, Android, iOS, Web) built on Next.js and Tauri. It processes user-supplied ebook files, syncs data to the cloud, integrates with external services (OPDS catalogs, KOReader, DeepL, Yandex), and handles user authentication.
### Assets
| Asset | Description |
| ------------------------------ | ------------------------------------------------------------------------------------ |
| Ebook files | User-uploaded EPUB, MOBI, PDF, and other formats stored locally and in cloud storage |
| Reading progress & annotations | Highlights, bookmarks, and notes synced across devices |
| User credentials | Authentication tokens and session data for cloud sync |
| User preferences & settings | Reading preferences, custom fonts, theme configurations |
| External API keys | Translation service credentials (DeepL, Yandex) configured by users |
### Threat Actors
| Actor | Motivation |
| ----------------------- | ---------------------------------------------------------- |
| Malicious ebook author | Craft a malformed file to exploit the parser or renderer |
| Network attacker (MitM) | Intercept sync traffic to steal credentials or inject data |
| Malicious OPDS server | Serve crafted catalog responses to exploit the client |
| Compromised dependency | Supply chain attack via npm or Cargo ecosystem |
| Unauthorized user | Access another user's synced library or annotations |
### Attack Surfaces & Mitigations
#### 1. Ebook File Parsing
- **Risk:** Malformed EPUB/MOBI/PDF files could trigger parser bugs, path traversal, or script injection via embedded HTML/JS.
- **Mitigations:** Ebook content is rendered in a sandboxed iframe. External script execution is blocked. File parsing is isolated from the main process.
#### 2. Cloud Sync & Authentication
- **Risk:** Credential theft, session hijacking, or unauthorized access to another user's library data.
- **Mitigations:** All sync traffic uses HTTPS/TLS. Authentication tokens are stored securely (OS keychain/secure storage). Server-side authorization ensures users can only access their own data.
#### 3. OPDS / External Catalog Integration
- **Risk:** A malicious OPDS server could serve crafted XML to exploit the parser, or redirect downloads to malicious files.
- **Mitigations:** OPDS responses are parsed defensively. Users explicitly add catalog sources. Downloaded files are treated as untrusted user content.
#### 4. Rendered HTML/JS in Ebook Content
- **Risk:** Embedded JavaScript in EPUB files could attempt XSS or data exfiltration.
- **Mitigations:** Book content is rendered in a sandboxed iframe with scripting restrictions. Navigation outside the book context is blocked.
#### 5. Supply Chain
- **Risk:** Compromised npm or Cargo packages could introduce malicious code.
- **Mitigations:** Dependencies are pinned via `pnpm-lock.yaml` and `Cargo.lock`. Dependabot and GitHub's dependency review are enabled for automated vulnerability detection.
#### 6. Desktop Native Code (Tauri)
- **Risk:** Tauri IPC commands could be abused by malicious web content to access the filesystem or OS APIs.
- **Mitigations:** Tauri's allowlist restricts which IPC commands are exposed. File system access is scoped to the application data directory.
### Out of Scope
- Vulnerabilities in user's operating system or browser outside of Readest's control
- Physical access attacks to a user's device
- Issues in third-party services (DeepL, Yandex, Calibre) themselves
## Supported Versions
Readest does not currently maintain separate release channels. Security updates are provided only for the latest release series.
@@ -35,3 +99,44 @@ What to expect after you report:
Please keep vulnerability details private until a fix is available and the
maintainers have approved disclosure.
## Incident Response Plan
When a security vulnerability is confirmed, we follow this process:
### 1. Triage (Day 12)
- Assign a severity level (Critical / High / Medium / Low) based on impact and exploitability.
- Identify affected versions, components, and users.
- Assign an owner responsible for coordinating the response.
### 2. Containment (Day 13)
- Assess whether an immediate mitigation or workaround can be published.
- Limit further exposure where possible (e.g., disable affected features, update dependencies).
### 3. Remediation (Day 314, depending on severity)
- Develop and internally review a fix.
- Validate the fix does not introduce regressions.
- Prepare a patched release and update changelog.
### 4. Disclosure & Release
- Coordinate disclosure timing with the reporter.
- Publish a GitHub Security Advisory with CVE if applicable.
- Release the patched version and notify users via release notes.
### 5. Post-Incident Review
- Document the root cause, timeline, and resolution.
- Update processes or controls to prevent recurrence.
### Severity Definitions
| Severity | Description |
| -------- | --------------------------------------------------------------------- |
| Critical | Remote code execution, full data compromise, or authentication bypass |
| High | Significant data exposure, privilege escalation, or denial of service |
| Medium | Limited data exposure or functionality disruption |
| Low | Minor issues with minimal security impact |
@@ -18,6 +18,10 @@
## Feature Notes
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / keyboard arrow navigation design, key files, and pitfalls
- [Cloudflare Workers WebSocket](cloudflare-workers-websocket.md) — use fetch() Upgrade pattern (not `ws` npm); CF delivers binary frames as Blob (must serialize async decodes)
## Patterns
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews
## Architecture Notes
- foliate-js is a git submodule at `packages/foliate-js/`
@@ -29,6 +33,9 @@
- Dropdown menus use `DropdownContext` (not blur-based) for screen reader compat
## Workflow
- [Test file filter](feedback_test_file_filter.md) — use `pnpm test <path>` without `--` to run a single file
- [Always rebase before PR](feedback_pr_rebase.md) — rebase onto origin/main before creating PRs
- [New branch per PR](feedback_pr_new_branch.md) — always create a fresh branch from main for each new PR/issue
- [Upgrade gstack locally](feedback_gstack_upgrade.md) — always upgrade from the project's .claude/skills/gstack, not global
- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never use `(?<=)` or `(?<!)` in JS/TS; build check rejects them
- [Use worktree](feedback_use_worktree.md) — never `git worktree add` directly; always `pnpm worktree:new` before PR review, issue fix, or feature work
@@ -0,0 +1,73 @@
---
name: Cloudflare Workers WebSocket
description: How to open and read WebSockets from Cloudflare Workers (the Node `ws` package does not work) and the Blob binary-frame gotcha
type: project
originSessionId: ec3d5424-adc2-4fca-836f-df323797489c
---
# Cloudflare Workers WebSocket on readest-app
## Why the Node `ws` package fails
The Node `ws` npm package (used transitively by `isomorphic-ws`) opens WebSockets by calling `http.request({ createConnection })`. The Cloudflare Workers runtime does not implement `options.createConnection`, so any attempt to `new WebSocket(url, { headers })` in a Worker throws:
```
The options.createConnection option is not implemented
```
This applies even with `compatibility_flags = ["nodejs_compat"]`.
## Correct pattern: fetch-based upgrade
On Workers you open a WebSocket by calling `fetch()` with an `Upgrade: websocket` header against the **https://** (not `wss://`) form of the URL. The response has `status === 101` and a non-standard `webSocket` property that must be `accept()`ed before use:
```ts
const upgradeUrl = url.replace(/^wss:\/\//i, 'https://');
const response = (await fetch(upgradeUrl, {
headers: { ...baseHeaders, Upgrade: 'websocket' },
})) as Response & { webSocket?: WebSocket & { accept(): void } };
if (response.status !== 101 || !response.webSocket) {
throw new Error(`WebSocket upgrade failed with status ${response.status}`);
}
const ws = response.webSocket;
ws.addEventListener('message', onMessage);
ws.accept();
ws.send(payload);
```
Detect the Workers runtime with `typeof globalThis.WebSocketPair !== 'undefined'``WebSocketPair` is a Workers-only global.
## Binary frames arrive as Blob (critical)
Cloudflare Workers deliver WebSocket binary frames as **`Blob`** — not `ArrayBuffer` (browsers) and not `Uint8Array` (Node `ws`). Blob decoding is async via `blob.arrayBuffer()`, so:
1. You must serialize decodes through a promise chain to keep frames in receive order — otherwise parallel awaits can merge bytes out of order.
2. Any terminal text message (e.g. Edge TTS's `Path: turn.end`) arrives **synchronously** and will finalize the stream before the in-flight Blob decodes have flushed. Always `await pendingBinary` in the turn.end handler and the close handler before checking whether data was received.
Example skeleton:
```ts
let pending: Promise<void> = Promise.resolve();
const enqueue = (getBuf: () => Promise<ArrayBufferLike> | ArrayBufferLike) => {
pending = pending.then(async () => {
const buf = await getBuf();
appendBinary(buf);
});
};
ws.addEventListener('message', (event) => {
const data = event.data;
if (data instanceof Blob) enqueue(() => data.arrayBuffer());
else if (data instanceof ArrayBuffer) enqueue(() => data);
else if (data instanceof Uint8Array) enqueue(() => data.buffer.slice(
data.byteOffset, data.byteOffset + data.byteLength,
));
// ... handle text path: turn.end
// -> await pending, then resolve
});
```
## Where this is used
`src/libs/edgeTTS.ts` `#fetchEdgeSpeechWs` has three branches: Tauri (plugin-websocket), Cloudflare Workers (fetch upgrade + Blob handling), and browser/Node fallback (`isomorphic-ws`). The route that exercises the CF branch is `src/app/api/tts/edge/route.ts`, hit when the web client falls back from direct `wss://` (which browsers can't set headers on) to the `/api/tts/edge` HTTPS endpoint.
@@ -0,0 +1,11 @@
---
name: No lookbehind regex
description: Never use lookbehind assertions in JS/TS code — the build check rejects them for browser compatibility
type: feedback
---
Never use lookbehind regex (`(?<=...)` or `(?<!...)`) in JavaScript/TypeScript source code. Use `(?:^|[^...])` or other alternatives instead.
**Why:** The project has a `check:lookbehind-regex` build check (`pnpm check:all`) that scans the Next.js output chunks and fails if any lookbehind assertions are found. Older WebViews (especially on some Android devices) don't support lookbehinds.
**How to apply:** When writing regex that needs to assert what comes before a match, use a non-capturing group with alternation (e.g., `(?:^|[^a-z-])`) instead of a negative lookbehind (`(?<![a-z-])`). This applies to all `.ts`/`.tsx`/`.js` files that end up in the build output.
@@ -0,0 +1,11 @@
---
name: test-file-filter
description: Use pnpm test/test:browser with path directly (no --) to run a single test file
type: feedback
---
Run a specific test file with `pnpm test <path>` or `pnpm test:browser <path>` — no `--` separator.
**Why:** Adding `--` before the path (e.g. `pnpm test:browser -- <path>`) causes vitest to ignore the file filter and run all test files. Without `--`, pnpm appends the path directly to the vitest command, which correctly filters to that file only.
**How to apply:** Always use `pnpm test src/__tests__/foo.test.ts` or `pnpm test:browser src/__tests__/foo.browser.test.tsx` when verifying a specific test file.
@@ -0,0 +1,18 @@
---
name: Use worktree for PR/issue/feature work
description: Always create a git worktree with pnpm worktree:new before reviewing PRs, fixing issues, or implementing features
type: feedback
originSessionId: 650f8ff2-980d-459f-ad23-ba0af56e28b5
---
Always use `pnpm worktree:new <branch-name|pr-number>` to create an isolated worktree before starting work on:
- Reviewing a GitHub PR (e.g., `pnpm worktree:new 3809`) → worktree at `~/dev/readest-pr-3809`
- Fixing a GitHub issue (e.g., `pnpm worktree:new fix/issue-123`) → worktree at `~/dev/readest-fix-issue-123`
- Implementing a feature request (e.g., `pnpm worktree:new feat/my-feature`) → worktree at `~/dev/readest-feat-my-feature`
Worktree directory convention: `readest-<name>` in the parent of the repo root (`~/dev/`), with slashes replaced by dashes.
Use `pnpm worktree:rm <branch-name|pr-number>` to clean up when done.
**Why:** Keeps the current bare repo branch untouched. Each task gets its own isolated workspace with submodules, dependencies, env files, and vendor assets already set up.
**How to apply:** Before touching any code for a PR review, bug fix, or feature, run `pnpm worktree:new` first. Work inside the new worktree directory (e.g., `~/dev/readest-pr-3809/apps/readest-app/`). Clean up with `pnpm worktree:rm` after merging or finishing.
@@ -0,0 +1,92 @@
---
name: Virtuoso + OverlayScrollbars pattern
description: How to integrate OverlayScrollbars with react-virtuoso for overlay scrollbars on Android/iOS webviews
type: reference
originSessionId: 9da59a46-3dff-4a77-b7a4-8de4d07297b6
---
Virtuoso manages its own internal scroller. On Android WebView (and similar) native scrollbars auto-hide, so users see no scrollbar. The fix: wrap Virtuoso with OverlayScrollbars using the `useOverlayScrollbars` hook — **not** the `OverlayScrollbarsComponent`.
## Migration from `customScrollParent`
The previous approach used `customScrollParent` to let an outer `OverlayScrollbarsComponent` own the scroll. This was replaced: Virtuoso now owns its own scroller, and OverlayScrollbars wraps it. This means:
- Remove `customScrollParent` prop from Virtuoso/VirtuosoGrid
- Remove the outer `OverlayScrollbarsComponent` wrapper
- Use `scrollerRef` instead to capture Virtuoso's scroller element
- If the parent needs the scroller ref (e.g. for pull-to-refresh, scroll save/restore), expose it via a callback prop like `onScrollerRef`
## Boilerplate
```tsx
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import 'overlayscrollbars/overlayscrollbars.css';
// Inside the component:
const osRootRef = useRef<HTMLDivElement>(null);
const [scroller, setScroller] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars({
defer: true,
options: { scrollbars: { autoHide: 'scroll' } },
events: {
initialized(instance) {
const { viewport } = instance.elements();
viewport.style.overflowX = 'var(--os-viewport-overflow-x)';
viewport.style.overflowY = 'var(--os-viewport-overflow-y)';
},
},
});
useEffect(() => {
const root = osRootRef.current;
if (scroller && root) {
initialize({ target: root, elements: { viewport: scroller } });
}
return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]);
const handleScrollerRef = useCallback((el: HTMLElement | Window | null) => {
const div = el instanceof HTMLElement ? el : null;
setScroller(div);
// If parent needs the scroller (e.g. for pull-to-refresh):
onScrollerRef?.(div as HTMLDivElement | null);
}, [onScrollerRef]);
```
## JSX structure
```tsx
<div ref={osRootRef} data-overlayscrollbars-initialize='' className='h-full'>
<Virtuoso
scrollerRef={handleScrollerRef}
style={{ height: containerHeight }}
totalCount={items.length}
itemContent={renderItem}
overscan={200}
/>
</div>
```
For `VirtuosoGrid`, same pattern — pass `scrollerRef={handleScrollerRef}`.
## Footer spacer
When Virtuoso owns its own scroller (no `customScrollParent`), the last items may be hidden behind bottom UI (tab bars, safe area). Add a Virtuoso `Footer` component to the components config:
```tsx
const VIRTUOSO_COMPONENTS = {
List: MyListComponent,
Footer: () => <div style={{ height: 34 }} />,
};
```
## Key points
- **`useOverlayScrollbars`** hook, not `OverlayScrollbarsComponent` — the component can't share a viewport with Virtuoso
- Wrapper div needs `ref={osRootRef}` and `data-overlayscrollbars-initialize=""`
- `initialize({ target: root, elements: { viewport: scroller } })` tells OverlayScrollbars to use Virtuoso's existing scroller as its viewport (no new DOM element)
- The `initialized` event **must** restore overflow CSS vars (`--os-viewport-overflow-x/y`) so OverlayScrollbars doesn't fight Virtuoso's scroll management
- No custom Scroller component needed — `scrollerRef` replaces the old `Scroller` component pattern (e.g. `TOCScroller` was removed)
## Used in
- `src/app/library/components/Bookshelf.tsx` — library grid/list with parent scroller exposure for pull-to-refresh and scroll save/restore
- `src/app/reader/components/sidebar/TOCView.tsx` — sidebar TOC (self-contained, no parent scroller needed)
+1
View File
@@ -9,6 +9,7 @@
# testing
/coverage
.test-sandbox-node/
.vitest-attachments/
# next.js
/.next/
+9 -4
View File
@@ -56,6 +56,15 @@ pnpm clippy:check # Lint Rust code (src-tauri)
Platform-specific code lives in `src-tauri/src/{macos,windows,android,ios}/`. Custom Tauri plugins are in `src-tauri/plugins/`.
## Git Worktrees
Always use `pnpm worktree:new <branch-name|pr-number>` to create worktrees. Never use `git worktree add` directly — the script handles submodule initialization (simplecc WASM, foliate-js), dependency installation, `.env` copying, vendor assets, and Tauri gen symlinks that are required for lint and tests to pass.
```bash
pnpm worktree:new feat/my-feature # New branch from origin/main
pnpm worktree:new 3837 # Checkout PR #3837 with push access to fork
```
## Project Rules
Rules are in `.claude/rules/`: test-first, typescript, verification.
@@ -68,10 +77,6 @@ See [docs/i18n.md](docs/i18n.md) for the key-as-content translation approach, `s
See [docs/safe-area-insets.md](docs/safe-area-insets.md) for rules on handling top/bottom insets for UI elements near screen edges.
## Web Browsing & QA (gstack)
For all web browsing, QA testing, and site interaction, use the `/browse` skill from gstack. **Never use `mcp__claude-in-chrome__*` tools directly.**
Available gstack skills:
- `/plan-ceo-review` — CEO/founder-mode plan review
+209
View File
@@ -0,0 +1,209 @@
## Adding a Config to `ViewSettings`
`ViewSettings` is the per-book view state (layout, fonts, colors, TTS, etc.) composed from several sub-interfaces defined in `src/types/book.ts`. A matching `globalViewSettings` lives on `SystemSettings` and acts as the default for every book. The per-book value is derived by merging the global defaults with any overrides stored on the book's `BookConfig`.
This doc covers how to plumb a new config through the three layers:
1. **Types**`src/types/book.ts`
2. **Defaults**`src/services/constants.ts` and `src/services/settingsService.ts`
3. **Read/write** — components via `saveViewSettings` from `src/helpers/settings.ts`
### Pick a Pattern
**Pattern A — add a field to an existing sub-interface.** Use when the new option belongs to an existing bundle (`BookLayout`, `BookStyle`, `BookFont`, `ViewConfig`, `TTSConfig`, etc.).
**Pattern B — introduce a new sub-interface.** Use when several related fields cluster together, or when a single field is semantically its own concept (e.g. `ParagraphModeConfig`, `ViewSettingsConfig`). Then extend `ViewSettings` with it.
Both patterns follow the same three-layer flow. The only difference is whether you reuse an existing `DEFAULT_*` constant or add a new one.
### Step 1 — Declare the Type
**Pattern A** — add a required field to the sub-interface that owns this concern:
```ts
// src/types/book.ts
export interface ViewConfig {
// ...existing fields
myNewToggle: boolean;
}
```
**Pattern B** — define a new interface and extend `ViewSettings`:
```ts
// src/types/book.ts
export interface ViewSettingsConfig {
isGlobal: boolean;
}
export interface ViewSettings
extends
BookLayout,
BookStyle,
// ...other bundles
ViewSettingsConfig {}
```
Fields should be **required**, not optional. Optional fields make downstream code defensive. Provide a sensible default in Step 2 instead.
### Step 2 — Provide a Default
Every field in `ViewSettings` must have a default, otherwise `getDefaultViewSettings()` produces an incomplete object.
**Pattern A** — add the value to the existing `DEFAULT_*` constant:
```ts
// src/services/constants.ts
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
// ...existing defaults
myNewToggle: false,
};
```
**Pattern B** — add a `DEFAULT_*_CONFIG` constant for your new bundle, then register it in `getDefaultViewSettings`:
```ts
// src/services/constants.ts
export const DEFAULT_VIEW_SETTINGS_CONFIG: ViewSettingsConfig = {
isGlobal: true,
};
```
```ts
// src/services/settingsService.ts
export function getDefaultViewSettings(ctx: Context): ViewSettings {
return {
...DEFAULT_BOOK_LAYOUT,
...DEFAULT_BOOK_STYLE,
// ...other bundles
...DEFAULT_VIEW_SETTINGS_CONFIG,
// platform overrides go last so they win
...(ctx.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}),
...(ctx.isEink ? DEFAULT_EINK_VIEW_SETTINGS : {}),
...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}),
};
}
```
#### Platform Overrides
To tweak the default on mobile, e-ink, or CJK locales, add the field to the matching `Partial<ViewSettings>` constant (`DEFAULT_MOBILE_VIEW_SETTINGS`, `DEFAULT_EINK_VIEW_SETTINGS`, `DEFAULT_CJK_VIEW_SETTINGS`). These are spread after the base defaults in `getDefaultViewSettings`, so they override them.
#### Migration
Old `settings.json` files on disk won't have your new field. `loadSettings` merges the stored blob over fresh defaults:
```ts
settings.globalViewSettings = {
...getDefaultViewSettings(ctx),
...settings.globalViewSettings,
};
```
So existing users pick up your default automatically — no explicit migration is needed for adding a field. Only bump `SYSTEM_SETTINGS_VERSION` if you are reshaping existing data.
### Step 3 — Read and Write from Components
Read the current value by preferring the per-book settings, falling back to the global:
```tsx
const { settings } = useSettingsStore();
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
```
Write via `saveViewSettings` — never mutate the store directly. The helper handles the global-vs-per-book routing, persists to disk, and re-applies styles when needed.
```tsx
import { saveViewSettings } from '@/helpers/settings';
const [myNewToggle, setMyNewToggle] = useState(viewSettings.myNewToggle);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'myNewToggle', myNewToggle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [myNewToggle]);
```
The `useEffect`-on-local-state pattern is the established convention in `LayoutPanel`, `ControlPanel`, `ColorPanel`, etc. It keeps the UI responsive and batches store updates until the user stops interacting.
#### Signature
```ts
saveViewSettings<K extends keyof ViewSettings>(
envConfig,
bookKey,
key: K,
value: ViewSettings[K],
skipGlobal = false, // true → only update this book's settings
applyStyles = true, // false → don't re-run style recomputation
)
```
**Global vs. per-book routing.** `saveViewSettings` inspects `viewSettings.isGlobal` on the target book. When `true` (the default), it writes to `globalViewSettings`, loops through every open book, and saves to disk. When `false`, it writes only to the one book's config.
**Skip global.** Pass `skipGlobal=true` when the setting is meta — i.e. it describes the settings system itself, not book content. The canonical case is toggling `isGlobal` from `DialogMenu`: you want the scope flag to live on the specific book without propagating it to every other book.
```tsx
saveViewSettings(envConfig, bookKey, 'isGlobal', !isSettingsGlobal, true, false);
```
**Skip styles.** Pass `applyStyles=false` for options that don't affect CSS rendering (toggles, flags, metadata). This avoids an unnecessary `renderer.setStyles` call.
### Step 4 — Support Reset
If your field should be resettable from the panel menu, register a setter in the panel's `handleReset` via `useResetViewSettings`:
```tsx
const resetToDefaults = useResetViewSettings();
const handleReset = () => {
resetToDefaults({
myNewToggle: setMyNewToggle,
// ...other setters
});
};
```
The hook resolves the default by reading from `getDefaultViewSettings(ctx)` and calls each provided setter with that value, which then fires your `useEffect` and persists the change.
### Step 5 — Register in the Command Palette
If your setting has a visible row in a panel, register it in the matching `*PanelItems` array in `src/services/commandRegistry.ts`. This wires it into the command-palette fuzzy search so users can jump straight to it.
```ts
// src/services/commandRegistry.ts
const layoutPanelItems = [
// ...existing entries
{
id: 'settings.layout.myNewToggle',
labelKey: _('My New Toggle'),
keywords: ['search', 'terms', 'for', 'discoverability'],
section: 'Paragraph',
},
];
```
- `id` must match the `data-setting-id` attribute on the panel row. The palette uses it to scroll/highlight the target control.
- `labelKey` uses `stubTranslation` (imported as `_`) so the extractor picks it up — the same string that appears in the panel.
- `keywords` broadens fuzzy-search hits beyond the label; include synonyms, related jargon, and the panel section name.
- `section` groups the entry in the palette results (matches the panel's sub-header: `Layout`, `Paragraph`, `Page`, `Header & Footer`, etc.).
Skip this step only for settings that don't surface as a user-visible row (hidden toggles, flags used by other settings).
### Don'ts
- **Don't make the field optional** just to skip providing a default. Add a default in Step 2 instead.
- **Don't mutate `settings.globalViewSettings` directly** in a component — `saveViewSettings` already handles global propagation when `isGlobal` is true.
- **Don't bump `SYSTEM_SETTINGS_VERSION`** for a plain additive field. The load-time merge handles it.
### Minimal Checklist
- [ ] Field or new interface added in `src/types/book.ts`
- [ ] Default value in `src/services/constants.ts`
- [ ] New `DEFAULT_*_CONFIG` spread into `getDefaultViewSettings` (Pattern B only)
- [ ] Optional mobile/eink/CJK override in the matching `Partial<ViewSettings>` constant
- [ ] Read via `getViewSettings(bookKey) || settings.globalViewSettings`
- [ ] Write via `saveViewSettings(envConfig, bookKey, 'key', value)`
- [ ] Reset setter wired into `useResetViewSettings` if the panel has a reset menu
- [ ] Command-palette entry added to the matching `*PanelItems` array in `src/services/commandRegistry.ts`, with an `id` that matches the panel row's `data-setting-id`
+14 -12
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.10.4",
"version": "0.10.6",
"private": true,
"type": "module",
"scripts": {
@@ -16,9 +16,9 @@
"build-tauri": "dotenv -e .env.tauri -- next build",
"i18n:extract": "i18next-scanner --config i18next-scanner.config.cjs",
"lint": "tsgo --noEmit && biome check .",
"test": "dotenv -e .env -e .env.test.local vitest",
"test": "dotenv -e .env -e .env.test.local -- vitest",
"test:coverage": "dotenv -e .env -e .env.test.local -- vitest run --coverage",
"test:browser": "vitest --config vitest.browser.config.mts --watch=false",
"test:browser": "dotenv -e .env -e .env.test.local -- vitest run --config vitest.browser.config.mts",
"test:tauri": "bash scripts/test-tauri.sh",
"test:pr:web": "pnpm test -- --watch=false && pnpm test:browser",
"test:pr:tauri": "bash scripts/test-tauri.sh",
@@ -66,7 +66,9 @@
"check:translations": "count=$(grep -rno '__STRING_NOT_TRANSLATED__' public/locales/* | wc -l); if [ \"$count\" -gt 0 ]; then echo '❌ Untranslated strings found!'; exit 1; else echo '✅ All strings translated.'; fi",
"check:lookbehind-regex": "count=$(grep -rnoE '\\(\\?<[!=]' .next/static/chunks/* out/_next/static/chunks/* | wc -l); if [ \"$count\" -gt 0 ]; then echo '❌ Lookbehind regex found in output!'; exit 1; else echo '✅ No lookbehind regex found.'; fi",
"check:all": "pnpm check:translations && pnpm check:lookbehind-regex",
"build-check": "pnpm build && pnpm build-web && pnpm check:all"
"build-check": "pnpm build && pnpm build-web && pnpm check:all",
"worktree:new": "pnpm exec tsx scripts/worktree-new.ts",
"worktree:rm": "pnpm exec tsx scripts/worktree-rm.ts"
},
"dependencies": {
"@ai-sdk/react": "^3.0.49",
@@ -80,7 +82,7 @@
"@emnapi/runtime": "^1.7.1",
"@fabianlars/tauri-plugin-oauth": "2",
"@napi-rs/wasm-runtime": "^1.1.1",
"@opennextjs/cloudflare": "^1.17.3",
"@opennextjs/cloudflare": "^1.19.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -146,15 +148,15 @@
"lunr": "^2.3.9",
"marked": "^15.0.12",
"nanoid": "^5.1.6",
"next": "16.2.2",
"next": "16.2.3",
"next-view-transitions": "^0.3.5",
"nunjucks": "^3.2.4",
"overlayscrollbars": "^2.11.4",
"overlayscrollbars-react": "^0.5.6",
"posthog-js": "^1.246.0",
"react": "19.2.4",
"react": "19.2.5",
"react-color": "^2.19.3",
"react-dom": "19.2.4",
"react-dom": "19.2.5",
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
"react-responsive": "^10.0.0",
@@ -196,7 +198,7 @@
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260312.1",
"@vitejs/plugin-react": "^5.1.1",
"@vitejs/plugin-rsc": "^0.5.21",
"@vitejs/plugin-rsc": "^0.5.23",
"@vitest/browser-playwright": "^4.0.18",
"@vitest/browser-webdriverio": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18",
@@ -220,15 +222,15 @@
"postcss-cli": "^11.0.0",
"postcss-nested": "^7.0.2",
"raw-loader": "^4.0.2",
"react-server-dom-webpack": "^19.2.4",
"react-server-dom-webpack": "^19.2.5",
"serwist": "^9.3.0",
"tailwindcss": "^3.4.18",
"typescript": "^5.7.2",
"vinext": "^0.0.21",
"vite": "^7.3.1",
"vite": "^7.3.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.0.18",
"wrangler": "^4.77.0"
"wrangler": "^4.81.1"
},
"browserslist": [
"chrome 92",
@@ -1235,5 +1235,13 @@
"Hardcover progress sync failed: {{error}}": "فشلت مزامنة تقدم Hardcover: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "الإبقاء على معرف المصدر الحالي",
"Toggle Toolbar": "تبديل شريط الأدوات"
"Toggle Toolbar": "تبديل شريط الأدوات",
"Split Hyphens": "تقسيم الواصلات",
"Apply Theme Colors to PDF": "تطبيق ألوان السمة على PDF",
"Name": "الاسم",
"Unavailable": "غير متاح",
"Remove": "إزالة",
"Edit OPDS Catalog": "تعديل كتالوج OPDS",
"Save Changes": "حفظ التغييرات",
"Use Book Layout": "استخدام تخطيط الكتاب"
}
@@ -1183,5 +1183,13 @@
"Hardcover progress sync failed: {{error}}": "Hardcover অগ্রগতি সিঙ্ক ব্যর্থ: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "বিদ্যমান উৎস শনাক্তকারী রাখুন",
"Toggle Toolbar": "টুলবার টগল করুন"
"Toggle Toolbar": "টুলবার টগল করুন",
"Split Hyphens": "হাইফেন বিভক্ত করুন",
"Apply Theme Colors to PDF": "PDF-এ থিম রং প্রয়োগ করুন",
"Name": "নাম",
"Unavailable": "অনুপলব্ধ",
"Remove": "সরান",
"Edit OPDS Catalog": "OPDS ক্যাটালগ সম্পাদনা",
"Save Changes": "পরিবর্তন সংরক্ষণ",
"Use Book Layout": "বইয়ের লেআউট ব্যবহার করুন"
}
@@ -1170,5 +1170,13 @@
"Hardcover progress sync failed: {{error}}": "Hardcover ཀློག་འགྲོས་ཟླ་སྒྲིག་མི་ཚོགས: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "ཡོད་བཞིན་པའི་འབྱུང་ཁུངས་ཐོབ་ཐང་གཞག",
"Toggle Toolbar": "ལག་ཆའི་ཚན་བར་བརྗེ་བ།"
"Toggle Toolbar": "ལག་ཆའི་ཚན་བར་བརྗེ་བ།",
"Split Hyphens": "སྦྲེལ་རྟགས་གཅད་པ",
"Apply Theme Colors to PDF": "PDF ལ་བརྗོད་དོན་ཚོས་གཞི་སྤྱོད་པ",
"Name": "མིང",
"Unavailable": "རག་མི་ཐུབ",
"Remove": "བསུབ།",
"Edit OPDS Catalog": "OPDS དཀར་ཆག་རྩོམ་སྒྲིག",
"Save Changes": "བསྒྱུར་བཅོས་ཉར་ཚགས།",
"Use Book Layout": "དཔེ་ཆའི་བཀོད་སྒྲིག་སྤྱོད་པ"
}
@@ -1183,5 +1183,13 @@
"Hardcover progress sync failed: {{error}}": "Hardcover-Fortschrittssynchronisierung fehlgeschlagen: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Vorhandene Quellkennung beibehalten",
"Toggle Toolbar": "Symbolleiste umschalten"
"Toggle Toolbar": "Symbolleiste umschalten",
"Split Hyphens": "Bindestriche trennen",
"Apply Theme Colors to PDF": "Designfarben auf PDF anwenden",
"Name": "Name",
"Unavailable": "Nicht verfügbar",
"Remove": "Entfernen",
"Edit OPDS Catalog": "OPDS-Katalog bearbeiten",
"Save Changes": "Änderungen speichern",
"Use Book Layout": "Buchlayout verwenden"
}
@@ -1183,5 +1183,13 @@
"Hardcover progress sync failed: {{error}}": "Αποτυχία συγχρονισμού προόδου Hardcover: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Διατήρηση υπάρχοντος αναγνωριστικού πηγής",
"Toggle Toolbar": "Εναλλαγή γραμμής εργαλείων"
"Toggle Toolbar": "Εναλλαγή γραμμής εργαλείων",
"Split Hyphens": "Διαχωρισμός παύλων",
"Apply Theme Colors to PDF": "Εφαρμογή χρωμάτων θέματος σε PDF",
"Name": "Όνομα",
"Unavailable": "Μη διαθέσιμο",
"Remove": "Αφαίρεση",
"Edit OPDS Catalog": "Επεξεργασία καταλόγου OPDS",
"Save Changes": "Αποθήκευση αλλαγών",
"Use Book Layout": "Χρήση διάταξης βιβλίου"
}
@@ -1196,5 +1196,13 @@
"Hardcover progress sync failed: {{error}}": "Error al sincronizar progreso de Hardcover: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Mantener el identificador de origen existente",
"Toggle Toolbar": "Alternar barra de herramientas"
"Toggle Toolbar": "Alternar barra de herramientas",
"Split Hyphens": "Dividir guiones",
"Apply Theme Colors to PDF": "Aplicar colores del tema al PDF",
"Name": "Nombre",
"Unavailable": "No disponible",
"Remove": "Eliminar",
"Edit OPDS Catalog": "Editar catálogo OPDS",
"Save Changes": "Guardar cambios",
"Use Book Layout": "Usar el diseño del libro"
}
@@ -1183,5 +1183,13 @@
"Hardcover progress sync failed: {{error}}": "همگام‌سازی پیشرفت Hardcover ناموفق: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "حفظ شناسه منبع موجود",
"Toggle Toolbar": "تغییر نوار ابزار"
"Toggle Toolbar": "تغییر نوار ابزار",
"Split Hyphens": "تقسیم خط‌تیره‌ها",
"Apply Theme Colors to PDF": "اعمال رنگ‌های پوسته روی PDF",
"Name": "نام",
"Unavailable": "ناموجود",
"Remove": "حذف",
"Edit OPDS Catalog": "ویرایش کاتالوگ OPDS",
"Save Changes": "ذخیره تغییرات",
"Use Book Layout": "استفاده از چیدمان کتاب"
}
@@ -1196,5 +1196,13 @@
"Hardcover progress sync failed: {{error}}": "Échec de la synchronisation de la progression Hardcover : {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Conserver l'identifiant source existant",
"Toggle Toolbar": "Basculer la barre d'outils"
"Toggle Toolbar": "Basculer la barre d'outils",
"Split Hyphens": "Séparer les traits d'union",
"Apply Theme Colors to PDF": "Appliquer les couleurs du thème au PDF",
"Name": "Nom",
"Unavailable": "Indisponible",
"Remove": "Supprimer",
"Edit OPDS Catalog": "Modifier le catalogue OPDS",
"Save Changes": "Enregistrer les modifications",
"Use Book Layout": "Utiliser la mise en page du livre"
}
@@ -1196,5 +1196,13 @@
"Hardcover progress sync failed: {{error}}": "סנכרון התקדמות Hardcover נכשל: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "שמור על מזהה המקור הקיים",
"Toggle Toolbar": "הצג/הסתר סרגל כלים"
"Toggle Toolbar": "הצג/הסתר סרגל כלים",
"Split Hyphens": "פיצול מקפים",
"Apply Theme Colors to PDF": "החל צבעי ערכת נושא על PDF",
"Name": "שם",
"Unavailable": "לא זמין",
"Remove": "הסר",
"Edit OPDS Catalog": "עריכת קטלוג OPDS",
"Save Changes": "שמור שינויים",
"Use Book Layout": "השתמש בפריסת הספר"
}
@@ -1183,5 +1183,13 @@
"Hardcover progress sync failed: {{error}}": "Hardcover प्रगति सिंक विफल: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "मौजूदा स्रोत पहचानकर्ता रखें",
"Toggle Toolbar": "टूलबार टॉगल करें"
"Toggle Toolbar": "टूलबार टॉगल करें",
"Split Hyphens": "हाइफ़न विभाजित करें",
"Apply Theme Colors to PDF": "PDF पर थीम रंग लागू करें",
"Name": "नाम",
"Unavailable": "अनुपलब्ध",
"Remove": "हटाएँ",
"Edit OPDS Catalog": "OPDS कैटलॉग संपादित करें",
"Save Changes": "परिवर्तन सहेजें",
"Use Book Layout": "पुस्तक का लेआउट उपयोग करें"
}
@@ -1183,5 +1183,13 @@
"Sunset": "Naplemente",
"Reveal in Finder": "Megjelenítés a Finderben",
"Reveal in File Explorer": "Megjelenítés a Fájlkezelőben",
"Reveal in Folder": "Megjelenítés a mappában"
"Reveal in Folder": "Megjelenítés a mappában",
"Split Hyphens": "Kötőjelek felosztása",
"Apply Theme Colors to PDF": "Téma színeinek alkalmazása PDF-re",
"Name": "Név",
"Unavailable": "Nem elérhető",
"Remove": "Eltávolítás",
"Edit OPDS Catalog": "OPDS katalógus szerkesztése",
"Save Changes": "Módosítások mentése",
"Use Book Layout": "Könyv elrendezésének használata"
}
@@ -1170,5 +1170,13 @@
"Hardcover progress sync failed: {{error}}": "Sinkronisasi progres Hardcover gagal: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Pertahankan pengidentifikasi sumber yang ada",
"Toggle Toolbar": "Alihkan Bilah Alat"
"Toggle Toolbar": "Alihkan Bilah Alat",
"Split Hyphens": "Pisahkan Tanda Hubung",
"Apply Theme Colors to PDF": "Terapkan Warna Tema ke PDF",
"Name": "Nama",
"Unavailable": "Tidak Tersedia",
"Remove": "Hapus",
"Edit OPDS Catalog": "Edit Katalog OPDS",
"Save Changes": "Simpan Perubahan",
"Use Book Layout": "Gunakan tata letak buku"
}
@@ -1196,5 +1196,13 @@
"Hardcover progress sync failed: {{error}}": "Sincronizzazione progressi Hardcover non riuscita: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Mantieni l'identificatore sorgente esistente",
"Toggle Toolbar": "Mostra/nascondi barra degli strumenti"
"Toggle Toolbar": "Mostra/nascondi barra degli strumenti",
"Split Hyphens": "Dividi trattini",
"Apply Theme Colors to PDF": "Applica colori tema al PDF",
"Name": "Nome",
"Unavailable": "Non disponibile",
"Remove": "Rimuovi",
"Edit OPDS Catalog": "Modifica catalogo OPDS",
"Save Changes": "Salva modifiche",
"Use Book Layout": "Usa layout del libro"
}
@@ -1170,5 +1170,13 @@
"Hardcover progress sync failed: {{error}}": "Hardcover進捗の同期に失敗: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "既存のソース識別子を保持",
"Toggle Toolbar": "ツールバーの表示切替"
"Toggle Toolbar": "ツールバーの表示切替",
"Split Hyphens": "ハイフンで分割",
"Apply Theme Colors to PDF": "テーマカラーをPDFに適用",
"Name": "名前",
"Unavailable": "利用不可",
"Remove": "削除",
"Edit OPDS Catalog": "OPDSカタログを編集",
"Save Changes": "変更を保存",
"Use Book Layout": "書籍のレイアウトを使用"
}
@@ -1170,5 +1170,13 @@
"Hardcover progress sync failed: {{error}}": "Hardcover 진행 상황 동기화 실패: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "기존 소스 식별자 유지",
"Toggle Toolbar": "도구 모음 전환"
"Toggle Toolbar": "도구 모음 전환",
"Split Hyphens": "하이픈 분리",
"Apply Theme Colors to PDF": "PDF에 테마 색상 적용",
"Name": "이름",
"Unavailable": "사용 불가",
"Remove": "삭제",
"Edit OPDS Catalog": "OPDS 카탈로그 편집",
"Save Changes": "변경 사항 저장",
"Use Book Layout": "책 레이아웃 사용"
}
@@ -1170,5 +1170,13 @@
"Hardcover progress sync failed: {{error}}": "Penyegerakan kemajuan Hardcover gagal: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Kekalkan pengecam sumber sedia ada",
"Toggle Toolbar": "Togol Bar Alat"
"Toggle Toolbar": "Togol Bar Alat",
"Split Hyphens": "Pisahkan Sempang",
"Apply Theme Colors to PDF": "Gunakan Warna Tema pada PDF",
"Name": "Nama",
"Unavailable": "Tidak Tersedia",
"Remove": "Alih keluar",
"Edit OPDS Catalog": "Edit Katalog OPDS",
"Save Changes": "Simpan Perubahan",
"Use Book Layout": "Guna susun atur buku"
}
@@ -1183,5 +1183,13 @@
"Hardcover progress sync failed: {{error}}": "Synchronisatie van Hardcover-voortgang mislukt: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Bestaande bronidentificatie behouden",
"Toggle Toolbar": "Werkbalk in-/uitschakelen"
"Toggle Toolbar": "Werkbalk in-/uitschakelen",
"Split Hyphens": "Afbreekstreepjes splitsen",
"Apply Theme Colors to PDF": "Themakleuren toepassen op PDF",
"Name": "Naam",
"Unavailable": "Niet beschikbaar",
"Remove": "Verwijderen",
"Edit OPDS Catalog": "OPDS-catalogus bewerken",
"Save Changes": "Wijzigingen opslaan",
"Use Book Layout": "Boekindeling gebruiken"
}
@@ -1209,5 +1209,13 @@
"Hardcover progress sync failed: {{error}}": "Synchronizacja postępu Hardcover nie powiodła się: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Zachowaj istniejący identyfikator źródła",
"Toggle Toolbar": "Przełącz pasek narzędzi"
"Toggle Toolbar": "Przełącz pasek narzędzi",
"Split Hyphens": "Dziel łączniki",
"Apply Theme Colors to PDF": "Zastosuj kolory motywu do PDF",
"Name": "Nazwa",
"Unavailable": "Niedostępne",
"Remove": "Usuń",
"Edit OPDS Catalog": "Edytuj katalog OPDS",
"Save Changes": "Zapisz zmiany",
"Use Book Layout": "Użyj układu książki"
}
@@ -1196,5 +1196,13 @@
"Hardcover progress sync failed: {{error}}": "Falha na sincronização de progresso do Hardcover: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Manter o identificador de origem existente",
"Toggle Toolbar": "Alternar barra de ferramentas"
"Toggle Toolbar": "Alternar barra de ferramentas",
"Split Hyphens": "Dividir hífens",
"Apply Theme Colors to PDF": "Aplicar cores do tema ao PDF",
"Name": "Nome",
"Unavailable": "Indisponível",
"Remove": "Remover",
"Edit OPDS Catalog": "Editar catálogo OPDS",
"Save Changes": "Salvar alterações",
"Use Book Layout": "Usar layout do livro"
}
@@ -1196,5 +1196,13 @@
"Hardcover progress sync failed: {{error}}": "Sincronizarea progresului Hardcover a eșuat: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Păstrați identificatorul sursă existent",
"Toggle Toolbar": "Comutare bară de instrumente"
"Toggle Toolbar": "Comutare bară de instrumente",
"Split Hyphens": "Desparte cratimele",
"Apply Theme Colors to PDF": "Aplică culorile temei la PDF",
"Name": "Nume",
"Unavailable": "Indisponibil",
"Remove": "Elimină",
"Edit OPDS Catalog": "Editare catalog OPDS",
"Save Changes": "Salvare modificări",
"Use Book Layout": "Folosește aspectul cărții"
}
@@ -1209,5 +1209,13 @@
"Hardcover progress sync failed: {{error}}": "Ошибка синхронизации прогресса Hardcover: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Сохранить существующий идентификатор источника",
"Toggle Toolbar": "Переключить панель инструментов"
"Toggle Toolbar": "Переключить панель инструментов",
"Split Hyphens": "Разделить дефисы",
"Apply Theme Colors to PDF": "Применить цвета темы к PDF",
"Name": "Имя",
"Unavailable": "Недоступно",
"Remove": "Удалить",
"Edit OPDS Catalog": "Редактировать каталог OPDS",
"Save Changes": "Сохранить изменения",
"Use Book Layout": "Использовать макет книги"
}
@@ -1183,5 +1183,13 @@
"Hardcover progress sync failed: {{error}}": "Hardcover ප්‍රගති සමමුහුර්තකරණය අසාර්ථකයි: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "පවතින මූලාශ්‍ර හඳුනාගැනීම තබාගන්න",
"Toggle Toolbar": "මෙවලම් තීරුව ටොගල් කරන්න"
"Toggle Toolbar": "මෙවලම් තීරුව ටොගල් කරන්න",
"Split Hyphens": "හයිෆන් බෙදන්න",
"Apply Theme Colors to PDF": "PDF වෙත තේමා වර්ණ යොදන්න",
"Name": "නම",
"Unavailable": "ලබාගත නොහැක",
"Remove": "ඉවත් කරන්න",
"Edit OPDS Catalog": "OPDS නාමාවලිය සංස්කරණය",
"Save Changes": "වෙනස්කම් සුරකින්න",
"Use Book Layout": "පොතේ සැකැස්ම භාවිත කරන්න"
}
@@ -1209,5 +1209,13 @@
"Hardcover progress sync failed: {{error}}": "Sinhronizacija napredka Hardcover ni uspela: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Ohrani obstoječi identifikator vira",
"Toggle Toolbar": "Preklopi orodno vrstico"
"Toggle Toolbar": "Preklopi orodno vrstico",
"Split Hyphens": "Razdeli vezaje",
"Apply Theme Colors to PDF": "Uporabi barve teme za PDF",
"Name": "Ime",
"Unavailable": "Nedosegljivo",
"Remove": "Odstrani",
"Edit OPDS Catalog": "Uredi katalog OPDS",
"Save Changes": "Shrani spremembe",
"Use Book Layout": "Uporabi postavitev knjige"
}
@@ -1183,5 +1183,13 @@
"Hardcover progress sync failed: {{error}}": "Synkronisering av Hardcover-framsteg misslyckades: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Behåll befintlig källidentifierare",
"Toggle Toolbar": "Visa/dölj verktygsfältet"
"Toggle Toolbar": "Visa/dölj verktygsfältet",
"Split Hyphens": "Dela bindestreck",
"Apply Theme Colors to PDF": "Tillämpa temafärger på PDF",
"Name": "Namn",
"Unavailable": "Inte tillgängligt",
"Remove": "Ta bort",
"Edit OPDS Catalog": "Redigera OPDS-katalog",
"Save Changes": "Spara ändringar",
"Use Book Layout": "Använd bokens layout"
}
@@ -1183,5 +1183,13 @@
"Hardcover progress sync failed: {{error}}": "Hardcover முன்னேற்ற ஒத்திசைவு தோல்வி: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "ஏற்கனவே உள்ள மூல அடையாளங்காட்டியை வைத்திருக்கவும்",
"Toggle Toolbar": "கருவிப்பட்டியை நிலைமாற்று"
"Toggle Toolbar": "கருவிப்பட்டியை நிலைமாற்று",
"Split Hyphens": "ஹைபன்களைப் பிரி",
"Apply Theme Colors to PDF": "PDF-க்கு தீம் நிறங்களைப் பயன்படுத்து",
"Name": "பெயர்",
"Unavailable": "கிடைக்கவில்லை",
"Remove": "நீக்கு",
"Edit OPDS Catalog": "OPDS பட்டியலைத் திருத்து",
"Save Changes": "மாற்றங்களைச் சேமி",
"Use Book Layout": "புத்தகத்தின் அமைப்பைப் பயன்படுத்து"
}
@@ -1170,5 +1170,13 @@
"Hardcover progress sync failed: {{error}}": "การซิงค์ความคืบหน้า Hardcover ล้มเหลว: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "เก็บตัวระบุแหล่งที่มาที่มีอยู่",
"Toggle Toolbar": "สลับแถบเครื่องมือ"
"Toggle Toolbar": "สลับแถบเครื่องมือ",
"Split Hyphens": "แยกยัติภังค์",
"Apply Theme Colors to PDF": "ใช้สีธีมกับ PDF",
"Name": "ชื่อ",
"Unavailable": "ไม่พร้อมใช้งาน",
"Remove": "ลบ",
"Edit OPDS Catalog": "แก้ไขแคตตาล็อก OPDS",
"Save Changes": "บันทึกการเปลี่ยนแปลง",
"Use Book Layout": "ใช้เลย์เอาต์ของหนังสือ"
}
@@ -1183,5 +1183,13 @@
"Hardcover progress sync failed: {{error}}": "Hardcover ilerleme senkronizasyonu başarısız: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Mevcut kaynak tanımlayıcısını koru",
"Toggle Toolbar": "Araç Çubuğunu Aç/Kapat"
"Toggle Toolbar": "Araç Çubuğunu Aç/Kapat",
"Split Hyphens": "Tireleri Böl",
"Apply Theme Colors to PDF": "Tema Renklerini PDF'ye Uygula",
"Name": "Ad",
"Unavailable": "Kullanılamaz",
"Remove": "Kaldır",
"Edit OPDS Catalog": "OPDS Kataloğunu Düzenle",
"Save Changes": "Değişiklikleri Kaydet",
"Use Book Layout": "Kitap düzenini kullan"
}
@@ -1209,5 +1209,13 @@
"Hardcover progress sync failed: {{error}}": "Помилка синхронізації прогресу Hardcover: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Зберегти наявний ідентифікатор джерела",
"Toggle Toolbar": "Перемкнути панель інструментів"
"Toggle Toolbar": "Перемкнути панель інструментів",
"Split Hyphens": "Розділити дефіси",
"Apply Theme Colors to PDF": "Застосувати кольори теми до PDF",
"Name": "Ім'я",
"Unavailable": "Недоступно",
"Remove": "Видалити",
"Edit OPDS Catalog": "Редагувати каталог OPDS",
"Save Changes": "Зберегти зміни",
"Use Book Layout": "Використовувати макет книги"
}
@@ -1170,5 +1170,13 @@
"Hardcover progress sync failed: {{error}}": "Đồng bộ tiến trình Hardcover thất bại: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Giữ mã định danh nguồn hiện tại",
"Toggle Toolbar": "Bật/tắt thanh công cụ"
"Toggle Toolbar": "Bật/tắt thanh công cụ",
"Split Hyphens": "Tách gạch nối",
"Apply Theme Colors to PDF": "Áp dụng màu chủ đề cho PDF",
"Name": "Tên",
"Unavailable": "Không khả dụng",
"Remove": "Xóa",
"Edit OPDS Catalog": "Chỉnh sửa danh mục OPDS",
"Save Changes": "Lưu thay đổi",
"Use Book Layout": "Sử dụng bố cục sách"
}
@@ -1170,5 +1170,13 @@
"Hardcover progress sync failed: {{error}}": "Hardcover 进度同步失败:{{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "保留现有来源标识符",
"Toggle Toolbar": "切换工具栏"
"Toggle Toolbar": "切换工具栏",
"Split Hyphens": "拆分连字符",
"Apply Theme Colors to PDF": "将主题颜色应用于 PDF",
"Name": "名称",
"Unavailable": "不可用",
"Remove": "移除",
"Edit OPDS Catalog": "编辑 OPDS 目录",
"Save Changes": "保存更改",
"Use Book Layout": "使用书籍排版"
}
@@ -1170,5 +1170,13 @@
"Hardcover progress sync failed: {{error}}": "Hardcover 進度同步失敗:{{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "保留現有來源識別碼",
"Toggle Toolbar": "切換工具列"
"Toggle Toolbar": "切換工具列",
"Split Hyphens": "拆分連字號",
"Apply Theme Colors to PDF": "將主題顏色套用至 PDF",
"Name": "名稱",
"Unavailable": "無法使用",
"Remove": "移除",
"Edit OPDS Catalog": "編輯 OPDS 目錄",
"Save Changes": "儲存變更",
"Use Book Layout": "使用書籍排版"
}
+15
View File
@@ -1,5 +1,20 @@
{
"releases": {
"0.10.6": {
"date": "2026-04-13",
"notes": [
"Library: Much faster browsing for large collections",
"Reading: Added an option to keep the book's original paragraph layout",
"Reading: Fixed page navigation for Arabic books",
"PDF: Fixed TTS reading interruptions at line breaks in PDFs",
"PDF: You can now apply your chosen theme colors to PDFs",
"Sync: Added HTTP Basic auth support for CWA",
"Sync: Improved progress sync with Hardcover.app",
"OPDS: You can now edit your registered catalogs, with better handling of download filenames",
"Speed Reading: Your reading position now syncs across devices",
"Platform: Fixed window buttons on macOS, reduced crashes on iOS"
]
},
"0.10.4": {
"date": "2026-04-06",
"notes": [
+278
View File
@@ -0,0 +1,278 @@
import { execSync, type StdioOptions } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
// Submodules skipped during worktree setup (shared via symlinks or pre-built)
const SKIPPED_SUBMODULES = [
'apps/readest-app/.claude/skills/gstack', // shared via .claude symlink
'packages/simplecc-wasm', // built assets already in public/vendor
];
const arg = process.argv[2];
if (!arg) {
console.error('Usage: pnpm worktree:new <branch-name|pr-number>');
process.exit(1);
}
const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
// Git output goes to stderr so stdout carries only the path (enables: cd $(pnpm worktree:new <arg>))
const gitStdio: StdioOptions = ['inherit', process.stderr, process.stderr];
// Fetch origin so origin/main is up to date
console.error('--- Fetching origin ---');
execSync('git fetch origin', { stdio: gitStdio, cwd: repoRoot });
let localBranch: string;
let worktreePath: string;
if (/^\d+$/.test(arg)) {
// PR number -- fetch and set up remote tracking so `git push` works (even for forks)
localBranch = `pr-${arg}`;
worktreePath = path.join(path.dirname(repoRoot), `readest-${localBranch}`);
// Get PR metadata to determine the source repo and branch
const prJson = execSync(
`gh pr view ${arg} --json headRefName,headRepositoryOwner,headRepository`,
{
encoding: 'utf8',
cwd: repoRoot,
},
);
const pr = JSON.parse(prJson) as {
headRefName: string;
headRepositoryOwner: { login: string };
headRepository: { name: string };
};
const forkOwner = pr.headRepositoryOwner.login;
const forkRepo = pr.headRepository.name;
const remoteBranch = pr.headRefName;
// Use "origin" if the PR is from the same repo, otherwise add the fork as a remote
const originUrl = execSync('git remote get-url origin', {
encoding: 'utf8',
cwd: repoRoot,
}).trim();
const isFromOrigin = originUrl.includes(`/${forkOwner}/${forkRepo}`);
const remoteName = isFromOrigin ? 'origin' : forkOwner;
if (!isFromOrigin) {
try {
execSync(`git remote get-url "${remoteName}"`, { encoding: 'utf8', cwd: repoRoot });
} catch {
execSync(`git remote add "${remoteName}" "https://github.com/${forkOwner}/${forkRepo}.git"`, {
stdio: gitStdio,
cwd: repoRoot,
});
}
}
execSync(`git fetch "${remoteName}" "${remoteBranch}:${localBranch}"`, {
stdio: gitStdio,
cwd: repoRoot,
});
execSync(`git worktree add "${worktreePath}" "${localBranch}"`, {
stdio: gitStdio,
cwd: repoRoot,
});
// Set upstream so `git push` targets the correct fork and branch.
// Use git-config directly instead of `branch --set-upstream-to` because
// the targeted fetch above doesn't create a remote-tracking ref.
execSync(`git -C "${worktreePath}" config "branch.${localBranch}.remote" "${remoteName}"`);
execSync(
`git -C "${worktreePath}" config "branch.${localBranch}.merge" "refs/heads/${remoteBranch}"`,
);
} else {
// Branch name -- slashes replaced with dashes for the directory name
localBranch = arg;
worktreePath = path.join(path.dirname(repoRoot), `readest-${arg.replace(/\//g, '-')}`);
if (fs.existsSync(worktreePath)) {
console.error(`Worktree path already exists: ${worktreePath}`);
console.error('Removing existing worktree...');
// Deinit only submodules we manage — skipped ones were never initialized
const initedSubs = execSync(
'git config --file .gitmodules --get-regexp "submodule\\..*\\.path"',
{ encoding: 'utf8', cwd: worktreePath },
)
.trim()
.split('\n')
.map((line) => line.split(/\s+/)[1]!)
.filter((p) => !SKIPPED_SUBMODULES.includes(p));
for (const sub of initedSubs) {
execSync(`git -C "${worktreePath}" submodule deinit --force -- "${sub}"`, {
stdio: gitStdio,
cwd: repoRoot,
});
}
execSync(`git worktree remove --force "${worktreePath}"`, { stdio: gitStdio, cwd: repoRoot });
}
// Check if the branch already exists
const branchExists = execSync('git branch --list --format="%(refname:short)"', {
encoding: 'utf8',
cwd: repoRoot,
})
.split('\n')
.includes(localBranch);
if (branchExists) {
execSync(`git worktree add "${worktreePath}" "${localBranch}"`, {
stdio: gitStdio,
cwd: repoRoot,
});
} else {
execSync(`git worktree add -b "${localBranch}" "${worktreePath}" origin/main`, {
stdio: gitStdio,
cwd: repoRoot,
});
}
}
// Rebase onto origin/main so the worktree starts from the latest upstream
console.error('\n--- Rebasing onto origin/main ---');
execSync('git rebase origin/main', { stdio: gitStdio, cwd: worktreePath });
// Symlink shared directories into the new worktree, pointing at the bare repo's
// git common dir so all worktrees share the same settings without duplication.
const gitCommonDir = execSync('git rev-parse --git-common-dir', {
encoding: 'utf8',
cwd: repoRoot,
}).trim();
for (const dir of ['.claude', '.local-settings']) {
const sharedDir = path.resolve(repoRoot, gitCommonDir, dir);
const newDir = path.join(worktreePath, dir);
fs.mkdirSync(sharedDir, { recursive: true });
if (!fs.existsSync(newDir)) {
// 'junction' works without elevated privileges on Windows; ignored on Unix
fs.symlinkSync(sharedDir, newDir, 'junction');
}
}
// Repoint submodule URLs to local .git/modules/ clones to avoid remote fetches.
// Submodules without a local cache fall back to the remote URL.
console.error('\n--- Initializing submodules (using local objects) ---');
const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8', cwd: repoRoot }).trim();
const absGitDir = path.resolve(repoRoot, gitDir);
const submoduleNames = execSync(
'git config --file .gitmodules --get-regexp "submodule\\..*\\.path"',
{ encoding: 'utf8', cwd: worktreePath },
)
.trim()
.split('\n')
.map((line) => {
// line: submodule.<name>.path <path>
const match = line.match(/^submodule\.(.+)\.path\s+(.+)$/);
return { name: match![1]!, subPath: match![2]! };
})
.filter(({ subPath }) => !SKIPPED_SUBMODULES.includes(subPath));
for (const { name, subPath } of submoduleNames) {
const localModuleDir = path.join(absGitDir, 'modules', subPath);
// Also check if the submodule has a full .git/ directory (cloned outside of git's modules cache)
const subGitDir = path.join(repoRoot, subPath, '.git');
let localDir: string | undefined;
if (fs.existsSync(localModuleDir)) {
localDir = localModuleDir;
console.error(` ${subPath} -> local (.git/modules)`);
} else if (fs.existsSync(subGitDir)) {
localDir = fs.statSync(subGitDir).isDirectory()
? subGitDir
: path.resolve(
path.join(repoRoot, subPath),
fs.readFileSync(subGitDir, 'utf8').replace('gitdir: ', '').trim(),
);
console.error(` ${subPath} -> local (standalone .git)`);
} else {
console.error(` ${subPath} -> remote (no local cache)`);
}
if (localDir) {
// Allow fetching any commit (not just branch tips) from the local source
execSync(`git -C "${localDir}" config uploadpack.allowAnySHA1InWant true`);
execSync(`git -C "${worktreePath}" config "submodule.${name}.url" "${localDir}"`);
}
}
for (const { subPath } of submoduleNames) {
execSync(
`git -c protocol.file.allow=always submodule update --init --recursive -- "${subPath}"`,
{ stdio: gitStdio, cwd: worktreePath },
);
}
// Restore original remote URLs so `git push` in submodules works correctly
for (const { name } of submoduleNames) {
const origUrl = execSync(`git config --file .gitmodules "submodule.${name}.url"`, {
encoding: 'utf8',
cwd: worktreePath,
}).trim();
execSync(`git -C "${worktreePath}" config "submodule.${name}.url" "${origUrl}"`);
}
// Install dependencies
console.error('\n--- Installing dependencies ---');
execSync('pnpm install', { stdio: gitStdio, cwd: worktreePath });
// Copy .env* files from the app directory to the new worktree's app directory
const appRelPath = 'apps/readest-app';
const srcAppDir = path.join(repoRoot, appRelPath);
const dstAppDir = path.join(worktreePath, appRelPath);
const envFiles = fs.readdirSync(srcAppDir).filter((f) => f.startsWith('.env'));
if (envFiles.length > 0) {
console.error(`\n--- Copying ${envFiles.length} .env* files ---`);
for (const envFile of envFiles) {
const src = path.join(srcAppDir, envFile);
const dst = path.join(dstAppDir, envFile);
if (!fs.existsSync(dst)) {
fs.copyFileSync(src, dst);
console.error(` ${envFile}`);
}
}
}
// Symlink target so the worktree shares the Rust build cache
const srcTarget = path.join(repoRoot, 'target');
const dstTarget = path.join(worktreePath, 'target');
if (fs.existsSync(srcTarget) && !fs.existsSync(dstTarget)) {
console.error('\n--- Symlinking src-tauri/target ---');
fs.symlinkSync(srcTarget, dstTarget, 'junction');
}
// Initialize Tauri Android gen directory (needs platform-specific paths regenerated)
const genDir = path.join(dstAppDir, 'src-tauri', 'gen');
const androidGenDir = path.join(genDir, 'android');
if (fs.existsSync(androidGenDir)) {
console.error('\n--- Initializing Tauri Android ---');
fs.rmSync(androidGenDir, { recursive: true });
execSync('pnpm tauri android init', { stdio: gitStdio, cwd: dstAppDir });
execSync('pnpm tauri icon ../../data/icons/readest-book.png', {
stdio: gitStdio,
cwd: dstAppDir,
});
execSync(`git checkout ${appRelPath}/src-tauri/gen/android`, {
stdio: gitStdio,
cwd: worktreePath,
});
}
// Symlink Tauri gen/apple and gen/schemas from the main worktree
for (const sub of ['apple', 'schemas', 'android/keystore.properties']) {
const src = path.join(srcAppDir, 'src-tauri', 'gen', sub);
const dst = path.join(genDir, sub);
if (fs.existsSync(src) && !fs.existsSync(dst)) {
console.error(` Symlinking src-tauri/gen/${sub}`);
fs.symlinkSync(src, dst, 'junction');
}
}
// Copy public/vendor to the new worktree (built assets not in git)
const srcVendor = path.join(srcAppDir, 'public', 'vendor');
const dstVendor = path.join(dstAppDir, 'public', 'vendor');
if (fs.existsSync(srcVendor) && !fs.existsSync(dstVendor)) {
console.error('\n--- Copying public/vendor ---');
fs.cpSync(srcVendor, dstVendor, { recursive: true });
}
// Print path to stdout -- allows: cd $(pnpm worktree:new <arg>)
process.stdout.write(worktreePath + '\n');
+51
View File
@@ -0,0 +1,51 @@
import { execSync, type StdioOptions } from 'node:child_process';
import path from 'node:path';
const SKIPPED_SUBMODULES = ['apps/readest-app/.claude/skills/gstack', 'packages/simplecc-wasm'];
const arg = process.argv[2];
if (!arg) {
console.error('Usage: pnpm worktree:rm <branch-name|pr-number>');
process.exit(1);
}
const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
const gitStdio: StdioOptions = ['inherit', process.stderr, process.stderr];
// Resolve worktree path from the argument
let dirName: string;
if (/^\d+$/.test(arg)) {
dirName = `readest-pr-${arg}`;
} else {
dirName = `readest-${arg.replace(/\//g, '-')}`;
}
const worktreePath = path.join(path.dirname(repoRoot), dirName);
// Check the worktree exists
const worktrees = execSync('git worktree list --porcelain', { encoding: 'utf8', cwd: repoRoot });
const found = worktrees.split('\n').some((line) => line === `worktree ${worktreePath}`);
if (!found) {
console.error(`error: no worktree found at ${worktreePath}`);
process.exit(1);
}
console.error(`Removing worktree: ${worktreePath}`);
// Deinit only submodules we manage — skipped ones were never initialized
const initedSubs = execSync('git config --file .gitmodules --get-regexp "submodule\\..*\\.path"', {
encoding: 'utf8',
cwd: worktreePath,
})
.trim()
.split('\n')
.map((line) => line.split(/\s+/)[1]!)
.filter((p) => !SKIPPED_SUBMODULES.includes(p));
for (const sub of initedSubs) {
execSync(`git -C "${worktreePath}" submodule deinit --force -- "${sub}"`, {
stdio: gitStdio,
cwd: repoRoot,
});
}
execSync(`git worktree remove --force "${worktreePath}"`, { stdio: gitStdio, cwd: repoRoot });
console.error('Done.');
+3
View File
@@ -78,3 +78,6 @@ tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2"
discord-rich-presence = "1.0.0"
[target.'cfg(target_os = "android")'.dependencies]
rsproperties = "0.3"
@@ -442,7 +442,6 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
if (windowInsets != null) {
val insets = windowInsets.getInsets(
WindowInsetsCompat.Type.systemBars() or
WindowInsetsCompat.Type.displayCutout()
)
val density = activity.resources.displayMetrics.density
+11 -18
View File
@@ -1,4 +1,4 @@
use std::process::Command;
use std::sync::OnceLock;
/// Known e-ink device manufacturers and brands (case-insensitive matching)
const EINK_MANUFACTURERS: &[&str] = &[
@@ -45,28 +45,21 @@ const EINK_MODELS: &[&str] = &[
"max lumi",
];
/// Get Android system property using getprop command
fn get_system_property(prop: &str) -> Option<String> {
Command::new("getprop")
.arg(prop)
.output()
rsproperties::get::<String>(prop)
.ok()
.and_then(|output| {
if output.status.success() {
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if value.is_empty() {
None
} else {
Some(value)
}
} else {
None
}
})
.filter(|s| !s.is_empty())
}
/// Check if the current Android device is an e-ink device
/// Check if the current Android device is an e-ink device.
///
/// The result is cached on first call so subsequent calls are free.
pub fn is_eink_device() -> bool {
static IS_EINK: OnceLock<bool> = OnceLock::new();
*IS_EINK.get_or_init(detect_eink_device)
}
fn detect_eink_device() -> bool {
// Get device manufacturer and model
let manufacturer = get_system_property("ro.product.manufacturer")
.or_else(|| get_system_property("ro.product.brand"))
@@ -0,0 +1,114 @@
import { describe, it, expect } from 'vitest';
import type { TOCItem } from '@/libs/document';
/**
* Regression test for TOC sidebar blank on initial book load.
*
* When a book opens at a position with no TOC entry (e.g., cover page),
* progress.sectionHref is undefined and expandParents is never called.
* For books with a nested TOC structure (chapters nested under a root
* container), the sidebar appears blank because expandedItems is empty
* and only the root item is shown.
*
* Fix: Initialize expandedItems with top-level TOC items that have subitems
* so chapters are visible immediately on sidebar open.
*/
// Mirrors the logic in TOCView.tsx's getItemIdentifier
const getItemIdentifier = (item: TOCItem) => {
const href = item.href || '';
return `toc-item-${item.id}-${href}`;
};
// Mirrors the initialization logic to be added to TOCView.tsx
const getInitialExpandedItems = (toc: TOCItem[]): Set<string> => {
const topLevelWithSubitems = toc
.filter((item) => item.subitems?.length)
.map((item) => getItemIdentifier(item));
return topLevelWithSubitems.length > 0 ? new Set(topLevelWithSubitems) : new Set();
};
// Mirrors useFlattenedTOC logic in TOCView.tsx
const flattenTOC = (items: TOCItem[], expandedItems: Set<string>, depth = 0): TOCItem[] => {
const result: TOCItem[] = [];
items.forEach((item) => {
const isExpanded = expandedItems.has(getItemIdentifier(item));
result.push(item);
if (item.subitems && isExpanded) {
result.push(...flattenTOC(item.subitems, expandedItems, depth + 1));
}
});
return result;
};
describe('TOC sidebar initialization', () => {
const nestedTOC: TOCItem[] = [
{
id: 0,
label: 'Book',
href: undefined,
subitems: [
{ id: 1, label: 'Chapter 1', href: 'ch1.html' },
{ id: 2, label: 'Chapter 2', href: 'ch2.html' },
{ id: 3, label: 'Chapter 3', href: 'ch3.html' },
],
} as unknown as TOCItem,
];
const flatTOC: TOCItem[] = [
{ id: 1, label: 'Chapter 1', href: 'ch1.html' } as unknown as TOCItem,
{ id: 2, label: 'Chapter 2', href: 'ch2.html' } as unknown as TOCItem,
{ id: 3, label: 'Chapter 3', href: 'ch3.html' } as unknown as TOCItem,
];
describe('before fix (demonstrates the bug)', () => {
it('nested TOC with empty expandedItems only shows root item, not chapters', () => {
const expandedItems = new Set<string>(); // Empty initial state (the bug)
const flatItems = flattenTOC(nestedTOC, expandedItems);
// Only root "Book" shows — chapters are hidden
expect(flatItems).toHaveLength(1);
expect(flatItems[0]!.label).toBe('Book');
});
});
describe('after fix (initialization effect behavior)', () => {
it('nested TOC: getInitialExpandedItems expands the root container', () => {
const expandedItems = getInitialExpandedItems(nestedTOC);
expect(expandedItems.size).toBe(1);
expect(expandedItems.has(getItemIdentifier(nestedTOC[0]!))).toBe(true);
});
it('nested TOC: with initialized expandedItems, all chapters are visible', () => {
const expandedItems = getInitialExpandedItems(nestedTOC);
const flatItems = flattenTOC(nestedTOC, expandedItems);
// Root + 3 chapters = 4 items
expect(flatItems).toHaveLength(4);
expect(flatItems[1]!.label).toBe('Chapter 1');
expect(flatItems[2]!.label).toBe('Chapter 2');
expect(flatItems[3]!.label).toBe('Chapter 3');
});
it('flat TOC: getInitialExpandedItems returns empty set (no change)', () => {
const expandedItems = getInitialExpandedItems(flatTOC);
expect(expandedItems.size).toBe(0);
});
it('flat TOC: all chapters visible regardless of expandedItems', () => {
const expandedItems = getInitialExpandedItems(flatTOC);
const flatItems = flattenTOC(flatTOC, expandedItems);
expect(flatItems).toHaveLength(3);
});
it('non-empty expandedItems is preserved (no re-initialization)', () => {
const existingItems = new Set(['toc-item-1-ch1.html']);
// The effect uses: if (prev.size > 0) return prev
const result = existingItems.size > 0 ? existingItems : getInitialExpandedItems(nestedTOC);
expect(result).toBe(existingItems); // Same reference - not re-initialized
});
it('empty TOC produces empty expandedItems', () => {
const expandedItems = getInitialExpandedItems([]);
expect(expandedItems.size).toBe(0);
});
});
});
@@ -0,0 +1,41 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
// Mock zustand stores before importing the component
vi.mock('@/store/atmosphereStore', () => ({
useAtmosphereStore: vi.fn(),
}));
vi.mock('@/store/themeStore', () => ({
useThemeStore: vi.fn(),
}));
import AtmosphereOverlay from '@/components/AtmosphereOverlay';
import { useAtmosphereStore } from '@/store/atmosphereStore';
import { useThemeStore } from '@/store/themeStore';
afterEach(cleanup);
const setupStores = (active: boolean) => {
vi.mocked(useAtmosphereStore).mockImplementation((selector: unknown) =>
(selector as (s: { active: boolean }) => unknown)({ active }),
);
vi.mocked(useThemeStore).mockImplementation((selector: unknown) =>
(selector as (s: { isDarkMode: boolean }) => unknown)({ isDarkMode: false }),
);
};
describe('AtmosphereOverlay', () => {
it('does not render <video> element when inactive', () => {
setupStores(false);
const { container } = render(<AtmosphereOverlay />);
const video = container.querySelector('video');
expect(video).toBeNull();
});
it('renders <video> element when active', () => {
setupStores(true);
const { container } = render(<AtmosphereOverlay />);
const video = container.querySelector('video');
expect(video).toBeTruthy();
});
});
@@ -0,0 +1,41 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import BookCover from '@/components/BookCover';
import { Book } from '@/types/book';
vi.mock('next/image', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => {
// biome-ignore lint/a11y/useAltText: test mock; alt comes from spread props
return <img {...props} />;
},
}));
afterEach(cleanup);
const makeBook = (overrides?: Partial<Book>): Book =>
({
hash: 'abc123',
title: 'Test Book',
author: 'Test Author',
format: 'epub',
coverImageUrl: 'https://example.com/cover.jpg',
...overrides,
}) as Book;
describe('BookCover', () => {
it('passes loading="lazy" to crop-mode Image', () => {
const { container } = render(<BookCover book={makeBook()} coverFit='crop' />);
const img = container.querySelector('img.cover-image');
expect(img).toBeTruthy();
expect(img?.getAttribute('loading')).toBe('lazy');
});
it('passes loading="lazy" to fit-mode Image', () => {
const { container } = render(<BookCover book={makeBook()} coverFit='fit' />);
const img = container.querySelector('img.cover-image');
expect(img).toBeTruthy();
expect(img?.getAttribute('loading')).toBe('lazy');
});
});
@@ -0,0 +1,115 @@
import React, { useState } from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import HighlightColorsEditor from '@/components/settings/color/HighlightColorsEditor';
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import type { DefaultHighlightColor, HighlightColor, UserHighlightColor } from '@/types/book';
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
}));
// Mocked SketchPicker that tracks mount/unmount via a shared module-level
// registry, so we can detect whether the picker was remounted across a hex
// change. Each mount gets a unique id; a mount is "alive" while its cleanup
// hasn't run.
const mountLog: Array<{ id: number; alive: boolean }> = [];
let nextMountId = 0;
vi.mock('react-color', () => ({
SketchPicker: ({
color,
onChange,
}: {
color: string;
onChange: (c: { hex: string }) => void;
}) => {
const [mountId] = useState(() => {
const id = nextMountId++;
mountLog.push({ id, alive: true });
return id;
});
React.useEffect(() => {
return () => {
const entry = mountLog.find((m) => m.id === mountId);
if (entry) entry.alive = false;
};
}, [mountId]);
return (
<div
data-testid='mock-sketch-picker'
data-mount-id={mountId}
data-color={color}
// Expose a way to trigger onChange as if from a drag.
onClick={() => onChange({ hex: '#112233' })}
/>
);
},
ColorResult: undefined,
}));
afterEach(() => {
cleanup();
mountLog.length = 0;
nextMountId = 0;
});
const Harness: React.FC<{ initialUserColors: UserHighlightColor[] }> = ({ initialUserColors }) => {
const [userColors, setUserColors] = useState<UserHighlightColor[]>(initialUserColors);
const [customColors, setCustomColors] =
useState<Record<HighlightColor, string>>(HIGHLIGHT_COLOR_HEX);
const [labels, setLabels] = useState<Partial<Record<DefaultHighlightColor, string>>>({});
return (
<HighlightColorsEditor
customHighlightColors={customColors}
userHighlightColors={userColors}
defaultHighlightLabels={labels}
highlightOpacity={0.3}
isEink={false}
onCustomHighlightColorsChange={setCustomColors}
onUserHighlightColorsChange={setUserColors}
onDefaultHighlightLabelsChange={setLabels}
onOpacityChange={() => {}}
/>
);
};
describe('HighlightColorsEditor — user color SketchPicker stability', () => {
it('keeps the SketchPicker mounted when the user-color hex updates (so drag is not interrupted)', () => {
render(<Harness initialUserColors={[{ hex: '#aabbcc' }]} />);
// The user color row's ColorInput renders a text input with the hex.
// Find it (the predefined palette also renders inputs; the user row's
// input is last in the document because it's rendered after the defaults).
const hexInputs = screen.getAllByDisplayValue(/^#/);
const userHexInput = hexInputs[hexInputs.length - 1]!;
expect(userHexInput).toHaveProperty('value', '#aabbcc');
// Open the SketchPicker for this user color.
fireEvent.click(userHexInput);
const picker = screen.getByTestId('mock-sketch-picker');
const initialMountId = picker.getAttribute('data-mount-id');
expect(initialMountId).not.toBeNull();
// Simulate an onChange coming from the SketchPicker during drag.
// (In the real picker this fires continuously as the user drags.)
fireEvent.click(picker);
// After the hex update, the picker should STILL be mounted (same id).
// If the parent row unmounted due to `key={hex}` changing, the original
// mount would be gone and React would have mounted a fresh one — the
// drag's window-level mouse listeners would be torn down with it.
const sameMount = mountLog.find((m) => String(m.id) === initialMountId);
expect(sameMount, 'SketchPicker mount with initial id should still exist').toBeDefined();
expect(sameMount!.alive, 'SketchPicker should not be unmounted on hex change').toBe(true);
// And a picker for the new color should still be visible in the DOM.
const pickerAfter = screen.queryByTestId('mock-sketch-picker');
expect(pickerAfter).not.toBeNull();
expect(pickerAfter!.getAttribute('data-color')).toBe('#112233');
expect(pickerAfter!.getAttribute('data-mount-id')).toBe(initialMountId);
});
});
@@ -0,0 +1,210 @@
/**
* Visual regression test for the AnnotationPopup component.
*
* Renders the *real* AnnotationPopup + HighlightOptions with actual
* annotationToolButtons, DEFAULT_HIGHLIGHT_COLORS, and optional user
* colors. Tailwind CSS is loaded so the screenshot matches the live app.
*
* Guards against the layout regression from PR #3741 (missing
* `justify-between`, unwanted `flex-1` on the color strip).
*/
import React from 'react';
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { page } from 'vitest/browser';
import type { UserHighlightColor } from '@/types/book';
// ── Tailwind / DaisyUI styles ───────────────────────────────────────────
import '@/styles/globals.css';
// ── Per-test state read by mocks ────────────────────────────────────────
let mockUserColors: UserHighlightColor[] = [];
// ── Mocks (must be before component imports) ────────────────────────────
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ envConfig: {}, appService: null }),
}));
vi.mock('@/store/themeStore', () => ({
useThemeStore: () => ({ isDarkMode: false }),
}));
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
}));
vi.mock('@/store/settingsStore', () => ({
useSettingsStore: () => ({
settings: {
globalReadSettings: {
highlightStyle: 'highlight' as const,
highlightStyles: {
highlight: 'yellow',
underline: 'red',
squiggly: 'blue',
},
customHighlightColors: {} as Record<string, string>,
get userHighlightColors() {
return mockUserColors;
},
defaultHighlightLabels: {},
},
globalViewSettings: {
isEink: false,
isColorEink: false,
},
},
}),
}));
vi.mock('@/hooks/useResponsiveSize', () => ({
useResponsiveSize: (n: number) => n,
useDefaultIconSize: () => 20,
}));
vi.mock('@/hooks/useKeyDownActions', () => ({
useKeyDownActions: () => {},
}));
vi.mock('@/helpers/settings', () => ({
saveSysSettings: vi.fn(),
}));
vi.mock('@/app/reader/utils/annotatorUtil', () => ({
getHighlightColorLabel: () => undefined,
}));
// ── Real component imports ──────────────────────────────────────────────
import AnnotationPopup from '@/app/reader/components/annotator/AnnotationPopup';
import { annotationToolButtons } from '@/app/reader/components/annotator/AnnotationTools';
// ── Constants ───────────────────────────────────────────────────────────
const POPUP_W = 300;
const POPUP_H = 44;
// Highlight options float above the popup by (28 + 16) = 44px
const OPTIONS_OFFSET = 28 + 16;
// Position the popup so both it and the floating options are visible:
// y=0..OPTIONS_OFFSET: highlight-options row
// y=OPTIONS_OFFSET..OPTIONS_OFFSET+POPUP_H: toolbar
const POPUP_Y = OPTIONS_OFFSET;
const POPUP_X = 0;
const WRAPPER_H = POPUP_Y + POPUP_H + 14; // +14 for triangle below
const toolButtons = annotationToolButtons.map(({ label, Icon }) => ({
tooltipText: label,
Icon,
onClick: vi.fn(),
}));
// Browser-mode matcher types are unavailable to tsgo; cast once here.
const expectElement = (locator: unknown) =>
// @ts-expect-error -- expect.element() exists in vitest browser mode
expect.element(locator) as { toMatchScreenshot: (name: string) => Promise<void> };
/**
* Fixed-size wrapper that contains both the popup and the absolutely
* positioned highlight-options row above it, matching the real app
* where the triangle points up and highlight options float above.
*/
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div
data-theme='dark'
style={{
position: 'relative',
width: POPUP_W,
height: WRAPPER_H,
overflow: 'visible',
}}
>
{children}
</div>
);
const renderPopup = (userColors: UserHighlightColor[] = []) => {
mockUserColors = userColors;
return render(
<Wrapper>
<AnnotationPopup
bookKey='test'
dir='ltr'
isVertical={false}
buttons={toolButtons}
notes={[]}
position={{ dir: 'up', point: { x: POPUP_X, y: POPUP_Y } }}
trianglePosition={{ dir: 'up', point: { x: POPUP_X + POPUP_W / 2, y: POPUP_Y + POPUP_H } }}
highlightOptionsVisible
selectedStyle='highlight'
selectedColor='yellow'
popupWidth={POPUP_W}
popupHeight={POPUP_H}
onHighlight={vi.fn()}
onDismiss={vi.fn()}
/>
</Wrapper>,
);
};
// ── Lifecycle ───────────────────────────────────────────────────────────
beforeAll(async () => {
await page.viewport(800, 600);
});
beforeEach(() => {
mockUserColors = [];
});
afterEach(() => {
cleanup();
});
// ── Tests ───────────────────────────────────────────────────────────────
describe('AnnotationPopup layout screenshot', () => {
it('default 5 colors — compact color strip, large gap', async () => {
const { container } = renderPopup();
const wrapper = container.firstElementChild as HTMLElement;
await expectElement(page.elementLocator(wrapper)).toMatchScreenshot(
'annotation-popup-5-colors',
);
});
it('5+5 user colors — color strip grows, gap shrinks', async () => {
const { container } = renderPopup([
{ hex: '#f97316' },
{ hex: '#06b6d4' },
{ hex: '#ec4899' },
{ hex: '#14b8a6' },
{ hex: '#f43f5e' },
]);
const wrapper = container.firstElementChild as HTMLElement;
await expectElement(page.elementLocator(wrapper)).toMatchScreenshot(
'annotation-popup-10-colors',
);
});
it('5+10 user colors — color strip at max, overflow scrolls', async () => {
const { container } = renderPopup([
{ hex: '#f97316' },
{ hex: '#06b6d4' },
{ hex: '#ec4899' },
{ hex: '#14b8a6' },
{ hex: '#f43f5e' },
{ hex: '#a855f7' },
{ hex: '#84cc16' },
{ hex: '#0ea5e9' },
{ hex: '#e11d48' },
{ hex: '#6366f1' },
]);
const wrapper = container.firstElementChild as HTMLElement;
await expectElement(page.elementLocator(wrapper)).toMatchScreenshot(
'annotation-popup-15-colors',
);
});
});
@@ -0,0 +1,102 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, act } from '@testing-library/react';
vi.mock('@/utils/supabase', () => ({
supabase: {
auth: {
onAuthStateChange: vi.fn(() => ({
data: { subscription: { unsubscribe: vi.fn() } },
})),
refreshSession: vi.fn().mockResolvedValue(undefined),
signOut: vi.fn().mockResolvedValue(undefined),
},
},
}));
vi.mock('posthog-js', () => ({
default: { identify: vi.fn() },
}));
import { AuthProvider, useAuth } from '@/context/AuthContext';
describe('AuthContext memoization', () => {
beforeEach(() => {
if (typeof window !== 'undefined') {
window.localStorage.clear();
}
});
afterEach(() => {
cleanup();
});
test('returns the same context value reference when parent re-renders without state change', () => {
const captured: ReturnType<typeof useAuth>[] = [];
function Probe() {
const value = useAuth();
captured.push(value);
return null;
}
function Wrapper({ tick }: { tick: number }) {
// The tick prop forces a parent re-render but does not change AuthProvider state
return (
<AuthProvider>
<span data-tick={tick} />
<Probe />
</AuthProvider>
);
}
const { rerender } = render(<Wrapper tick={0} />);
act(() => {
rerender(<Wrapper tick={1} />);
});
act(() => {
rerender(<Wrapper tick={2} />);
});
// Probe captures one value per render. We expect at least 3 captures.
expect(captured.length).toBeGreaterThanOrEqual(3);
// The first capture happens during initial mount (state may settle async),
// but subsequent captures from parent-only re-renders should reuse the same
// memoized context value reference. If login/logout/refresh are not stable
// (no useCallback), useMemo's deps change every render and produce a fresh
// object each time — this assertion catches that regression.
const firstStable = captured[captured.length - 2]!;
const secondStable = captured[captured.length - 1]!;
expect(secondStable).toBe(firstStable);
});
test('login/logout/refresh callbacks are stable across re-renders', () => {
const captured: ReturnType<typeof useAuth>[] = [];
function Probe() {
const value = useAuth();
captured.push(value);
return null;
}
function Wrapper({ tick }: { tick: number }) {
return (
<AuthProvider>
<span data-tick={tick} />
<Probe />
</AuthProvider>
);
}
const { rerender } = render(<Wrapper tick={0} />);
act(() => {
rerender(<Wrapper tick={1} />);
});
const last = captured[captured.length - 1]!;
const prev = captured[captured.length - 2]!;
expect(last.login).toBe(prev.login);
expect(last.logout).toBe(prev.logout);
expect(last.refresh).toBe(prev.refresh);
});
});
@@ -278,6 +278,40 @@ describe('Paginator multi-view architecture', () => {
// With tolerance, this should detect index 3 (not 2)
expect(detectPrimary(visibleStart)).toBe(3);
});
it('should not inflate page count due to fractional DPR rounding (pages getter)', () => {
// On fractional DPR devices, getBoundingClientRect() on the view element
// returns a width that is a near-integer multiple of the container size,
// but with tiny floating-point drift. Math.ceil inflates the count by 1.
// Real scenario: containerSize=785.45458984375, viewSize should be
// exactly 4*containerSize but getBoundingClientRect() returns a value
// with sub-pixel error.
const containerSize = 785.45458984375;
const viewSize = containerSize * 4 + 0.0001; // tiny FP drift
// Bug: Math.ceil gives 5 instead of 4
const buggyPages = Math.ceil(viewSize / containerSize);
expect(buggyPages).toBe(5); // confirms the bug exists
// Fix: Math.round absorbs sub-pixel drift
const fixedPages = Math.round(viewSize / containerSize);
expect(fixedPages).toBe(4);
});
it('should not inflate rendered page count due to fractional DPR rounding', () => {
// Same issue for #renderedPages which sums multiple view sizes
const containerSize = 785.45458984375;
const viewSizes = [containerSize * 3 + 0.00005, containerSize * 2 + 0.00008];
const totalViewSize = viewSizes.reduce((a, b) => a + b, 0);
// Bug: Math.ceil over-counts
const buggyPages = Math.ceil(totalViewSize / containerSize);
expect(buggyPages).toBe(6); // should be 5 but ceil rounds up
// Fix: Math.round
const fixedPages = Math.round(totalViewSize / containerSize);
expect(fixedPages).toBe(5);
});
});
describe('Adjacent index with fromIndex parameter', () => {
@@ -19,10 +19,16 @@ const highlight = vi.fn();
/**
* Build a document that mimics a rendered PDF page with text layer,
* matching the structure that pdf.js produces in the iframe.
* When withLineBreaks is true, inserts <br> between spans (matching real PDF.js output).
*/
const createPDFTextLayerDoc = (textSpans: string[], annotationText?: string): Document => {
const createPDFTextLayerDoc = (
textSpans: string[],
annotationText?: string,
withLineBreaks?: boolean,
): Document => {
const parser = new DOMParser();
const spans = textSpans.map((t) => `<span>${t}</span>`).join('');
const separator = withLineBreaks ? '<br>' : '';
const spans = textSpans.map((t) => `<span>${t}</span>`).join(separator);
const annotation = annotationText
? `<div class="annotationLayer"><a href="#">${annotationText}</a></div>`
: '<div class="annotationLayer"></div>';
@@ -38,7 +44,7 @@ const createPDFTextLayerDoc = (textSpans: string[], annotationText?: string): Do
/** Node filter matching what TTSController uses for PDFs */
const pdfNodeFilter = createRejectFilter({
tags: ['rt', 'canvas'],
tags: ['rt', 'canvas', 'br'],
classes: ['annotationLayer'],
contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/ }],
});
@@ -101,6 +107,183 @@ describe('PDF TTS', () => {
});
});
describe('TTS with PDF line breaks (br elements)', () => {
it('should not produce SSML break elements for br tags in PDF text layer', () => {
const doc = createPDFTextLayerDoc(
['Alice was beginning to get very ', 'tired of sitting by her sister '],
undefined,
true,
);
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
// The SSML should NOT contain <break> elements for PDF line breaks
expect(ssml).not.toMatch(/<break\s*\/?\s*>/);
// But the text should still be continuous
const text = stripTags(ssml!);
expect(text).toContain('Alice');
expect(text).toContain('tired');
});
it('should read through PDF line breaks without interruption', () => {
const doc = createPDFTextLayerDoc(
[
'This is the first line of a paragraph ',
'and this continues on the second line ',
'ending on the third line.',
],
undefined,
true,
);
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
const text = stripTags(ssml!);
// All text should be in a single block without breaks
expect(text).toContain('first line');
expect(text).toContain('second line');
expect(text).toContain('third line');
// No SSML break elements
expect(ssml).not.toMatch(/<break\s*\/?\s*>/);
});
});
describe('PDF sentence-level block splitting', () => {
it('should split multiple sentences into separate TTS blocks', () => {
const doc = createPDFTextLayerDoc([
'Alice was beginning to get very tired. ',
'She had nothing to do. ',
'The day was warm and sunny.',
]);
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const blocks: string[] = [];
let ssml = tts.start();
while (ssml) {
blocks.push(stripTags(ssml));
ssml = tts.next();
}
// Each sentence should be its own block
expect(blocks.length).toBe(3);
expect(blocks[0]).toContain('Alice was beginning');
expect(blocks[1]).toContain('She had nothing');
expect(blocks[2]).toContain('The day was warm');
});
it('should handle sentences that span across multiple spans', () => {
const doc = createPDFTextLayerDoc([
'Alice was beginning to get very ',
'tired of sitting by her sister. She had ',
'nothing to do.',
]);
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const blocks: string[] = [];
let ssml = tts.start();
while (ssml) {
blocks.push(stripTags(ssml));
ssml = tts.next();
}
expect(blocks.length).toBe(2);
expect(blocks[0]).toContain('tired of sitting');
expect(blocks[1]).toContain('nothing to do');
});
it('should handle a single sentence as one block', () => {
const doc = createPDFTextLayerDoc(['Just one sentence here.']);
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const blocks: string[] = [];
let ssml = tts.start();
while (ssml) {
blocks.push(stripTags(ssml));
ssml = tts.next();
}
expect(blocks.length).toBe(1);
expect(blocks[0]).toContain('Just one sentence here');
});
it('should handle sentences with br elements between lines', () => {
const doc = createPDFTextLayerDoc(
[
'First sentence on line one. ',
'Second sentence starts here ',
'and continues on line three.',
],
undefined,
true,
);
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const blocks: string[] = [];
let ssml = tts.start();
while (ssml) {
const text = stripTags(ssml);
blocks.push(text);
// No break elements in any block
expect(ssml).not.toMatch(/<break\s*\/?\s*>/);
ssml = tts.next();
}
expect(blocks.length).toBe(2);
expect(blocks[0]).toContain('First sentence');
expect(blocks[1]).toContain('Second sentence');
expect(blocks[1]).toContain('line three');
});
it('should produce word marks within each sentence block', () => {
const doc = createPDFTextLayerDoc(['Hello world. Goodbye world.']);
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(ssml).toContain('<mark');
expect(stripTags(ssml!)).toContain('Hello');
expect(stripTags(ssml!)).not.toContain('Goodbye');
const ssml2 = tts.next();
expect(ssml2).toBeTruthy();
expect(ssml2).toContain('<mark');
expect(stripTags(ssml2!)).toContain('Goodbye');
});
it('should align marks with sentence text when sentence spans multiple spans', () => {
// Sentence boundary falls in the middle of span 2:
// "Alice was beginning to get " + "very tired. She had nothing " + "to do."
// Sentence 1: "Alice was beginning to get very tired. "
// Sentence 2: "She had nothing to do."
const doc = createPDFTextLayerDoc([
'Alice was beginning to get ',
'very tired. She had nothing ',
'to do.',
]);
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const ssml1 = tts.start();
expect(ssml1).toBeTruthy();
const text1 = stripTags(ssml1!);
// First block must contain ONLY sentence 1 words
expect(text1).toContain('Alice');
expect(text1).toContain('tired');
expect(text1).not.toContain('She');
expect(text1).not.toContain('nothing');
const ssml2 = tts.next();
expect(ssml2).toBeTruthy();
const text2 = stripTags(ssml2!);
// Second block must contain ONLY sentence 2 words
expect(text2).toContain('She');
expect(text2).toContain('nothing');
expect(text2).not.toContain('Alice');
expect(text2).not.toContain('tired');
});
});
describe('PDF node filter', () => {
it('should reject canvas elements', () => {
const canvas = document.createElement('canvas');
@@ -0,0 +1,66 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, act } from '@testing-library/react';
vi.mock('@/store/readerStore', () => {
return {
useReaderStore: () => ({ hoveredBookKey: null }),
};
});
vi.mock('@/store/bookDataStore', () => {
return {
useBookDataStore: () => ({ getBookData: () => null }),
};
});
vi.mock('@/utils/event', () => ({
eventDispatcher: { dispatch: vi.fn() },
}));
import { useMouseEvent } from '@/app/reader/hooks/useIframeEvents';
function dispatchWheelMessage(bookKey: string) {
// useMouseEvent listens on `message`, not `window.postMessage` directly,
// so we dispatch a MessageEvent manually for synchronous delivery.
const event = new MessageEvent('message', {
data: { bookKey, type: 'iframe-wheel', deltaY: 100, ctrlKey: false },
});
window.dispatchEvent(event);
}
describe('useMouseEvent debounce ref', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
test('debounced wheel handler dispatches to the latest handlePageFlip after re-render', () => {
const fn1 = vi.fn();
const fn2 = vi.fn();
function Wrapper({ handler }: { handler: (msg: MessageEvent) => void }) {
// useMouseEvent has the 2nd parameter typed as a union including
// React.MouseEvent — we cast through unknown to satisfy the typecheck
// for this focused unit test.
useMouseEvent('book-1', handler as unknown as Parameters<typeof useMouseEvent>[1]);
return null;
}
const { rerender } = render(<Wrapper handler={fn1} />);
// Re-render with a new handler reference. The debounced wheel wrapper
// should pick up the latest one rather than holding onto fn1 forever.
rerender(<Wrapper handler={fn2} />);
dispatchWheelMessage('book-1');
act(() => {
vi.advanceTimersByTime(150); // exceed the 100ms debounce
});
expect(fn1).not.toHaveBeenCalled();
expect(fn2).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,216 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock isomorphic-ws so that if the legacy (non-fetch) path is hit on
// Cloudflare Workers, the test fails loudly instead of attempting a real
// WebSocket connection.
vi.mock('isomorphic-ws', () => ({
default: class {
constructor() {
throw new Error('isomorphic-ws should not be used on Cloudflare Workers');
}
},
}));
// Stub the Supabase client so importing edgeTTS.ts (transitively via
// @/utils/fetch -> @/utils/access) does not instantiate a real GoTrueClient.
// Each `vi.resetModules()` would otherwise create another client and Supabase
// logs "Multiple GoTrueClient instances detected" to stderr.
vi.mock('@/utils/supabase', () => ({
supabase: { auth: { getSession: async () => ({ data: { session: null } }) } },
createSupabaseClient: () => ({}),
createSupabaseAdminClient: () => ({}),
}));
type GlobalWithWsPair = typeof globalThis & { WebSocketPair?: unknown };
describe('EdgeSpeechTTS on Cloudflare Workers', () => {
let originalWebSocketPair: unknown;
let originalFetch: typeof fetch | undefined;
beforeEach(() => {
// Simulate Cloudflare Workers by defining WebSocketPair on globalThis.
originalWebSocketPair = (globalThis as GlobalWithWsPair).WebSocketPair;
(globalThis as GlobalWithWsPair).WebSocketPair = function WebSocketPair() {};
originalFetch = globalThis.fetch;
vi.resetModules();
});
afterEach(() => {
if (originalWebSocketPair === undefined) {
delete (globalThis as GlobalWithWsPair).WebSocketPair;
} else {
(globalThis as GlobalWithWsPair).WebSocketPair = originalWebSocketPair;
}
if (originalFetch) {
globalThis.fetch = originalFetch;
}
vi.restoreAllMocks();
});
test('uses fetch-based WebSocket upgrade and returns audio Response', async () => {
// Build a mock WebSocket that records listeners and emits a frame
// containing valid audio after both speech.config and ssml are sent.
const listeners: Record<string, Array<(event: unknown) => void>> = {};
const mockSocket = {
accept: vi.fn(),
send: vi.fn(),
close: vi.fn(),
addEventListener: vi.fn((type: string, cb: (event: unknown) => void) => {
(listeners[type] ??= []).push(cb);
}),
removeEventListener: vi.fn(),
};
// Simulate server responses once both config + ssml messages are sent.
let sendCount = 0;
mockSocket.send.mockImplementation(() => {
sendCount++;
if (sendCount === 2) {
// Binary audio frame: [2-byte big-endian header length][header text][audio body]
const headerText = 'X-RequestId:1\r\nContent-Type:audio/mpeg\r\nPath:audio\r\n';
const headerBytes = new TextEncoder().encode(headerText);
const audioBody = new Uint8Array([0xaa, 0xbb, 0xcc, 0xdd]);
const frame = new Uint8Array(2 + headerBytes.byteLength + audioBody.byteLength);
new DataView(frame.buffer).setInt16(0, headerBytes.byteLength);
frame.set(headerBytes, 2);
frame.set(audioBody, 2 + headerBytes.byteLength);
// Dispatch on a microtask so the send() call returns first.
queueMicrotask(() => {
for (const cb of listeners['message'] ?? []) {
cb({ data: frame.buffer });
}
for (const cb of listeners['message'] ?? []) {
cb({ data: 'X-RequestId:1\r\nPath: turn.end\r\n\r\n' });
}
});
}
});
const fetchSpy = vi.fn().mockResolvedValue({
status: 101,
webSocket: mockSocket,
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
// Import AFTER the mocks and globals are set up.
const { EdgeSpeechTTS } = await import('@/libs/edgeTTS');
const tts = new EdgeSpeechTTS('wss');
const response = await tts.create({
lang: 'en-US',
text: 'hello',
voice: 'en-US-AriaNeural',
rate: 1.0,
pitch: 1.0,
});
expect(response).toBeInstanceOf(Response);
const buffer = await response.arrayBuffer();
expect(new Uint8Array(buffer)).toEqual(new Uint8Array([0xaa, 0xbb, 0xcc, 0xdd]));
// fetch should be called once with an https URL and an Upgrade header.
expect(fetchSpy).toHaveBeenCalledOnce();
const call = fetchSpy.mock.calls[0]!;
const calledUrl = call[0] as string | URL;
const calledInit = call[1] as RequestInit;
expect(String(calledUrl)).toContain('https://speech.platform.bing.com/');
expect(String(calledUrl)).not.toContain('wss://');
const headers = calledInit.headers as Record<string, string>;
expect(headers['Upgrade']).toBe('websocket');
// The WebSocket returned by fetch must be accepted before use, and both
// the speech.config and ssml messages must be sent.
expect(mockSocket.accept).toHaveBeenCalledOnce();
expect(mockSocket.send).toHaveBeenCalledTimes(2);
// Socket is closed once turn.end is received.
expect(mockSocket.close).toHaveBeenCalledOnce();
});
test('decodes Blob binary frames (Cloudflare Workers shape)', async () => {
// On real Cloudflare Workers, WebSocket binary frames arrive as Blob
// instances rather than ArrayBuffer. This test guards that code path
// by having the mock socket emit Blob messages.
const listeners: Record<string, Array<(event: unknown) => void>> = {};
const mockSocket = {
accept: vi.fn(),
send: vi.fn(),
close: vi.fn(),
addEventListener: vi.fn((type: string, cb: (event: unknown) => void) => {
(listeners[type] ??= []).push(cb);
}),
removeEventListener: vi.fn(),
};
const buildFrame = (body: Uint8Array) => {
const headerText = 'X-RequestId:1\r\nContent-Type:audio/mpeg\r\nPath:audio\r\n';
const headerBytes = new TextEncoder().encode(headerText);
const frame = new Uint8Array(2 + headerBytes.byteLength + body.byteLength);
new DataView(frame.buffer).setInt16(0, headerBytes.byteLength);
frame.set(headerBytes, 2);
frame.set(body, 2 + headerBytes.byteLength);
return new Blob([frame]);
};
let sendCount = 0;
mockSocket.send.mockImplementation(() => {
sendCount++;
if (sendCount === 2) {
queueMicrotask(() => {
// Two binary Blob frames...
for (const cb of listeners['message'] ?? []) {
cb({ data: buildFrame(new Uint8Array([0x01, 0x02, 0x03])) });
}
for (const cb of listeners['message'] ?? []) {
cb({ data: buildFrame(new Uint8Array([0x04, 0x05])) });
}
// ...then turn.end text message (fires before blob.arrayBuffer() resolves).
for (const cb of listeners['message'] ?? []) {
cb({ data: 'X-RequestId:1\r\nPath:turn.end\r\n\r\n' });
}
});
}
});
const fetchSpy = vi.fn().mockResolvedValue({
status: 101,
webSocket: mockSocket,
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const { EdgeSpeechTTS } = await import('@/libs/edgeTTS');
const tts = new EdgeSpeechTTS('wss');
const response = await tts.create({
lang: 'en-US',
text: 'hello',
voice: 'en-US-AriaNeural',
rate: 1.0,
pitch: 1.0,
});
// Both Blob frames should be decoded in receive order before the
// turn.end message finalizes the audio payload.
const buffer = await response.arrayBuffer();
expect(new Uint8Array(buffer)).toEqual(new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]));
expect(mockSocket.close).toHaveBeenCalled();
});
test('rejects when fetch upgrade returns non-101 status', async () => {
const fetchSpy = vi.fn().mockResolvedValue({
status: 403,
webSocket: undefined,
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const { EdgeSpeechTTS } = await import('@/libs/edgeTTS');
const tts = new EdgeSpeechTTS('wss');
await expect(
tts.create({
lang: 'en-US',
text: 'hello',
voice: 'en-US-AriaNeural',
rate: 1.0,
pitch: 1.0,
}),
).rejects.toThrow();
});
});
@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
parseRedirect,
parsePinyin,
cleanWikiMarkup,
parseDefinitions,
fetchChineseDefinition,
} from '@/services/dictionaries/chineseDict';
// Sample wikitext for 書 (traditional, full entry)
const WIKITEXT_SHU_TRADITIONAL = `
==Chinese==
===Glyph origin===
Some origin text.
===Pronunciation===
{{zh-pron
|m=shū
|c=syu1
|cat=n,v,pn
}}
===Definitions===
{{head|zh|hanzi}}
# [[book]]; [[codex]]
# [[letter]]; [[document]]
# form of a written or printed [[Chinese character]]; [[style]]
# {{lb|zh|literary}} [[Chinese character]]; [[writing]]; [[script]]
# {{lb|zh|historical}} ancient government [[post]]
# [[storytelling]]
# to [[write]]
# {{surname|zh}}
===Compounds===
* {{zh-l|}}
`;
// Sample wikitext for 书 (simplified, redirect)
const WIKITEXT_SHU_SIMPLIFIED = `
{{also|}}
{{character info}}
==Translingual==
===Han character===
{{Han char|rn=5|rad=|as=3|sn=4}}
==Chinese==
===Glyph origin===
{{Han simp|}}
===Definitions===
{{zh-see|}}
`;
// Sample wikitext for 你好 (multi-character word)
const WIKITEXT_NI_HAO = `
==Chinese==
===Pronunciation===
{{zh-pron
|m= hǎo
|c=nei5 hou2
|cat=intj
}}
===Definitions===
{{head|zh|interjection}}
# [[hello]]; [[hi]]
====Usage notes====
Often used as a polite greeting.
===See also===
* {{zh-l|}}
`;
describe('parseRedirect', () => {
it('detects zh-see redirect', () => {
expect(parseRedirect(WIKITEXT_SHU_SIMPLIFIED)).toBe('書');
});
it('returns null when no redirect', () => {
expect(parseRedirect(WIKITEXT_SHU_TRADITIONAL)).toBeNull();
});
it('returns null for empty string', () => {
expect(parseRedirect('')).toBeNull();
});
});
describe('parsePinyin', () => {
it('extracts pinyin from zh-pron template', () => {
expect(parsePinyin(WIKITEXT_SHU_TRADITIONAL)).toBe('shū');
});
it('extracts multi-syllable pinyin', () => {
expect(parsePinyin(WIKITEXT_NI_HAO)).toBe('nǐ hǎo');
});
it('returns null when no zh-pron template', () => {
expect(parsePinyin('no pronunciation here')).toBeNull();
});
it('returns null when zh-pron has no mandarin entry', () => {
const wikitext = '{{zh-pron\n|c=syu1\n}}';
expect(parsePinyin(wikitext)).toBeNull();
});
});
describe('cleanWikiMarkup', () => {
it('cleans simple wiki links', () => {
expect(cleanWikiMarkup('[[book]]')).toBe('book');
});
it('cleans piped wiki links', () => {
expect(cleanWikiMarkup('[[Chinese character|character]]')).toBe('character');
});
it('cleans language label templates', () => {
expect(cleanWikiMarkup('{{lb|zh|literary}} text')).toBe('(literary) text');
});
it('cleans multiple labels', () => {
expect(cleanWikiMarkup('{{lb|zh|historical|archaic}} text')).toBe('(historical, archaic) text');
});
it('cleans surname template', () => {
expect(cleanWikiMarkup('{{surname|zh}}')).toBe('A surname');
});
it('cleans zh-abbrev template', () => {
expect(cleanWikiMarkup('{{zh-abbrev|书经}}')).toBe('abbreviation of 书经');
});
it('cleans l template', () => {
expect(cleanWikiMarkup('{{l|zh|書}}')).toBe('書');
});
it('removes unknown templates', () => {
expect(cleanWikiMarkup('{{unknown|template}}')).toBe('');
});
it('handles mixed content', () => {
const input = '{{lb|zh|literary}} [[Chinese character]]; [[writing]]';
expect(cleanWikiMarkup(input)).toBe('(literary) Chinese character; writing');
});
});
describe('parseDefinitions', () => {
it('parses definitions from traditional character entry', () => {
const result = parseDefinitions(WIKITEXT_SHU_TRADITIONAL);
expect(result).toHaveLength(1);
expect(result[0]!.meanings).toContain('book; codex');
expect(result[0]!.meanings).toContain('letter; document');
expect(result[0]!.meanings).toContain('to write');
expect(result[0]!.meanings).toContain('A surname');
});
it('parses definitions from multi-character word', () => {
const result = parseDefinitions(WIKITEXT_NI_HAO);
expect(result).toHaveLength(1);
expect(result[0]!.meanings).toContain('hello; hi');
});
it('returns empty for redirect entries', () => {
const result = parseDefinitions(WIKITEXT_SHU_SIMPLIFIED);
expect(result).toHaveLength(0);
});
it('returns empty for text without definitions', () => {
expect(parseDefinitions('no definitions here')).toHaveLength(0);
});
it('skips sub-definition lines (## lines)', () => {
const wikitext = `===Definitions===
{{head|zh|hanzi}}
# main definition
## sub definition
# another main
===Other===`;
const result = parseDefinitions(wikitext);
expect(result[0]!.meanings).toHaveLength(2);
expect(result[0]!.meanings).toContain('main definition');
expect(result[0]!.meanings).toContain('another main');
});
});
describe('fetchChineseDefinition', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('fetches and parses a traditional character', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(
JSON.stringify({
parse: { wikitext: { '*': WIKITEXT_SHU_TRADITIONAL } },
}),
),
);
const result = await fetchChineseDefinition('書');
expect(result).not.toBeNull();
expect(result!.pinyin).toBe('shū');
expect(result!.word).toBe('書');
expect(result!.definitions[0]!.meanings).toContain('book; codex');
});
it('follows simplified → traditional redirect', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
// First call returns simplified (redirect)
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
parse: { wikitext: { '*': WIKITEXT_SHU_SIMPLIFIED } },
}),
),
);
// Second call returns traditional (full entry)
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
parse: { wikitext: { '*': WIKITEXT_SHU_TRADITIONAL } },
}),
),
);
const result = await fetchChineseDefinition('书');
expect(result).not.toBeNull();
expect(result!.word).toBe('書');
expect(result!.pinyin).toBe('shū');
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('returns null on network error', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(null, { status: 404 }));
const result = await fetchChineseDefinition('nonexistent');
expect(result).toBeNull();
});
it('returns null when wikitext has no useful data', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(
JSON.stringify({
parse: { wikitext: { '*': '==Translingual==\nSome unrelated content.' } },
}),
),
);
const result = await fetchChineseDefinition('test');
expect(result).toBeNull();
});
});
@@ -28,6 +28,7 @@ type HardcoverClientTestApi = {
token: string;
extractISBN: (book: Book) => string | null;
request: <TVariables, TData>(query: string, variables: TVariables) => Promise<TData>;
fetchBookContext: (book: Book) => Promise<TestBookContext | null>;
buildJournalPayload: (
note: BookNote,
config: BookConfig,
@@ -254,6 +255,74 @@ describe('HardcoverClient', () => {
expect(variables?.started_at).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
test('should prefer the active user read edition when resolving Hardcover context', async () => {
const book = {
metadata: {
isbn: '9780679783268',
},
} as unknown as Book;
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ data: { me: { id: 1 } } }),
});
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: () =>
Promise.resolve({
data: {
editions: [
{
id: 101,
pages: 320,
reading_format_id: 1,
book: {
id: 202,
pages: 500,
user_books: [
{
id: 303,
status_id: 2,
edition: {
id: 404,
pages: 410,
reading_format_id: 1,
},
user_book_reads: [
{
id: 505,
started_at: '2026-03-29',
edition: {
id: 606,
pages: 400,
reading_format_id: 1,
},
},
],
},
],
},
},
],
},
}),
});
const context = await clientApi.ensureBookInLibrary(book, true);
expect(context).toMatchObject({
editionId: 606,
pages: 400,
bookId: 202,
bookPages: 500,
userBook: {
id: 303,
},
});
});
test('should format multiline quote text with a short divider before the note', () => {
const note = {
id: '1',
@@ -327,6 +396,145 @@ describe('HardcoverClient', () => {
});
});
test('should reuse the active read returned when promoting a book to currently reading', async () => {
const book = {
createdAt: 1711737600000,
metadata: { isbn: '1234567890' },
} as Book;
const config = { progress: [25, 100] } as BookConfig;
vi.spyOn(clientApi, 'ensureBookInLibrary').mockResolvedValue({
editionId: 101,
pages: 100,
bookId: 202,
bookPages: 100,
userBook: {
id: 303,
status_id: 1,
user_book_reads: [],
},
});
const requestSpy = vi.spyOn(clientApi, 'request').mockImplementation(async (query) => {
if (String(query).includes('mutation UpdateUserBook')) {
return {
update_user_book: {
user_book: {
user_book_reads: [{ id: 404, started_at: '2024-03-29' }],
},
},
};
}
return {};
});
await client.pushProgress(book, config);
const requestCalls = requestSpy.mock.calls as RequestSpyCall[];
const updateReadCall = requestCalls.find((call) =>
String(call[0]).includes('mutation UpdateRead'),
);
const insertReadCall = requestCalls.find((call) =>
String(call[0]).includes('mutation InsertRead'),
);
expect(updateReadCall).toBeDefined();
expect(insertReadCall).toBeUndefined();
expect(updateReadCall?.[1]).toMatchObject({
id: 404,
progress_pages: 25,
edition_id: 101,
started_at: '2024-03-29',
});
});
test('should reuse the active read returned when adding a book through sync', async () => {
const book = {
createdAt: 1711737600000,
title: 'Test Book',
author: 'Test Author',
} as Book;
const config = { progress: [25, 100] } as BookConfig;
vi.spyOn(clientApi, 'fetchBookContext').mockResolvedValue({
editionId: 101,
pages: 100,
bookId: 202,
bookPages: 100,
userBook: null,
} as TestBookContext);
const requestSpy = vi.spyOn(clientApi, 'request').mockImplementation(async (query) => {
if (String(query).includes('mutation InsertUserBook')) {
return {
insert_user_book: {
user_book: {
id: 303,
user_book_reads: [{ id: 404, started_at: '2024-03-29' }],
},
},
};
}
return {};
});
await client.pushProgress(book, config);
const requestCalls = requestSpy.mock.calls as RequestSpyCall[];
const updateReadCall = requestCalls.find((call) =>
String(call[0]).includes('mutation UpdateRead'),
);
const insertReadCall = requestCalls.find((call) =>
String(call[0]).includes('mutation InsertRead'),
);
expect(updateReadCall).toBeDefined();
expect(insertReadCall).toBeUndefined();
expect(updateReadCall?.[1]).toMatchObject({
id: 404,
progress_pages: 25,
edition_id: 101,
started_at: '2024-03-29',
});
});
test('should scale progress pages from local percentage to Hardcover edition pages', async () => {
const book = {
createdAt: 1711737600000,
metadata: { isbn: '1234567890' },
} as Book;
const config = { progress: [25, 100] } as BookConfig;
vi.spyOn(clientApi, 'ensureBookInLibrary').mockResolvedValue({
editionId: 101,
pages: 400,
bookId: 202,
bookPages: 400,
userBook: {
id: 303,
status_id: 2,
user_book_reads: [],
},
});
const requestSpy = vi.spyOn(clientApi, 'request').mockResolvedValue({});
await client.pushProgress(book, config);
const requestCalls = requestSpy.mock.calls as RequestSpyCall[];
const insertReadCall = requestCalls.find((call) =>
String(call[0]).includes('mutation InsertRead'),
);
expect(insertReadCall).toBeDefined();
expect(insertReadCall?.[1]).toMatchObject({
user_book_id: 303,
edition_id: 101,
progress_pages: 100,
started_at: '2024-03-29',
});
});
test('should not promote an existing user book when syncing notes only', async () => {
const book = {
hash: 'book-hash',
@@ -390,4 +598,118 @@ describe('HardcoverClient', () => {
calls.some((call: { query: string }) => call.query.includes('mutation UpdateUserBook')),
).toBe(false);
});
test('should throw when insert_user_book returns a null user_book', async () => {
const book = { title: 'Test Book', author: 'Test Author' } as Book;
const config = { progress: [25, 100] } as BookConfig;
vi.spyOn(clientApi, 'fetchBookContext').mockResolvedValue({
editionId: 101,
pages: 100,
bookId: 202,
bookPages: 100,
userBook: null,
} as TestBookContext);
vi.spyOn(clientApi, 'request').mockImplementation(async (query) => {
if (String(query).includes('mutation InsertUserBook')) {
return { insert_user_book: { error: 'conflict', user_book: null } };
}
return {};
});
await expect(client.pushProgress(book, config)).rejects.toThrow('insert_user_book failed');
});
test('should skip progress push when Hardcover edition page count is unknown', async () => {
const book = { createdAt: 1711737600000, metadata: { isbn: '1234567890' } } as Book;
const config = { progress: [25, 100] } as BookConfig;
vi.spyOn(clientApi, 'ensureBookInLibrary').mockResolvedValue({
editionId: 101,
pages: null,
bookId: 202,
bookPages: null,
userBook: { id: 303, status_id: 2, user_book_reads: [] },
} as TestBookContext);
const requestSpy = vi.spyOn(clientApi, 'request').mockResolvedValue({});
await client.pushProgress(book, config);
const requestCalls = requestSpy.mock.calls as RequestSpyCall[];
const insertReadCall = requestCalls.find((call) =>
String(call[0]).includes('mutation InsertRead'),
);
const updateReadCall = requestCalls.find((call) =>
String(call[0]).includes('mutation UpdateRead'),
);
expect(insertReadCall).toBeUndefined();
expect(updateReadCall).toBeUndefined();
});
test('should apply edition preference when resolving context via title search', async () => {
const book = { title: 'Test Book', author: 'Test Author' } as Book;
// authenticate
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ data: { me: { id: 1 } } }),
});
// QUERY_SEARCH_BOOK
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: () =>
Promise.resolve({
data: {
search: {
results: [{ id: 202, pages: 300, featured_edition_id: 101 }],
},
},
}),
});
// QUERY_GET_BOOK_USER_DATA
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: () =>
Promise.resolve({
data: {
editions: [
{
book: {
id: 202,
pages: 300,
user_books: [
{
id: 303,
status_id: 2,
edition: { id: 404, pages: 310, reading_format_id: 1 },
user_book_reads: [
{
id: 505,
started_at: '2026-03-29',
edition: { id: 606, pages: 400, reading_format_id: 1 },
},
],
},
],
},
},
],
},
}),
});
const context = await clientApi.fetchBookContext(book);
expect(context).toMatchObject({
editionId: 606,
pages: 400,
bookId: 202,
bookPages: 300,
userBook: { id: 303 },
});
});
});
@@ -32,6 +32,7 @@ vi.mock('@/libs/storage', () => ({
}));
import { BaseAppService } from '@/services/appService';
import { buildBookLookupIndex } from '@/services/bookService';
// Concrete test subclass of BaseAppService with mocked fs
class TestAppService extends BaseAppService {
@@ -240,7 +241,7 @@ describe('importBook metaHash deduplication', () => {
fs.openFile.mockResolvedValue(new File(['content'], 'test.epub'));
// Transient import requires string file path
const result = await service.importBook('/path/to/test.epub', books, true, true, false, true);
const result = await service.importBook('/path/to/test.epub', books, { transient: true });
// Should create a new entry, not override existing
expect(result).not.toBe(existingBook);
@@ -593,3 +594,58 @@ describe('importBook metaHash aggregation', () => {
expect(writtenConfig.booknotes).toHaveLength(2);
});
});
describe('importBook with BookLookupIndex', () => {
let service: TestAppService;
beforeEach(() => {
vi.clearAllMocks();
service = new TestAppService();
const fs = service.getFs();
fs.exists.mockResolvedValue(false);
fs.createDir.mockResolvedValue(undefined);
fs.writeFile.mockResolvedValue(undefined);
fs.removeDir.mockResolvedValue(undefined);
fs.readFile.mockResolvedValue('{}');
});
it('updates the lookup index after a successful new-book import', async () => {
const books: Book[] = [];
const lookupIndex = buildBookLookupIndex(books);
mockPartialMD5.mockResolvedValue('imported-hash');
setupMockBookDoc();
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
const result = await service.importBook(mockFile, books, { lookupIndex });
expect(result).not.toBeNull();
expect(result?.hash).toBe('imported-hash');
// The lookup index must contain the freshly imported book
expect(lookupIndex.byHash.get('imported-hash')).toBe(result);
if (result?.metaHash) {
const key = `${result.metaHash}:${result.format}`;
expect(lookupIndex.byMetaKey.get(key)).toContain(result);
}
});
it('finds existing book via lookup index without scanning books array', async () => {
const metaHash = getMetadataHash(TEST_METADATA);
const existingBook = makeBook({ hash: 'existing', metaHash });
// Pass an EMPTY books array but a lookup index that already contains the book.
// If the implementation falls back to books.find(), it will fail to find the
// existing book and create a new one. If it consults the lookup index, it
// will discover the existing book and update it.
const books: Book[] = [];
const lookupIndex = buildBookLookupIndex([existingBook]);
mockPartialMD5.mockResolvedValue('existing'); // same hash as existing
setupMockBookDoc();
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
const result = await service.importBook(mockFile, books, { lookupIndex });
// Should reuse the existing book object via lookup index
expect(result).toBe(existingBook);
});
});
@@ -84,4 +84,63 @@ describe('RSVPController', () => {
expect(controller.currentState.words[0]!.text).toBe('Foo');
});
});
describe('currentDisplayWord', () => {
test('returns full word when splitHyphens is false', () => {
const doc = makeDoc('well-known');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.setSplitHyphens(false);
controller.start();
expect(controller.currentDisplayWord?.text).toBe('well-known');
});
test('returns first part only when splitHyphens is true', () => {
const doc = makeDoc('well-known');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.setSplitHyphens(true);
controller.start();
expect(controller.currentDisplayWord?.text).toBe('well-');
});
test('returns unsplit word when splitHyphens is true but no hyphen pattern', () => {
const doc = makeDoc('hello');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.setSplitHyphens(true);
controller.start();
expect(controller.currentDisplayWord?.text).toBe('hello');
});
});
describe('duplicate word blank insertion', () => {
test('inserts blank between two consecutive identical words', () => {
const doc = makeDoc('the the cat');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.start();
const words = controller.currentState.words;
expect(words[0]!.text).toBe('the');
expect(words[1]!.text).toBe(' ');
expect(words[2]!.text).toBe('the');
expect(words[3]!.text).toBe('cat');
});
test('does not insert blank between different words', () => {
const doc = makeDoc('the cat');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.start();
const words = controller.currentState.words;
expect(words.length).toBe(2);
expect(words[0]!.text).toBe('the');
expect(words[1]!.text).toBe('cat');
});
});
});
@@ -274,5 +274,17 @@ describe('rsvp/utils', () => {
test('returns trailing-hyphen word unchanged', () => {
expect(getHyphenParts('word-')).toEqual(['word-']);
});
test('splits on ellipsis between letters with trailing ellipsis on non-last parts', () => {
expect(getHyphenParts('a...b')).toEqual(['a...', 'b']);
});
test('splits mixed hyphens and ellipses preserving each delimiter', () => {
expect(getHyphenParts('foo-bar...baz')).toEqual(['foo-', 'bar...', 'baz']);
});
test('returns ellipsis-only unchanged', () => {
expect(getHyphenParts('...')).toEqual(['...']);
});
});
});
@@ -61,7 +61,7 @@ export function bookTests(
const first = await service.importBook(file1, books);
const file2 = await getBookFile('sample-alice.epub');
const second = await service.importBook(file2, books, true, true, true);
const second = await service.importBook(file2, books, { overwrite: true });
expect(books).toHaveLength(1);
expect(second!.hash).toBe(first!.hash);
@@ -10,7 +10,19 @@ vi.mock('@/utils/md5', () => ({
import { useBookDataStore } from '@/store/bookDataStore';
import type { BookData } from '@/store/bookDataStore';
import type { BookConfig, BookNote } from '@/types/book';
import type { BookConfig, BookNote, Book } from '@/types/book';
import { useLibraryStore } from '@/store/libraryStore';
import type { EnvConfigType } from '@/services/environment';
import type { AppService } from '@/types/system';
import type { SystemSettings } from '@/types/settings';
function makeEnvConfig(appService: Partial<AppService>): EnvConfigType {
return {
getAppService: vi.fn().mockResolvedValue(appService as AppService),
};
}
const FAKE_SETTINGS = {} as unknown as SystemSettings;
function makeBookData(id: string, config?: Partial<BookConfig>): BookData {
return {
@@ -273,4 +285,120 @@ describe('bookDataStore', () => {
expect(config!.booknotes).toHaveLength(3);
});
});
describe('saveConfig', () => {
function makeLibraryBook(overrides: Partial<Book> = {}): Book {
return {
hash: 'h1',
format: 'EPUB',
title: 'Book',
author: 'Author',
createdAt: 1000,
updatedAt: 1000,
...overrides,
};
}
test('creates a new library array reference (Zustand change-detection)', async () => {
const saveBookConfig = vi.fn().mockResolvedValue(undefined);
const saveLibraryBooks = vi.fn().mockResolvedValue(undefined);
const envConfig = makeEnvConfig({ saveBookConfig, saveLibraryBooks });
const book = makeLibraryBook({ hash: 'h1' });
useLibraryStore.getState().setLibrary([book]);
const before = useLibraryStore.getState().library;
const data = makeBookData('h1', { progress: [10, 100] });
useBookDataStore.setState({ booksData: { h1: data } });
await useBookDataStore.getState().saveConfig(envConfig, 'h1', data.config!, FAKE_SETTINGS);
const after = useLibraryStore.getState().library;
expect(after).not.toBe(before);
});
test('moves the saved book to the front of the library', async () => {
const saveBookConfig = vi.fn().mockResolvedValue(undefined);
const saveLibraryBooks = vi.fn().mockResolvedValue(undefined);
const envConfig = makeEnvConfig({ saveBookConfig, saveLibraryBooks });
useLibraryStore
.getState()
.setLibrary([
makeLibraryBook({ hash: 'a' }),
makeLibraryBook({ hash: 'b' }),
makeLibraryBook({ hash: 'c' }),
]);
const data = makeBookData('c', { progress: [5, 100] });
useBookDataStore.setState({ booksData: { c: data } });
await useBookDataStore.getState().saveConfig(envConfig, 'c', data.config!, FAKE_SETTINGS);
const library = useLibraryStore.getState().library;
expect(library.map((b) => b.hash)).toEqual(['c', 'a', 'b']);
// hashIndex should be rebuilt to match the new order
expect(useLibraryStore.getState().hashIndex.get('c')).toBe(0);
expect(useLibraryStore.getState().hashIndex.get('a')).toBe(1);
expect(useLibraryStore.getState().hashIndex.get('b')).toBe(2);
});
test('updates visibleLibrary to match the new library order', async () => {
const saveBookConfig = vi.fn().mockResolvedValue(undefined);
const saveLibraryBooks = vi.fn().mockResolvedValue(undefined);
const envConfig = makeEnvConfig({ saveBookConfig, saveLibraryBooks });
useLibraryStore
.getState()
.setLibrary([
makeLibraryBook({ hash: 'a' }),
makeLibraryBook({ hash: 'b', deletedAt: 999 }),
makeLibraryBook({ hash: 'c' }),
]);
const data = makeBookData('c', { progress: [5, 100] });
useBookDataStore.setState({ booksData: { c: data } });
await useBookDataStore.getState().saveConfig(envConfig, 'c', data.config!, FAKE_SETTINGS);
const visible = useLibraryStore.getState().getVisibleLibrary();
expect(visible.map((b) => b.hash)).toEqual(['c', 'a']);
});
test('persists progress and writes the library', async () => {
const saveBookConfig = vi.fn().mockResolvedValue(undefined);
const saveLibraryBooks = vi.fn().mockResolvedValue(undefined);
const envConfig = makeEnvConfig({ saveBookConfig, saveLibraryBooks });
useLibraryStore.getState().setLibrary([makeLibraryBook({ hash: 'h1' })]);
const data = makeBookData('h1', { progress: [42, 100] });
useBookDataStore.setState({ booksData: { h1: data } });
await useBookDataStore.getState().saveConfig(envConfig, 'h1', data.config!, FAKE_SETTINGS);
const stored = useLibraryStore.getState().getBookByHash('h1');
expect(stored?.progress).toEqual([42, 100]);
expect(saveBookConfig).toHaveBeenCalledOnce();
expect(saveLibraryBooks).toHaveBeenCalledOnce();
});
test('does nothing for unknown book hash', async () => {
const saveBookConfig = vi.fn().mockResolvedValue(undefined);
const saveLibraryBooks = vi.fn().mockResolvedValue(undefined);
const envConfig = makeEnvConfig({ saveBookConfig, saveLibraryBooks });
useLibraryStore.getState().setLibrary([makeLibraryBook({ hash: 'h1' })]);
const data = makeBookData('nonexistent', { progress: [1, 100] });
useBookDataStore.setState({ booksData: { nonexistent: data } });
await useBookDataStore
.getState()
.saveConfig(envConfig, 'nonexistent', data.config!, FAKE_SETTINGS);
expect(saveBookConfig).not.toHaveBeenCalled();
expect(saveLibraryBooks).not.toHaveBeenCalled();
});
});
});
@@ -10,6 +10,14 @@ vi.mock('@/utils/md5', () => ({
import { useLibraryStore } from '@/store/libraryStore';
import type { Book, BooksGroup } from '@/types/book';
import type { EnvConfigType } from '@/services/environment';
import type { AppService } from '@/types/system';
function makeEnvConfig(appService: Partial<AppService>): EnvConfigType {
return {
getAppService: vi.fn().mockResolvedValue(appService as AppService),
};
}
function makeBook(overrides: Partial<Book> = {}): Book {
return {
@@ -33,6 +41,8 @@ describe('libraryStore', () => {
currentBookshelf: [],
selectedBooks: new Set(),
groups: {},
hashIndex: new Map(),
visibleLibrary: [],
});
});
@@ -46,6 +56,16 @@ describe('libraryStore', () => {
expect(state.libraryLoaded).toBe(true);
});
test('builds hash index on setLibrary', () => {
const books = [makeBook({ hash: 'a' }), makeBook({ hash: 'b' })];
useLibraryStore.getState().setLibrary(books);
const state = useLibraryStore.getState();
expect(state.hashIndex.get('a')).toBe(0);
expect(state.hashIndex.get('b')).toBe(1);
expect(state.hashIndex.size).toBe(2);
});
test('calls refreshGroups after setting library', () => {
const book = makeBook({ hash: 'a', groupName: 'Fiction' });
useLibraryStore.getState().setLibrary([book]);
@@ -56,14 +76,153 @@ describe('libraryStore', () => {
});
});
describe('getVisibleLibrary', () => {
test('filters out books with deletedAt set', () => {
describe('getBookByHash', () => {
test('returns the book for a known hash', () => {
const books = [makeBook({ hash: 'a', title: 'Book A' }), makeBook({ hash: 'b' })];
useLibraryStore.getState().setLibrary(books);
expect(useLibraryStore.getState().getBookByHash('a')?.title).toBe('Book A');
});
test('returns undefined for unknown hash', () => {
useLibraryStore.getState().setLibrary([makeBook({ hash: 'a' })]);
expect(useLibraryStore.getState().getBookByHash('nonexistent')).toBeUndefined();
});
});
describe('updateBookProgress', () => {
test('updates progress and explicitly clears readingStatus when undefined is passed', () => {
const books = [makeBook({ hash: 'a', progress: [1, 100], readingStatus: 'unread' })];
useLibraryStore.getState().setLibrary(books);
useLibraryStore.getState().updateBookProgress('a', [50, 100], undefined);
const book = useLibraryStore.getState().getBookByHash('a');
expect(book?.progress).toEqual([50, 100]);
expect(book?.readingStatus).toBeUndefined();
expect(book?.updatedAt).toBeGreaterThan(0);
});
test('does nothing for unknown hash', () => {
useLibraryStore.getState().setLibrary([makeBook({ hash: 'a' })]);
useLibraryStore.getState().updateBookProgress('nonexistent', [1, 1], undefined);
expect(useLibraryStore.getState().library).toHaveLength(1);
});
test('marks book as finished at 100%', () => {
const books = [makeBook({ hash: 'a' })];
useLibraryStore.getState().setLibrary(books);
useLibraryStore.getState().updateBookProgress('a', [100, 100], 'finished');
expect(useLibraryStore.getState().getBookByHash('a')?.readingStatus).toBe('finished');
});
test('creates a new library array reference (Zustand change-detection)', () => {
useLibraryStore.getState().setLibrary([makeBook({ hash: 'a' })]);
const before = useLibraryStore.getState().library;
useLibraryStore.getState().updateBookProgress('a', [50, 100], undefined);
const after = useLibraryStore.getState().library;
expect(after).not.toBe(before);
});
test('replaces the book entry with a new object (no in-place mutation)', () => {
const original = makeBook({ hash: 'a', progress: [1, 100] });
useLibraryStore.getState().setLibrary([original]);
useLibraryStore.getState().updateBookProgress('a', [50, 100], undefined);
// Original reference must NOT be mutated
expect(original.progress).toEqual([1, 100]);
// Store should have a new book object
expect(useLibraryStore.getState().getBookByHash('a')).not.toBe(original);
});
test('updates visibleLibrary cache so callers see fresh progress', () => {
useLibraryStore.getState().setLibrary([makeBook({ hash: 'a', progress: [1, 100] })]);
useLibraryStore.getState().updateBookProgress('a', [42, 100], undefined);
const visible = useLibraryStore.getState().getVisibleLibrary();
expect(visible).toHaveLength(1);
expect(visible[0]?.progress).toEqual([42, 100]);
});
test('does not include deleted books in visibleLibrary after update', () => {
const books = [
makeBook({ hash: 'a', deletedAt: null }),
makeBook({ hash: 'a' }),
makeBook({ hash: 'b', deletedAt: 12345 }),
makeBook({ hash: 'c' }),
];
useLibraryStore.setState({ library: books });
useLibraryStore.getState().setLibrary(books);
useLibraryStore.getState().updateBookProgress('a', [10, 100], undefined);
const visible = useLibraryStore.getState().getVisibleLibrary();
expect(visible.map((b) => b.hash)).toEqual(['a', 'c']);
});
});
describe('updateBooks', () => {
test('persists by default', async () => {
const saveLibraryBooks = vi.fn().mockResolvedValue(undefined);
const envConfig = makeEnvConfig({ saveLibraryBooks });
await useLibraryStore.getState().updateBooks(envConfig, [makeBook({ hash: 'a' })]);
expect(saveLibraryBooks).toHaveBeenCalledTimes(1);
});
test('skips persistence when skipSave: true', async () => {
const saveLibraryBooks = vi.fn().mockResolvedValue(undefined);
const envConfig = makeEnvConfig({ saveLibraryBooks });
await useLibraryStore
.getState()
.updateBooks(envConfig, [makeBook({ hash: 'a' })], { skipSave: true });
expect(saveLibraryBooks).not.toHaveBeenCalled();
});
test('still updates store state when skipSave: true', async () => {
const saveLibraryBooks = vi.fn().mockResolvedValue(undefined);
const envConfig = makeEnvConfig({ saveLibraryBooks });
await useLibraryStore
.getState()
.updateBooks(envConfig, [makeBook({ hash: 'a' })], { skipSave: true });
expect(useLibraryStore.getState().library).toHaveLength(1);
expect(useLibraryStore.getState().getBookByHash('a')).toBeDefined();
});
});
describe('rebuildHashIndex', () => {
test('rebuilds index after manual array mutation', () => {
const books = [makeBook({ hash: 'a' }), makeBook({ hash: 'b' })];
useLibraryStore.getState().setLibrary(books);
// Simulate manual splice + unshift (like saveConfig does)
const { library } = useLibraryStore.getState();
const [book] = library.splice(1, 1);
library.unshift(book!);
useLibraryStore.getState().rebuildHashIndex();
const state = useLibraryStore.getState();
expect(state.hashIndex.get('b')).toBe(0);
expect(state.hashIndex.get('a')).toBe(1);
});
});
describe('getVisibleLibrary', () => {
test('filters out books with deletedAt set', () => {
const bookA = makeBook({ hash: 'a', deletedAt: null });
const bookB = makeBook({ hash: 'b', deletedAt: 12345 });
const bookC = makeBook({ hash: 'c' });
const books = [bookA, bookB, bookC];
useLibraryStore.setState({ library: books, visibleLibrary: [bookA, bookC] });
const visible = useLibraryStore.getState().getVisibleLibrary();
expect(visible).toHaveLength(2);
@@ -72,7 +231,7 @@ describe('libraryStore', () => {
test('returns all books when none are deleted', () => {
const books = [makeBook({ hash: 'a' }), makeBook({ hash: 'b' })];
useLibraryStore.setState({ library: books });
useLibraryStore.setState({ library: books, visibleLibrary: books });
const visible = useLibraryStore.getState().getVisibleLibrary();
expect(visible).toHaveLength(2);
@@ -26,7 +26,11 @@ vi.mock('@/store/libraryStore', () => {
return {
useLibraryStore: create(() => ({
library: [],
hashIndex: new Map(),
setLibrary: vi.fn(),
getBookByHash: vi.fn(),
updateBookProgress: vi.fn(),
rebuildHashIndex: vi.fn(),
})),
};
});
@@ -66,7 +66,6 @@ describe('settingsStore', () => {
settings: {} as SystemSettings,
settingsDialogBookKey: '',
isSettingsDialogOpen: false,
isSettingsGlobal: true,
fontPanelView: 'main-fonts',
activeSettingsItemId: null,
});
@@ -118,19 +117,6 @@ describe('settingsStore', () => {
});
});
describe('setSettingsGlobal', () => {
test('sets global mode to true', () => {
useSettingsStore.getState().setSettingsGlobal(false);
useSettingsStore.getState().setSettingsGlobal(true);
expect(useSettingsStore.getState().isSettingsGlobal).toBe(true);
});
test('sets global mode to false', () => {
useSettingsStore.getState().setSettingsGlobal(false);
expect(useSettingsStore.getState().isSettingsGlobal).toBe(false);
});
});
describe('setFontPanelView', () => {
test('sets to main-fonts', () => {
useSettingsStore.getState().setFontPanelView('main-fonts');
@@ -149,6 +149,43 @@ describe('themeStore', () => {
expect(state.systemIsDarkMode).toBe(true);
expect(state.isDarkMode).toBe(false);
});
test('updates themeCode when system theme changes to dark', async () => {
const styleModule = await import('@/utils/style');
const mockGetThemeCode = vi.mocked(styleModule.getThemeCode);
const darkThemeCode = {
bg: '#1a1a1a',
fg: '#ffffff',
primary: '#4d9fff',
palette: {
'base-100': '#1a1a1a',
'base-200': '#2a2a2a',
'base-300': '#3a3a3a',
'base-content': '#ffffff',
neutral: '#333333',
'neutral-content': '#ffffff',
primary: '#4d9fff',
secondary: '#6c757d',
accent: '#4d9fff',
},
isDarkMode: true,
};
mockGetThemeCode.mockReturnValueOnce(darkThemeCode);
useThemeStore.setState({ themeMode: 'auto' });
useThemeStore.getState().handleSystemThemeChange(true);
expect(useThemeStore.getState().themeCode).toEqual(darkThemeCode);
});
test('updates data-theme attribute when system theme changes', () => {
useThemeStore.setState({ themeMode: 'auto', themeColor: 'default' });
useThemeStore.getState().handleSystemThemeChange(true);
expect(document.documentElement.getAttribute('data-theme')).toBe('default-dark');
useThemeStore.getState().handleSystemThemeChange(false);
expect(document.documentElement.getAttribute('data-theme')).toBe('default-light');
});
});
describe('showSystemUI / dismissSystemUI', () => {
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { isLanAddress } from '@/utils/network';
/**
* Tests for SSRF protection in the kosync proxy.
* The proxy must reject requests to private/internal addresses.
* See: https://github.com/readest/readest/security/code-scanning/14
*/
describe('isLanAddress SSRF edge cases for proxy', () => {
// 0.0.0.0 routes to localhost on many systems
it('returns true for 0.0.0.0', () => {
expect(isLanAddress('http://0.0.0.0')).toBe(true);
expect(isLanAddress('http://0.0.0.0:8080')).toBe(true);
});
// Cloud metadata endpoint (169.254.x.x is already covered, but test the exact AWS one)
it('returns true for cloud metadata IP 169.254.169.254', () => {
expect(isLanAddress('http://169.254.169.254')).toBe(true);
expect(isLanAddress('http://169.254.169.254/latest/meta-data/')).toBe(true);
});
// IPv6 loopback with brackets (URL standard format)
it('returns true for bracket-wrapped IPv6 loopback [::1]', () => {
expect(isLanAddress('http://[::1]')).toBe(true);
expect(isLanAddress('http://[::1]:8080')).toBe(true);
});
// IPv6 link-local with brackets
it('returns true for bracket-wrapped IPv6 link-local [fe80::1]', () => {
expect(isLanAddress('http://[fe80::1]')).toBe(true);
});
// IPv6 unique local with brackets
it('returns true for bracket-wrapped IPv6 unique local [fc00::1] and [fd00::1]', () => {
expect(isLanAddress('http://[fc00::1]')).toBe(true);
expect(isLanAddress('http://[fd00::abc]')).toBe(true);
});
// Public addresses should still return false
it('returns false for public addresses', () => {
expect(isLanAddress('https://sync.koreader.rocks')).toBe(false);
expect(isLanAddress('https://8.8.8.8')).toBe(false);
expect(isLanAddress('http://[2001:db8::1]')).toBe(false);
});
});
@@ -76,15 +76,15 @@ describe('isLanAddress', () => {
// IPv6 private addresses
// -----------------------------------------------------------------------
describe('IPv6 private addresses', () => {
// Note: URL.hostname wraps IPv6 addresses in brackets (e.g., '[::1]'),
// so the current implementation's startsWith checks won't match bracket-
// wrapped addresses. These tests document the actual behavior.
it('returns false for bracket-wrapped IPv6 (URL standard hostname format)', () => {
// URL('http://[::1]').hostname === '[::1]' which doesn't match startsWith('::1')
expect(isLanAddress('http://[::1]')).toBe(false);
expect(isLanAddress('http://[fe80::1]')).toBe(false);
expect(isLanAddress('http://[fc00::1]')).toBe(false);
expect(isLanAddress('http://[fd00::abc]')).toBe(false);
it('returns true for bracket-wrapped IPv6 loopback', () => {
expect(isLanAddress('http://[::1]')).toBe(true);
expect(isLanAddress('http://[::1]:8080')).toBe(true);
});
it('returns true for bracket-wrapped IPv6 link-local and unique local', () => {
expect(isLanAddress('http://[fe80::1]')).toBe(true);
expect(isLanAddress('http://[fc00::1]')).toBe(true);
expect(isLanAddress('http://[fd00::abc]')).toBe(true);
});
});
@@ -368,6 +368,58 @@ describe('getLayoutStyles branches (via getStyles)', () => {
// getStyles passes 1.0 as zoomLevel to getLayoutStyles
expect(css).toContain('zoom: 1');
});
it('omits only paragraph-related layout rules when useBookLayout is true', () => {
const vs = makeViewSettings({
useBookLayout: true,
lineHeight: 1.7,
wordSpacing: 3,
letterSpacing: 2,
textIndent: 2,
paragraphMargin: 1.25,
fullJustification: true,
hyphenation: true,
marginTopPx: 50,
});
const css = getStyles(vs, theme);
// paragraph-specific tokens driven by the Paragraph section must be absent
expect(css).not.toContain('--default-text-align:');
expect(css).not.toContain('line-height: 1.7');
expect(css).not.toContain('word-spacing: 3px');
expect(css).not.toContain('letter-spacing: 2px');
expect(css).not.toContain('text-indent: 2em');
expect(css).not.toContain('hyphens: auto');
expect(css).not.toContain('-webkit-hyphens: auto');
// non-paragraph layout rules must still be emitted
expect(css).toContain('@namespace epub');
expect(css).toContain('--margin-top: 50px');
expect(css).toContain('--margin-right:');
expect(css).toContain('--margin-bottom:');
expect(css).toContain('--margin-left:');
// font/color/translation sections must still be present
expect(css).toContain('--serif:');
expect(css).toContain('--theme-bg-color');
expect(css).toContain('.translation-source');
});
it('includes paragraph layout rules when useBookLayout is false', () => {
const vs = makeViewSettings({
useBookLayout: false,
lineHeight: 1.7,
wordSpacing: 3,
letterSpacing: 2,
textIndent: 2,
fullJustification: true,
hyphenation: true,
});
const css = getStyles(vs, theme);
expect(css).toContain('--default-text-align: justify');
expect(css).toContain('line-height: 1.7');
expect(css).toContain('word-spacing: 3px');
expect(css).toContain('letter-spacing: 2px');
expect(css).toContain('text-indent: 2em');
expect(css).toContain('hyphens: auto');
});
});
// ---------------------------------------------------------------------------
@@ -245,19 +245,17 @@ describe('transformStylesheet', () => {
it('adds width when both left and right bleed', () => {
const css = '.bleed { duokan-bleed: left right; }';
const result = transformStylesheet(css, VW, VH, VERTICAL);
expect(result).toContain(
'width: calc(var(--_max-width) + var(--page-margin-left) + var(--page-margin-right)) !important',
);
expect(result).toContain('max-width: calc(var(--full-width) * 1px) !important');
expect(result).toContain('width: calc(var(--available-width) * 1px) !important');
expect(result).toContain('min-width: calc(var(--available-width) * 1px) !important');
expect(result).toContain('max-width: calc(var(--available-width) * 1px) !important');
});
it('adds height when both top and bottom bleed', () => {
const css = '.bleed { duokan-bleed: top bottom; }';
const result = transformStylesheet(css, VW, VH, VERTICAL);
expect(result).toContain(
'height: calc(100% + var(--page-margin-top) + var(--page-margin-bottom)) !important',
);
expect(result).toContain('max-height: calc(var(--full-height) * 1px) !important');
expect(result).toContain('height: calc(var(--available-height) * 1px) !important');
expect(result).toContain('min-height: calc(var(--available-height) * 1px) !important');
expect(result).toContain('max-height: calc(var(--available-height) * 1px) !important');
});
it('does not add bleed styles when vertical is true', () => {
@@ -268,6 +266,46 @@ describe('transformStylesheet', () => {
});
});
describe('hardcoded pixel width clamping', () => {
it('adds max-width and border-box when width exceeds viewport', () => {
const css = '.calibre8 { display: block; width: 1200px; padding: 2em 0 0 1em; }';
const result = transformStylesheet(css, VW, VH, VERTICAL);
expect(result).toContain('max-width: calc(var(--available-width) * 1px)');
expect(result).toContain('box-sizing: border-box');
});
it('does not clamp when width is smaller than viewport', () => {
const css = '.box { width: 450px; padding: 2em; }';
const result = transformStylesheet(css, VW, VH, VERTICAL);
expect(result).not.toContain('max-width: calc(var(--available-width)');
});
it('does not add max-width when one already exists', () => {
const css = '.box { width: 1200px; max-width: 100%; }';
const result = transformStylesheet(css, VW, VH, VERTICAL);
const matches = result.match(/max-width/g);
expect(matches).toHaveLength(1);
});
it('does not affect max-width or min-width properties', () => {
const css = '.box { max-width: 1200px; min-width: 200px; }';
const result = transformStylesheet(css, VW, VH, VERTICAL);
expect(result).not.toContain('max-width: calc(var(--available-width)');
});
it('does not add max-width for non-pixel width values', () => {
const css = '.box { width: 50%; }';
const result = transformStylesheet(css, VW, VH, VERTICAL);
expect(result).not.toContain('max-width: calc(var(--available-width)');
});
it('does not add max-width for em width values', () => {
const css = '.box { width: 20em; }';
const result = transformStylesheet(css, VW, VH, VERTICAL);
expect(result).not.toContain('max-width: calc(var(--available-width)');
});
});
describe('preserves unrelated CSS', () => {
it('passes through CSS without any matching patterns unchanged', () => {
const css = '.custom { display: flex; padding: 10px; margin: 5px; }';
@@ -1,7 +1,12 @@
import { describe, it, expect } from 'vitest';
import { transformBookNoteToDB, transformBookNoteFromDB } from '@/utils/transform';
import { BookNote } from '@/types/book';
import { DBBookNote } from '@/types/records';
import {
transformBookNoteToDB,
transformBookNoteFromDB,
transformBookConfigToDB,
transformBookConfigFromDB,
} from '@/utils/transform';
import { BookConfig, BookNote } from '@/types/book';
import { DBBookConfig, DBBookNote } from '@/types/records';
describe('transformBookNoteToDB with xpointer fields', () => {
it('passes through xpointer0 and xpointer1', () => {
@@ -112,3 +117,59 @@ describe('transformBookNoteFromDB with xpointer fields', () => {
expect(note.xpointer1).toBe('/body/DocFragment[1]/body/p[1]/text().20');
});
});
describe('transformBookConfigToDB / transformBookConfigFromDB rsvpPosition', () => {
const baseConfig: BookConfig = {
bookHash: 'hash1',
updatedAt: 1700000000000,
};
it('serializes rsvpPosition to JSON string in DB record', () => {
const config: BookConfig = {
...baseConfig,
rsvpPosition: { cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'hello' },
};
const db = transformBookConfigToDB(config, 'user1');
expect(db.rsvp_position).toBe(
JSON.stringify({ cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'hello' }),
);
});
it('omits rsvp_position when rsvpPosition is undefined', () => {
const db = transformBookConfigToDB(baseConfig, 'user1');
expect(db.rsvp_position).toBeUndefined();
});
it('deserializes rsvp_position from DB record', () => {
const dbConfig: DBBookConfig = {
user_id: 'user1',
book_hash: 'hash1',
rsvp_position: JSON.stringify({ cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'hello' }),
updated_at: '2023-11-14T22:13:20.000Z',
};
const config = transformBookConfigFromDB(dbConfig);
expect(config.rsvpPosition).toEqual({ cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'hello' });
});
it('leaves rsvpPosition undefined when rsvp_position is absent from DB', () => {
const dbConfig: DBBookConfig = {
user_id: 'user1',
book_hash: 'hash1',
updated_at: '2023-11-14T22:13:20.000Z',
};
const config = transformBookConfigFromDB(dbConfig);
expect(config.rsvpPosition).toBeUndefined();
});
it('round-trips rsvpPosition through DB transform', () => {
const config: BookConfig = {
...baseConfig,
rsvpPosition: { cfi: 'epubcfi(/6/8!/4/2/3:5)', wordText: 'world' },
};
const db = transformBookConfigToDB(config, 'user1');
// Simulate what DB returns (updated_at as ISO string)
const dbRecord: DBBookConfig = { ...db, updated_at: new Date(config.updatedAt).toISOString() };
const restored = transformBookConfigFromDB(dbRecord);
expect(restored.rsvpPosition).toEqual(config.rsvpPosition);
});
});
@@ -63,8 +63,8 @@ const BookItem: React.FC<BookItemProps> = ({
>
<div
className={clsx(
'bookitem-main relative flex aspect-[28/41] justify-center rounded',
coverFit === 'crop' && 'overflow-hidden shadow-md',
'bookitem-main relative flex aspect-[28/41] justify-center overflow-hidden rounded',
coverFit === 'crop' && 'shadow-md',
mode === 'grid' && 'items-end',
mode === 'list' && 'min-w-20 items-center',
)}
@@ -3,6 +3,16 @@ import * as React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PiPlus } from 'react-icons/pi';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import 'overlayscrollbars/overlayscrollbars.css';
import {
Virtuoso,
VirtuosoGrid,
type Components,
type GridComponents,
type GridListProps,
type ListProps,
} from 'react-virtuoso';
import { Book, BooksGroup, ReadingStatus } from '@/types/book';
import {
LibraryCoverFitType,
@@ -46,6 +56,7 @@ interface BookshelfProps {
isSelectMode: boolean;
isSelectAll: boolean;
isSelectNone: boolean;
onScrollerRef: (el: HTMLDivElement | null) => void;
handleImportBooks: () => void;
handleBookDownload: (
book: Book,
@@ -60,11 +71,67 @@ interface BookshelfProps {
booksTransferProgress: { [key: string]: number | null };
}
/**
* Context passed to the custom Virtuoso `List` components so they can render
* grid styles that depend on runtime settings without being re-created on
* every Bookshelf render (which would break Virtuoso's component identity).
*/
type BookshelfListContext = {
autoColumns: boolean;
fixedColumns: number;
};
const BOOKSHELF_GRID_CLASSES =
'bookshelf-items transform-wrapper grid gap-x-4 px-4 sm:gap-x-0 sm:px-2 ' +
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12';
const BOOKSHELF_LIST_CLASSES = 'bookshelf-items transform-wrapper flex flex-col';
const BookshelfGridList: GridComponents<BookshelfListContext>['List'] = React.forwardRef<
HTMLDivElement,
GridListProps & { context?: BookshelfListContext }
>(({ children, className, style, context, 'data-testid': testId }, ref) => (
<div
ref={ref}
data-testid={testId}
className={clsx(BOOKSHELF_GRID_CLASSES, className)}
style={{
...style,
gridTemplateColumns:
context && !context.autoColumns
? `repeat(${context.fixedColumns}, minmax(0, 1fr))`
: undefined,
}}
>
{children}
</div>
));
BookshelfGridList.displayName = 'BookshelfGridList';
const BookshelfLinearList: Components['List'] = React.forwardRef<HTMLDivElement, ListProps>(
({ children, style, 'data-testid': testId }, ref) => (
<div ref={ref} data-testid={testId} className={BOOKSHELF_LIST_CLASSES} style={style}>
{children}
</div>
),
);
BookshelfLinearList.displayName = 'BookshelfLinearList';
const GRID_VIRTUOSO_COMPONENTS: GridComponents<BookshelfListContext> = {
List: BookshelfGridList,
Footer: () => <div style={{ height: 34 }} />,
};
const LIST_VIRTUOSO_COMPONENTS: Components = {
List: BookshelfLinearList,
Footer: () => <div style={{ height: 34 }} />,
};
const Bookshelf: React.FC<BookshelfProps> = ({
libraryBooks,
isSelectMode,
isSelectAll,
isSelectNone,
onScrollerRef,
handleImportBooks,
handleBookUpload,
handleBookDownload,
@@ -395,62 +462,65 @@ const Bookshelf: React.FC<BookshelfProps> = ({
};
}, []);
const selectedBooks = getSelectedBooks();
// OverlayScrollbars + Virtuoso integration: Virtuoso manages its own
// scroller; OverlayScrollbars wraps it for overlay scrollbar rendering.
const osRootRef = useRef<HTMLDivElement>(null);
const [scroller, setScroller] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars({
defer: true,
options: { scrollbars: { autoHide: 'scroll' } },
events: {
initialized(instance) {
const { viewport } = instance.elements();
viewport.style.overflowX = 'var(--os-viewport-overflow-x)';
viewport.style.overflowY = 'var(--os-viewport-overflow-y)';
},
},
});
return (
<div className='bookshelf'>
<div
ref={autofocusRef}
tabIndex={-1}
className={clsx(
'bookshelf-items transform-wrapper focus:outline-none',
viewMode === 'grid' && 'grid flex-1 grid-cols-3 gap-x-4 px-4 sm:gap-x-0 sm:px-2',
viewMode === 'grid' && 'sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
viewMode === 'list' && 'flex flex-col',
)}
style={{
gridTemplateColumns:
viewMode === 'grid' && !settings.libraryAutoColumns
? `repeat(${settings.libraryColumns}, minmax(0, 1fr))`
: undefined,
}}
role='main'
aria-label={_('Bookshelf')}
>
{sortedBookshelfItems.map((item) => (
<BookshelfItem
key={`library-item-${'hash' in item ? item.hash : item.id}`}
item={item}
mode={viewMode as LibraryViewModeType}
coverFit={coverFit as LibraryCoverFitType}
isSelectMode={isSelectMode}
itemSelected={
'hash' in item ? selectedBooks.includes(item.hash) : selectedBooks.includes(item.id)
}
setLoading={setLoading}
toggleSelection={toggleSelection}
handleGroupBooks={groupSelectedBooks}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
handleLibraryNavigation={handleLibraryNavigation}
handleUpdateReadingStatus={handleUpdateReadingStatus}
transferProgress={
'hash' in item ? booksTransferProgress[(item as Book).hash] || null : null
}
/>
))}
{viewMode === 'grid' && currentBookshelfItems.length > 0 && (
useEffect(() => {
const root = osRootRef.current;
if (scroller && root) {
initialize({ target: root, elements: { viewport: scroller } });
}
return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]);
// Expose the Virtuoso scroller to the parent for pull-to-refresh & scroll save.
const handleScrollerRef = useCallback(
(el: HTMLElement | Window | null) => {
const div = el instanceof HTMLElement ? el : null;
setScroller(div);
onScrollerRef(div as HTMLDivElement | null);
},
[onScrollerRef],
);
const selectedBooks = getSelectedBooks();
const isGridMode = viewMode === 'grid';
const hasItems = sortedBookshelfItems.length > 0;
// In grid mode the Import-Books "+" tile is rendered as an extra grid cell
// after all books. We represent it to Virtuoso as an extra index past the
// last book; list mode doesn't have an import tile.
const gridTotalCount = hasItems ? sortedBookshelfItems.length + 1 : 0;
const listContext = useMemo<BookshelfListContext>(
() => ({
autoColumns: settings.libraryAutoColumns,
fixedColumns: settings.libraryColumns,
}),
[settings.libraryAutoColumns, settings.libraryColumns],
);
const renderBookshelfItem = useCallback(
(index: number) => {
if (isGridMode && index === sortedBookshelfItems.length) {
return (
<div
className={clsx('bookshelf-import-item mx-0 my-2 sm:mx-4 sm:my-4')}
style={
coverFit === 'fit' && viewMode === 'grid'
? {
display: 'flex',
paddingBottom: `${iconSize15 + 24}px`,
}
coverFit === 'fit'
? { display: 'flex', paddingBottom: `${iconSize15 + 24}px` }
: undefined
}
>
@@ -468,6 +538,98 @@ const Bookshelf: React.FC<BookshelfProps> = ({
</div>
</button>
</div>
);
}
const item = sortedBookshelfItems[index];
if (!item) return null;
const itemSelected =
'hash' in item ? selectedBooks.includes(item.hash) : selectedBooks.includes(item.id);
return (
<BookshelfItem
item={item}
mode={viewMode as LibraryViewModeType}
coverFit={coverFit as LibraryCoverFitType}
isSelectMode={isSelectMode}
itemSelected={itemSelected}
setLoading={setLoading}
toggleSelection={toggleSelection}
handleGroupBooks={groupSelectedBooks}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
handleLibraryNavigation={handleLibraryNavigation}
handleUpdateReadingStatus={handleUpdateReadingStatus}
transferProgress={
'hash' in item ? booksTransferProgress[(item as Book).hash] || null : null
}
/>
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
sortedBookshelfItems,
selectedBooks,
isGridMode,
viewMode,
coverFit,
isSelectMode,
booksTransferProgress,
iconSize15,
handleImportBooks,
toggleSelection,
handleBookUpload,
handleBookDownload,
handleBookDelete,
handleSetSelectMode,
handleShowDetailsBook,
handleLibraryNavigation,
handleUpdateReadingStatus,
],
);
const computeItemKey = useCallback(
(index: number) => {
if (isGridMode && index === sortedBookshelfItems.length) {
return 'library-import-tile';
}
const item = sortedBookshelfItems[index];
if (!item) return `library-item-${index}`;
return `library-item-${'hash' in item ? item.hash : item.id}`;
},
[sortedBookshelfItems, isGridMode],
);
return (
<div
ref={autofocusRef}
tabIndex={-1}
role='main'
aria-label={_('Bookshelf')}
className='bookshelf min-h-0 flex-grow focus:outline-none'
>
<div ref={osRootRef} data-overlayscrollbars-initialize='' className='h-full'>
{hasItems && isGridMode && (
<VirtuosoGrid<unknown, BookshelfListContext>
overscan={200}
totalCount={gridTotalCount}
components={GRID_VIRTUOSO_COMPONENTS}
context={listContext}
computeItemKey={computeItemKey}
itemContent={renderBookshelfItem}
scrollerRef={handleScrollerRef}
/>
)}
{hasItems && !isGridMode && (
<Virtuoso
overscan={200}
totalCount={sortedBookshelfItems.length}
components={LIST_VIRTUOSO_COMPONENTS}
computeItemKey={computeItemKey}
itemContent={renderBookshelfItem}
scrollerRef={handleScrollerRef}
/>
)}
</div>
{loading && (
@@ -388,12 +388,12 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
};
return (
<div className={clsx(mode === 'list' && 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
<div className={clsx(mode === 'grid' ? 'h-full' : 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
<div
className={clsx(
'visible-focus-inset-2 group',
mode === 'grid' &&
'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-2 sm:px-4 sm:py-4',
'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-2 sm:rounded-md sm:px-4 sm:py-4',
mode === 'list' && 'border-base-300 flex flex-col border-b py-2',
appService?.isMobileApp && 'no-context-menu',
pressing && mode === 'grid' ? 'not-eink:scale-95' : 'scale-100',
@@ -32,7 +32,7 @@ export const useDemoBooks = () => {
const appService = await envConfig.getAppService();
const demoBooks = libraries[userLang] || (libraries.en as DemoBooks);
const books = await Promise.all(
demoBooks.library.map((url) => appService.importBook(url, [], false, true)),
demoBooks.library.map((url) => appService.importBook(url, [], { saveBook: false })),
);
setBooks(books.filter((book) => book !== null) as Book[]);
} catch (error) {
+120 -77
View File
@@ -5,11 +5,10 @@ import * as React from 'react';
import { MdChevronRight } from 'react-icons/md';
import { useState, useRef, useEffect, Suspense, useCallback } from 'react';
import { ReadonlyURLSearchParams, useSearchParams } from 'next/navigation';
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from 'overlayscrollbars-react';
import 'overlayscrollbars/overlayscrollbars.css';
import { Book } from '@/types/book';
import { AppService, DeleteAction } from '@/types/system';
import { buildBookLookupIndex } from '@/services/bookService';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { formatAuthors, formatTitle, getPrimaryLanguage, listFormater } from '@/utils/book';
import { getImportErrorMessage } from '@/services/errors';
@@ -40,6 +39,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useTransferStore } from '@/store/transferStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
import { lockScreenOrientation, selectDirectory } from '@/utils/bridge';
import { requestStoragePermission } from '@/utils/permission';
@@ -98,7 +98,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setLibrary,
getGroupId,
getGroupName,
refreshGroups,
checkOpenWithBooks,
checkLastOpenBooks,
setCheckOpenWithBooks,
@@ -134,57 +133,28 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const iconSize = useResponsiveSize(18);
const viewSettings = settings.globalViewSettings;
const demoBooks = useDemoBooks();
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
const containerRef: React.MutableRefObject<HTMLDivElement | null> = useRef(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const handleScrollerRef = useCallback((el: HTMLDivElement | null) => {
scrollRef.current = el;
}, []);
const containerRef = useRef<HTMLDivElement | null>(null);
const pageRef = useRef<HTMLDivElement>(null);
const getScrollKey = (group: string) => `library-scroll-${group || 'all'}`;
const saveScrollPosition = (group: string) => {
const viewport = osRef.current?.osInstance()?.elements().viewport;
if (viewport) {
const scrollTop = viewport.scrollTop;
sessionStorage.setItem(getScrollKey(group), scrollTop.toString());
if (scrollRef.current) {
sessionStorage.setItem(getScrollKey(group), scrollRef.current.scrollTop.toString());
}
};
const restoreScrollPosition = useCallback((group: string) => {
const savedPosition = sessionStorage.getItem(getScrollKey(group));
if (savedPosition) {
const scrollTop = parseInt(savedPosition, 10);
const viewport = osRef.current?.osInstance()?.elements().viewport;
if (viewport) {
viewport.scrollTop = scrollTop;
}
if (savedPosition && scrollRef.current) {
scrollRef.current.scrollTop = parseInt(savedPosition, 10);
}
}, []);
// Unified navigation function that handles scroll position and direction
const handleLibraryNavigation = useCallback(
(targetGroup: string) => {
const currentGroup = searchParams?.get('group') || '';
// Save current scroll position BEFORE navigation
saveScrollPosition(currentGroup);
// Detect and set navigation direction
const direction = currentGroup && !targetGroup ? 'back' : 'forward';
document.documentElement.setAttribute('data-nav-direction', direction);
// Build query params
const params = new URLSearchParams(searchParams?.toString());
if (targetGroup) {
params.set('group', targetGroup);
} else {
params.delete('group');
}
navigateToLibrary(router, `${params.toString()}`);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[searchParams, router],
);
useTheme({ systemUIVisible: true, appThemeColor: 'base-200' });
useUICSS();
@@ -195,7 +165,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const { isDragging } = useDragDropImport();
usePullToRefresh(
containerRef,
scrollRef,
pullLibrary.bind(null, false, true),
pullLibrary.bind(null, true, true),
);
@@ -229,6 +199,74 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
sessionStorage.setItem('lastLibraryParams', searchParams?.toString() || '');
}, [searchParams]);
// Strip the empty `group=` param that `handleLibraryNavigation` sets as a
// workaround for a Next.js 16.2 static-export regression (see the NOTE
// above `handleLibraryNavigation` for full context). This effect runs
// after the router.replace() has committed, so React has already
// re-rendered with the new (empty) group state; we're only rewriting the
// URL cosmetically via window.history.replaceState — Next.js' patched
// replaceState will pick up the new canonical URL without triggering
// another navigation.
useEffect(() => {
if (typeof window === 'undefined') return;
if (searchParams?.get('group') !== '') return;
const url = new URL(window.location.href);
url.searchParams.delete('group');
const cleanHref = `${url.pathname}${url.search}${url.hash}`;
window.history.replaceState(null, '', cleanHref);
}, [searchParams]);
// Unified navigation function that handles scroll position and direction.
// Workaround for a Next.js 16.2 static-export regression: navigating to a
// same-pathname URL with an empty search string causes `router.replace()`
// to silently no-op (e.g. `/library?group=foo` -> `/library`), which broke
// the breadcrumb "All" button. By always calling `params.set('group',
// targetGroup)` — including when `targetGroup` is an empty string — the
// resulting URL becomes `/library?group=` instead of `/library`, which
// Next.js does commit. The trailing empty `group=` is stripped via a
// cleanup effect below (purely cosmetic URL rewrite). See
// https://github.com/readest/readest/issues/3782.
const handleLibraryNavigation = useCallback(
(targetGroup: string) => {
const currentGroup = searchParams?.get('group') || '';
// Save current scroll position BEFORE navigation
saveScrollPosition(currentGroup);
// Detect and set navigation direction
const direction = currentGroup && !targetGroup ? 'back' : 'forward';
document.documentElement.setAttribute('data-nav-direction', direction);
// Build query params — always `set` so the search string is non-empty
// even when targetGroup is '' (the Next.js 16.2 workaround).
const params = new URLSearchParams(searchParams?.toString());
params.set('group', targetGroup);
navigateToLibrary(router, `${params.toString()}`);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[searchParams, router],
);
const handleBackUpOneGroupLevel = () => {
if (!currentGroupPath) return;
const segments = currentGroupPath.split('/');
const parentPath = segments.length > 1 ? segments.slice(0, -1).join('/') : undefined;
const parentGroupId = parentPath ? getGroupId(parentPath) || '' : '';
setIsSelectAll(false);
setIsSelectNone(false);
handleLibraryNavigation(parentGroupId);
};
const handleBackUpOneGroupLevelRef = useRef(handleBackUpOneGroupLevel);
handleBackUpOneGroupLevelRef.current = handleBackUpOneGroupLevel;
const triggerBackUpOneGroupLevel = useCallback(() => handleBackUpOneGroupLevelRef.current(), []);
useKeyDownActions({
onCancel: triggerBackUpOneGroupLevel,
enabled: !!appService?.isAndroidApp && !!currentGroupPath,
});
useEffect(() => {
const doCheckAppUpdates = async () => {
if (appService?.hasUpdater && settings.autoCheckUpdates) {
@@ -250,27 +288,28 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}
}, [appService]);
const handleRefreshLibrary = useCallback(async () => {
const appService = await envConfig.getAppService();
const settings = await appService.loadSettings();
const library = await appService.loadLibraryBooks();
setSettings(settings);
setLibrary(library);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [envConfig, appService]);
useEffect(() => {
if (appService?.hasWindow) {
const currentWebview = getCurrentWebview();
const unlisten = currentWebview.listen('close-reader-window', async () => {
handleRefreshLibrary();
// Reader windows are independent Tauri webviews with their own
// libraryStore instance — progress / readingStatus / move-to-front
// updates from the reader window do NOT propagate to this main
// window's store. Reload from disk so the library reflects the
// changes the reader just persisted.
const appService = await envConfig.getAppService();
const settings = await appService.loadSettings();
const library = await appService.loadLibraryBooks();
setSettings(settings);
setLibrary(library);
});
return () => {
unlisten.then((fn) => fn());
};
}
return;
}, [appService, handleRefreshLibrary]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService, envConfig]);
const handleImportBookFiles = useCallback(async (event: CustomEvent) => {
const selectedFiles: SelectedFile[] = event.detail.files;
@@ -288,7 +327,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}, [handleImportBookFiles]);
useEffect(() => {
refreshGroups();
if (!libraryBooks.some((book) => !book.deletedAt)) {
handleSetSelectMode(false);
}
@@ -303,7 +341,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
console.log('Open with book:', file);
try {
const temp = appService.isMobile ? false : !settings.autoImportBooksOnOpen;
const book = await appService.importBook(file, libraryBooks, true, true, false, temp);
const book = await appService.importBook(file, libraryBooks, { transient: temp });
if (book) {
bookIds.push(book.hash);
}
@@ -433,7 +471,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
isInitiating.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
}, []);
useEffect(() => {
const group = searchParams?.get('group') || '';
@@ -496,6 +534,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const importBooks = async (files: SelectedFile[], groupId?: string) => {
setLoading(true);
const { library } = useLibraryStore.getState();
// Build the lookup index ONCE per import batch so each book lookup is
// O(1) instead of O(n) over the existing library. importBook also keeps
// the index updated as new books are appended, so subsequent files in
// the same batch see the additions.
const lookupIndex = buildBookLookupIndex(library);
const failedImports: Array<{ filename: string; errorMessage: string }> = [];
const successfulImports: string[] = [];
@@ -503,7 +546,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const file = selectedFile.file || selectedFile.path;
if (!file) return null;
try {
const book = await appService?.importBook(file, library);
const book = await appService?.importBook(file, library, { lookupIndex });
if (!book) return null;
const { path, basePath } = selectedFile;
if (groupId) {
@@ -536,7 +579,18 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
for (let i = 0; i < files.length; i += concurrency) {
const batch = files.slice(i, i + concurrency);
const importedBooks = (await Promise.all(batch.map(processFile))).filter((book) => !!book);
await updateBooks(envConfig, importedBooks);
// Update store state per batch (so the UI can render imported books
// incrementally) but defer disk persistence until the entire batch is
// done — saving library.json once per batch of 4 books was the dominant
// cost for large imports.
await updateBooks(envConfig, importedBooks, { skipSave: true });
}
// Persist the full library once after every file in the batch is done.
if (successfulImports.length > 0) {
const finalLibrary = useLibraryStore.getState().library;
const finalAppService = await envConfig.getAppService();
await finalAppService.saveLibraryBooks(finalLibrary);
}
pushLibrary();
@@ -890,27 +944,15 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
)}
{showBookshelf &&
(libraryBooks.some((book) => !book.deletedAt) ? (
<OverlayScrollbarsComponent
defer
aria-label={_('Your Bookshelf')}
ref={osRef}
className='flex-grow'
options={{ scrollbars: { autoHide: 'scroll' } }}
events={{
initialized: (instance) => {
const { content } = instance.elements();
if (content) {
containerRef.current = content as HTMLDivElement;
}
},
}}
>
<div aria-label={_('Your Bookshelf')} className='flex min-h-0 flex-grow flex-col'>
<div
className={clsx('scroll-container drop-zone flex-grow', isDragging && 'drag-over')}
ref={containerRef}
className={clsx(
'scroll-container drop-zone flex min-h-0 flex-grow flex-col',
isDragging && 'drag-over',
)}
style={{
paddingTop: '0px',
paddingRight: `${insets.right}px`,
paddingBottom: `${insets.bottom}px`,
paddingLeft: `${insets.left}px`,
}}
>
@@ -920,6 +962,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
isSelectNone={isSelectNone}
onScrollerRef={handleScrollerRef}
handleImportBooks={handleImportBooksFromFiles}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
@@ -931,7 +974,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
handlePushLibrary={pushLibrary}
/>
</div>
</OverlayScrollbarsComponent>
</div>
) : (
<div className='hero drop-zone h-screen items-center justify-center'>
<DropIndicator />
@@ -2,7 +2,7 @@
import clsx from 'clsx';
import { useState } from 'react';
import { IoAdd, IoTrash, IoOpenOutline, IoBook, IoEyeOff, IoEye } from 'react-icons/io5';
import { IoAdd, IoTrash, IoOpenOutline, IoBook, IoEyeOff, IoEye, IoPencil } from 'react-icons/io5';
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
@@ -77,6 +77,7 @@ export function CatalogManager() {
const { settings } = useSettingsStore();
const [catalogs, setCatalogs] = useState<OPDSCatalog[]>(() => settings.opdsCatalogs || []);
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingCatalogId, setEditingCatalogId] = useState<string | null>(null);
const [newCatalog, setNewCatalog] = useState(EMPTY_NEW_CATALOG);
const [showPassword, setShowPassword] = useState(false);
const [urlError, setUrlError] = useState('');
@@ -147,7 +148,7 @@ export function CatalogManager() {
}
const catalog: OPDSCatalog = {
id: Date.now().toString(),
id: editingCatalogId || Date.now().toString(),
name: newCatalog.name,
url: newCatalog.url,
description: newCatalog.description,
@@ -158,15 +159,35 @@ export function CatalogManager() {
: undefined,
};
saveCatalogs([catalog, ...catalogs]);
if (editingCatalogId) {
saveCatalogs(catalogs.map((c) => (c.id === editingCatalogId ? catalog : c)));
} else {
saveCatalogs([catalog, ...catalogs]);
}
setNewCatalog(EMPTY_NEW_CATALOG);
setUrlError('');
setHeaderError('');
setProxyConsentError('');
setIsValidating(false);
setEditingCatalogId(null);
setShowAddDialog(false);
};
const handleEditCatalog = (catalog: OPDSCatalog) => {
setNewCatalog({
name: catalog.name,
url: catalog.url,
description: catalog.description || '',
username: catalog.username || '',
password: catalog.password || '',
customHeadersInput: formatOPDSCustomHeadersInput(catalog.customHeaders),
proxyConsent: false,
});
setEditingCatalogId(catalog.id);
setShowAddDialog(true);
};
const handleAddPopularCatalog = (popularCatalog: OPDSCatalog) => {
if (catalogs.some((c) => c.url === popularCatalog.url)) {
return;
@@ -192,6 +213,7 @@ export function CatalogManager() {
setHeaderError('');
setProxyConsentError('');
setShowPassword(false);
setEditingCatalogId(null);
};
return (
@@ -239,13 +261,22 @@ export function CatalogManager() {
{catalog.icon && <span className=''>{catalog.icon}</span>}
{catalog.name}
</h3>
<button
onClick={() => handleRemoveCatalog(catalog.id)}
className='btn btn-ghost btn-xs btn-square'
title='Remove'
>
<IoTrash className='h-4 w-4' />
</button>
<div className='flex gap-1'>
<button
onClick={() => handleEditCatalog(catalog)}
className='btn btn-ghost btn-xs btn-square'
title={_('Edit')}
>
<IoPencil className='h-4 w-4' />
</button>
<button
onClick={() => handleRemoveCatalog(catalog.id)}
className='btn btn-ghost btn-xs btn-square'
title={_('Remove')}
>
<IoTrash className='h-4 w-4' />
</button>
</div>
</div>
{catalog.description && (
<p className='text-base-content/70 mb-2 line-clamp-1 h-6 text-sm sm:line-clamp-2 sm:h-10'>
@@ -329,12 +360,14 @@ export function CatalogManager() {
</div>
</section>
{/* Add Catalog Dialog */}
{/* Add/Edit Catalog Dialog */}
{showAddDialog && (
<ModalPortal>
<dialog className='modal modal-open'>
<div className='modal-box'>
<h3 className='mb-4 text-lg font-bold'>{_('Add OPDS Catalog')}</h3>
<h3 className='mb-4 text-lg font-bold'>
{editingCatalogId ? _('Edit OPDS Catalog') : _('Add OPDS Catalog')}
</h3>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -518,6 +551,8 @@ export function CatalogManager() {
<span className='loading loading-spinner loading-sm'></span>
{_('Validating...')}
</>
) : editingCatalogId ? (
_('Save Changes')
) : (
_('Add Catalog')
)}
+28 -3
View File
@@ -269,16 +269,41 @@ export const probeAuth = async (
export const probeFilename = async (headers: Record<string, string>) => {
const contentDisposition = headers['content-disposition'];
if (contentDisposition) {
// 1. Try RFC 5987 format (filename*=utf-8''encoded_name)
const extendedMatch = contentDisposition.match(
/filename\*\s*=\s*(?:utf-8|UTF-8)'[^']*'([^;\s]+)/i,
);
if (extendedMatch?.[1]) {
return decodeURIComponent(extendedMatch[1]);
try {
return decodeURIComponent(extendedMatch[1]);
} catch (e) {
// If decoding fails, ignore and proceed to the next format
console.warn('Failed to decode filename*', e);
}
}
const plainMatch = contentDisposition.match(/filename\s*=\s*["']?([^"';\s]+)["']?/i);
// 2. Try standard quoted format (supports spaces, apostrophes, and escaped quotes)
const quotedMatch = contentDisposition.match(/filename\s*=\s*(["'])((?:(?!\1)[^\\]|\\.)*)\1/i);
if (quotedMatch?.[2]) {
// Unescape characters (e.g., \" becomes ")
const unescaped = quotedMatch[2].replace(/\\(.)/g, '$1');
try {
// Attempt to decode in case the server incorrectly applied URL encoding
return decodeURIComponent(unescaped);
} catch {
// If decoding fails (e.g., literal '%' symbols), return the unescaped string as-is
return unescaped;
}
}
// 3. Fallback: standard format without quotes
const plainMatch = contentDisposition.match(/filename\s*=\s*([^;\s]+)/i);
if (plainMatch?.[1]) {
return decodeURIComponent(plainMatch[1]);
try {
return decodeURIComponent(plainMatch[1]);
} catch {
return plainMatch[1];
}
}
}
@@ -233,11 +233,13 @@ const FoliateViewer: React.FC<{
if (bookDoc.rendition?.layout === 'pre-paginated') {
applyFixedlayoutStyles(detail.doc, viewSettings);
const themeCode = getThemeCode();
if (themeCode && renderer) {
renderer.pageColors = {
background: themeCode.bg,
foreground: themeCode.fg,
};
if (bookData.book?.format === 'PDF' && themeCode && renderer) {
renderer.pageColors = viewSettings.applyThemeToPDF
? {
background: themeCode.bg,
foreground: themeCode.fg,
}
: undefined;
}
}
@@ -340,7 +342,7 @@ const FoliateViewer: React.FC<{
const { handlePageFlip } = usePagination(bookKey, viewRef, containerRef);
const mouseHandlers = useMouseEvent(bookKey, handlePageFlip);
const touchHandlers = useTouchEvent(bookKey, handlePageFlip);
const touchHandlers = useTouchEvent(bookKey);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [selectedTableHtml, setSelectedTableHtml] = useState<string | null>(null);
@@ -619,6 +621,7 @@ const FoliateViewer: React.FC<{
useEffect(() => {
if (viewRef.current && viewRef.current.renderer) {
const renderer = viewRef.current.renderer;
const viewSettings = getViewSettings(bookKey)!;
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
const docs = viewRef.current.renderer.getContents();
@@ -631,13 +634,13 @@ const FoliateViewer: React.FC<{
applyScrollbarStyle(document, viewSettings.hideScrollbar || false);
});
if (bookDoc.rendition?.layout === 'pre-paginated') {
if (themeCode && viewRef.current?.renderer) {
viewRef.current.renderer.pageColors = {
background: themeCode.bg,
foreground: themeCode.fg,
};
}
if (bookData?.book?.format === 'PDF' && themeCode && renderer) {
renderer.pageColors = viewSettings.applyThemeToPDF
? {
background: themeCode.bg,
foreground: themeCode.fg,
}
: undefined;
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -647,6 +650,7 @@ const FoliateViewer: React.FC<{
viewSettings?.scrolled,
viewSettings?.overrideColor,
viewSettings?.invertImgColorInDark,
viewSettings?.applyThemeToPDF,
viewSettings?.hideScrollbar,
]);
@@ -69,7 +69,6 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
const iconSize16 = useResponsiveSize(16);
const iconSize18 = useResponsiveSize(18);
const headerRef = useRef<HTMLDivElement>(null);
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
const docs = view?.renderer.getContents() ?? [];
const pointerInDoc = docs.some(({ doc }) => doc?.body?.style.cursor === 'pointer');
@@ -138,6 +137,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
useSpatialNavigation(headerRef, isHeaderVisible);
const trafficLightInHeader =
appService?.hasTrafficLight && !trafficLightInFullscreen && !isSideBarVisible && isTopLeft;
const windowButtonVisible =
appService?.hasWindowBar && !isTrafficLightVisible && !trafficLightInHeader;
return (
<div
@@ -2,6 +2,7 @@ import clsx from 'clsx';
import React, { useState, useRef, useEffect } from 'react';
import { IoChevronBack, IoChevronForward } from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { Insets } from '@/types/misc';
import ZoomControls from './ZoomControls';
@@ -39,6 +40,9 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
const imageRef = useRef<HTMLImageElement>(null);
const zoomLabelTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Escape (desktop) and Android Back key → close the viewer.
useKeyDownActions({ onCancel: onClose });
const hideZoomLabelAfterDelay = () => {
if (zoomLabelTimeoutRef.current) {
clearTimeout(zoomLabelTimeoutRef.current);
@@ -79,10 +83,7 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
const handleKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Escape') {
onClose();
return;
}
// Escape is handled by useKeyDownActions (also covers Android Back key).
// Arrow key navigation
if (e.key === 'ArrowLeft' && onPrevious) {
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import React, { Fragment, useState, useEffect, useMemo, useCallback } from 'react';
import { md5 } from 'js-md5';
import { type as osType } from '@tauri-apps/plugin-os';
import { useEnv } from '@/context/EnvContext';
@@ -148,6 +148,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
serverUrl: url,
username,
userkey: md5(password),
password,
deviceName,
enabled: true,
};
@@ -218,7 +219,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
{isOpen && (
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
{isConfigured ? (
<>
<Fragment key='configured'>
<div className='text-center'>
<p className='text-base-content/80 text-sm'>
{_('Sync as {{userDisplayName}}', {
@@ -275,9 +276,9 @@ export const KOSyncSettingsWindow: React.FC = () => {
onChange={handleDeviceNameChange}
/>
</div>
</>
</Fragment>
) : (
<>
<Fragment key='login'>
<p className='text-base-content/70 text-center text-sm'>
{_('Connect to your KOReader Sync server.')}
</p>
@@ -333,7 +334,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
{connectionStatus && (
<div className='text-error h-4 text-center text-sm'>{connectionStatus}</div>
)}
</>
</Fragment>
)}
</div>
)}
@@ -8,6 +8,7 @@ import { saveViewSettings } from '@/helpers/settings';
import { READING_RULER_COLORS } from '@/services/constants';
import { throttle } from '@/utils/throttle';
import { eventDispatcher } from '@/utils/event';
import { useTouchInterceptor } from '../hooks/useTouchInterceptor';
import {
calculateReadingRulerSize,
clampReadingRulerPosition,
@@ -305,6 +306,82 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
}
}, [containerSize.width, containerSize.height, isVertical, clampPosition, setRulerPosition]);
// Touch interceptor: allows dragging the ruler from anywhere on its body.
// The ruler body stays pointer-events-none so text selection works through it.
// Returning true consumes the gesture, preventing swipe/page-flip.
const isTouchDraggingRef = useRef(false);
const touchInRulerRef = useRef(false);
useTouchInterceptor(
`ruler-drag-${bookKey}`,
(bk, detail) => {
if (bk !== bookKey) return false;
if (detail.phase === 'start') {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return false;
const vx = detail.touch.screenX - (window.screenX || 0);
const vy = detail.touch.screenY - (window.screenY || 0);
const dim = isVertical ? rect.width : rect.height;
const center = (currentPositionRef.current / 100) * dim;
const half = rulerSize / 2;
const rel = isVertical ? vx - rect.left : vy - rect.top;
touchInRulerRef.current = rel >= center - half && rel <= center + half;
isTouchDraggingRef.current = false;
return false;
}
if (detail.phase === 'move') {
if (!touchInRulerRef.current) return false;
if (!isTouchDraggingRef.current) {
const dx = Math.abs(detail.deltaX);
const dy = Math.abs(detail.deltaY);
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance >= 10) {
const isDragGesture = isVertical ? dx >= 3 * dy : dy >= 3 * dx;
if (isDragGesture) {
isTouchDraggingRef.current = true;
isDragging.current = true;
dragPointerOffsetRef.current = 0;
setShouldAnimate(false);
} else {
touchInRulerRef.current = false;
return false;
}
} else {
return false;
}
}
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return true;
const vx = detail.touch.screenX - (window.screenX || 0);
const vy = detail.touch.screenY - (window.screenY || 0);
const dim = isVertical ? rect.width : rect.height;
const rel = isVertical ? vx - rect.left : vy - rect.top;
const newPos = clampPosition((rel / dim) * 100, dim);
setCurrentPosition(newPos);
currentPositionRef.current = newPos;
return true;
}
if (detail.phase === 'end') {
const wasConsumed = isTouchDraggingRef.current;
if (wasConsumed) {
isDragging.current = false;
throttledSave(currentPositionRef.current);
}
touchInRulerRef.current = false;
isTouchDraggingRef.current = false;
return wasConsumed;
}
return false;
},
10, // higher priority than swipe-to-flip (0)
);
useEffect(() => {
const handleMove = (event: CustomEvent) => {
const detail = (event.detail ?? {}) as {
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { Insets } from '@/types/misc';
import ZoomControls from './ZoomControls';
@@ -27,6 +28,9 @@ const TableViewer: React.FC<TableViewerProps> = ({ gridInsets, html, isDarkMode,
const contentRef = useRef<HTMLDivElement>(null);
const zoomLabelTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Escape (desktop) and Android Back key → close the viewer.
useKeyDownActions({ onCancel: onClose });
const hideZoomLabelAfterDelay = () => {
if (zoomLabelTimeoutRef.current) {
clearTimeout(zoomLabelTimeoutRef.current);
@@ -63,10 +67,7 @@ const TableViewer: React.FC<TableViewerProps> = ({ gridInsets, html, isDarkMode,
const handleKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Escape') {
onClose();
return;
}
// Escape is handled by useKeyDownActions (also covers Android Back key).
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
@@ -58,6 +58,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
const [invertImgColorInDark, setInvertImgColorInDark] = useState(
viewSettings!.invertImgColorInDark,
);
const [applyThemeToPDF, setApplyThemeToPDF] = useState(viewSettings!.applyThemeToPDF!);
const zoomIn = () => setZoomLevel((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM_LEVEL));
const zoomOut = () => setZoomLevel((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM_LEVEL));
@@ -127,6 +128,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invertImgColorInDark]);
useEffect(() => {
if (applyThemeToPDF === viewSettings.applyThemeToPDF) return;
saveViewSettings(envConfig, bookKey, 'applyThemeToPDF', applyThemeToPDF, true, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [applyThemeToPDF]);
useEffect(() => {
if (zoomMode === viewSettings.zoomMode) return;
viewSettings.zoomMode = zoomMode;
@@ -328,6 +335,13 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
Icon={themeMode === 'dark' ? BiMoon : themeMode === 'light' ? BiSun : TbSunMoon}
onClick={cycleThemeMode}
/>
{bookData.book?.format === 'PDF' && (
<MenuItem
label={_('Apply Theme Colors to PDF')}
Icon={applyThemeToPDF ? MdCheck : undefined}
onClick={() => setApplyThemeToPDF(!applyThemeToPDF)}
/>
)}
<MenuItem
label={_('Invert Image In Dark Mode')}
disabled={!isDarkMode}
@@ -36,7 +36,13 @@ function createAnnotationToolButtons<T extends AnnotationToolType>(
}
export const annotationToolButtons = createAnnotationToolButtons([
{ type: 'copy', label: _('Copy'), tooltip: _('Copy text after selection'), Icon: FiCopy },
{
type: 'copy',
label: _('Copy'),
tooltip: _('Copy text after selection'),
Icon: FiCopy,
quickAction: true,
},
{
type: 'highlight',
label: _('Highlight'),

Some files were not shown because too many files have changed in this diff Show More