Compare commits

...

129 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
Huang Xin 3174e341a3 release: version 0.10.4 (#3767) 2026-04-05 19:57:06 +02:00
Huang Xin 298d4872a0 fix(translate): disable yandex provider while upstream relay is down (#3765)
The translate.toil.cc relay that backs the yandex provider is currently
unavailable. Introduce a `disabled` flag on TranslationProvider and
filter disabled providers out of both `getTranslators()` and
`getTranslator()` so the settings panel, translator popup, and the
fallback logic in `useTranslator` all agree the provider doesn't exist —
no per-callsite changes needed. Flip the flag back (or delete the line)
on `yandexProvider` to re-enable once the upstream is healthy.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:48:25 +02:00
Huang Xin b679817fce fix(tts): prevent double playback on rapid TTS icon clicks (#3764)
Guard handleTTSSpeak with a single-flight ref so a second tts-speak
event that arrives while the first is still inside its initial awaits
(initMediaSession / backgroundAudio / TTSController.init) is ignored
instead of racing ahead to construct a second TTSController. Without
this, rapid clicks produced two concurrent speakers talking over each
other because viewState.ttsEnabled is only set at the end of the first
invocation, so the footer bar would dispatch tts-speak twice.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:11:28 +02:00
Huang Xin b9a2b10fac fix(a11y): fixed keyboard activation of dropdown menu (#3762) 2026-04-05 18:48:27 +02:00
Huang Xin a9e33ca50a fix(style): let background texture take precedence over overridden background color (#3761)
When a background texture is active, skip applying the overridden bg/fg/border
colors on block elements so the texture stays visible instead of being painted over.
2026-04-05 18:05:05 +02:00
Huang Xin 09d1e0c040 fix(sync): show last push time as the last sync time (#3760) 2026-04-05 17:55:41 +02:00
Huang Xin 8b10e7fb17 fix(layout): use mobile footer bar in portrait mode without regressing phone panel animation, closes #3742 (#3759)
On mobile tablets/foldables (Android or iOS) in portrait, the viewport
width can exceed the Tailwind \`sm:\` breakpoint (640px), causing the
desktop footer bar to show and hiding the brightness / font size /
color / progress panels that only exist in the mobile layout.

The previous fix in #3746 introduced a regression on phones: wrapping
MobileFooterBar's children in a \`<div>\` changed the flex layout of
the footer container, and panels no longer slid cleanly behind the
navigation bar on dismissal. That PR was reverted.

This re-implementation scopes the override narrowly:

- \`forceMobileLayout\` is only true for mobile devices in portrait
  with innerWidth >= 640. Phones (innerWidth < 640) always get
  \`false\`, so every \`!forceMobileLayout && '…'\` expression
  evaluates to the original class string — phone classNames are
  set-equal to the pre-#3746 version.
- MobileFooterBar keeps its Fragment return; no wrapper div is
  introduced anywhere in the tree, preserving the panel slide-down
  animation exactly as before.
- DesktopFooterBar, NavigationBar, and the three panels
  (ColorPanel / FontLayoutPanel / NavigationPanel) gate their
  \`sm:hidden\` / \`sm:flex\` classes on \`!forceMobileLayout\` so
  they correctly show/hide on wide portrait tablets.
- The orphaned \`.force-mobile-layout\` CSS override in globals.css
  is removed since we no longer rely on a class-based escape hatch.

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

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

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:25:58 +02:00
Huang Xin 7e62516b5d chore(deps): bump Next.js to version 16.2.2 (#3757) 2026-04-05 15:59:10 +02:00
Lex Moulton d53f3b42e2 feat(rsvp): split words option, faster countdown, and skip pages RSVP cant open (#3755)
* fix: use string-parsed year to avoid UTC/timezone boundary issues

* feat: Add option to split words in RSVP mode

* fix: change the checkbox to a toggle

* fix(rsvp): speed up countdown from 2.4s to 1.5s

* feat(rsvp): skip to next page when no readable text is found

* fix(rsvp): replace lookbehind regex with lookahead-only split in getHyphenParts
2026-04-05 14:29:30 +02:00
Mohammed Efaz d682fcbb44 feat: add named highlight colors with sync and picker ux fixes (#3741)
* fix: add highlight color label fields

* fix: add default highlight label sync fields

* fix: add highlight prefs sync helpers

* fix: add highlight color name inputs

* fix: persist and sync highlight color names

* fix: add highlight color label helpers

* fix: add long press highlight label preview

* fix: pull highlight color prefs during library sync

* test: cover highlight color label helpers

* fix: widen highlight color name inputs

* fix: show highlight names on hover and touch hold

* fix: prevent highlight name input overlap

* fix: improve highlight name input responsiveness

* fix: support drag scrolling for highlight colors

* fix: batch custom color and label updates

* fix: serialize highlight prefs saves

* fix: align color strip drag and restore color clicks

* fix: translate default highlight color labels

* refactor: remove highlight preference sync wiring

* fix: align highlight option i18n with existing pattern

* fix: remove redundant english highlight keys

* fix: support raw and normalized highlight label keys

* fix: use underscore translator in highlight options

* fix: translate custom highlight color labels in editor

* refactor: simplify highlight settings persistence

* fix: maintianer review

* refactor: simplify highlight prefs save and clean up editor

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

* refactor: couple highlight labels to their colors

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

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

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

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

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

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

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

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-04-05 18:08:55 +08:00
Huang Xin b3fe33221d feat(i18n): add Hungarian translation and translate new keys across all locales (#3753)
Add Hungarian (hu) as a new supported locale with full translation coverage.
Also translate the new "Toggle Toolbar" key across all 30 existing locales.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:15:48 +02:00
Huang Xin fbfd7fd6c6 fix(hardcover): use Tauri HTTP plugin to bypass CORS and coerce search result IDs to numbers, closes #3751 (#3752)
The Hardcover GraphQL API rejects requests from tauri://localhost origin on iOS.
Switch to tauriFetch (like KOSyncClient) to bypass CORS in Tauri environments.
Also coerce bookId/editionId/pages from search results to numbers since the
search API returns string IDs but the GraphQL mutations expect 32-bit integers.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:56:47 +02:00
Huang Xin 531dbe5f16 release: version 0.10.2 (#3750) 2026-04-04 21:23:25 +02:00
Huang Xin 70b94d8986 fix(layout): fixed layout of progress bar in vertical mode (#3749) 2026-04-04 21:20:34 +02:00
Huang Xin 21795e5cd3 fix(tts): avoid race condition in preloadNextSSML causing wrong highlights (#3748)
preloadNextSSML() called tts.next() interleaved with await, creating async
gaps where the concurrent #speak() could dispatch marks against corrupted
#ranges state (replaced by next() for a different block). Since mark names
restart at "0" per block, marks resolved to the wrong text position, causing
accidental page turns and highlights far from the current sentence.

Fix: gather all tts.next() and tts.prev() calls synchronously (no await
between them) so no async code can see the intermediate #ranges state.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:42:06 +02:00
Huang Xin 52242d6886 fix(android): use mobile footer bar in portrait mode on Android, closes #3742 (#3746)
On Android tablets/foldables in portrait, screen width can exceed 640px
causing the desktop footer bar to show. Now use portrait orientation
detection to force the mobile footer bar layout on Android regardless
of screen width.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:11:02 +02:00
Huang Xin f361698e05 feat(android): add D-pad navigation for Android TV remote controller (#3745)
* feat(android): add D-pad navigation for Android TV remote controller

Add basic D-pad/arrow key navigation support for Android TV Bluetooth
remote controllers, covering the full flow: library grid browsing,
reader page turning, toolbar interaction, and TTS toggle.

Library:
- Custom spatial navigation hook for grid D-pad navigation
- Arrow keys move between BookshelfItem elements with auto column detection
- ArrowDown from header enters the bookshelf grid

Reader:
- Enter key toggles header/footer toolbar visibility
- Left/Right navigate between toolbar buttons, Up/Down moves between
  header and footer bars
- Back button dismisses toolbar (with blur) before sidebar/library
- Focus-probe technique for reliable button visibility detection
  across fixed/hidden containers

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

* fix: only auto-focus toolbar buttons on keyboard activation, not mouse hover

Track pointer activity to distinguish keyboard vs mouse toolbar activation.
When the toolbar appears due to mouse hover, skip auto-focus to prevent
unwanted focus outlines on desktop.

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

* fix: track keyboard events instead of pointer events for auto-focus guard

Pointer events from iframes don't bubble to the main document, so
tracking pointermove/pointerdown missed hover interactions over book
content. Track keydown instead — auto-focus only when a recent keyboard
event (Enter key) triggered the toolbar, not mouse hover.

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-04 19:50:34 +02:00
Huang Xin 9595aa56e0 fix(android): get rid of the outline on the header and footer bar when using remote control to turn page (#3744) 2026-04-04 17:00:20 +02:00
Huang Xin caa0d719c5 compat(vertical): check writing mode also for child element of body, closes #3583 (#3743) 2026-04-04 15:56:26 +02:00
Yi-Shun Wang 45bd355981 feat(opds): support custom catalog headers with web proxy consent (#3740)
* Add custom headers support for OPDS catalogs

* Require web proxy consent for sensitive OPDS credentials

* Strengthen OPDS header forwarding tests

---------

Co-authored-by: Yi-Shun Wang <git@albertwang.dev>
2026-04-04 12:01:16 +02:00
Huang Xin 52ac74bbad fix: fixed status info layout in vertical mode, fixed Android build (#3735) 2026-04-03 21:41:49 +02:00
Huang Xin 6a44f609ba fix(paginator): fixed paginator section preloading, closes #3600 and closes #3601 (#3734) 2026-04-03 20:46:45 +02:00
Huang Xin ca5c860594 fix(kosync): don't normalize xpointer for more accurate progress sync, closes #3672 and closes #3616 (#3733) 2026-04-03 16:17:20 +02:00
AK Venugopal 80cab8e56d feat: Hardcover.app Sync (#3724)
* 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
2026-04-03 09:06:43 +02:00
Mohammed Efaz 888f4afde9 fix: preserve paragraph mode reading layouts and other UI/UX fixes (#3730)
* fix: paragraph mode

* test: paragraph mode

* test: remove paragraph mode

* fix: paragraph utils

* fix: paragraph hook

* fix: paragraph overlay

* fix: paragraph bar

* fix: paragraph control

* fix: paragraph shortcuts

* test: paragraph utils

* test: paragraph hook

* test: paragraph overlay

* test: paragraph shortcuts

* fix: paragraph overlay

* test: paragraph mode

* test: shortcuts

* test: remove overlay

* test: remove hook

* test: remove utils

* fix: paragraph overlay

* fix: paragraph overlay

* feat: paragraph overlay

* fix: vertical container sizing

* test: paragraph container

* fix: paragraph animation

* fix: paragraph text animation

* fix: remove container morph
2026-04-02 20:56:38 +02:00
Huang Xin 05afaab5fd fix(layout): fixed static image size and layout shift on window resize, closes #3634 (#3729) 2026-04-02 20:50:02 +02:00
Huang Xin ff962a1f02 fix(android): auto-shutdown native TTS engine after 30 min idle to save battery (#3728)
When the Android native TTS engine is paused or stopped but not shut down,
it holds resources and drains battery. This adds a 30-minute idle timer that
automatically shuts down the TextToSpeech engine and MediaPlaybackService
after inactivity. The engine transparently re-initializes on next use.

Also adds missing androidx.lifecycle:lifecycle-process dependency to fix
ProcessLifecycleOwner build error.

Closes #3713

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:00:21 +02:00
Huang Xin 62df631dd1 feat(theme): add atmosphere easter egg with video overlay and ambient audio (#3727)
Hidden easter egg: re-clicking the active light mode sun icon
or dark mode moon icon activates an ambient atmosphere with a
komorebi leaf shadow video overlay and forest background audio. Audio
adapts to theme: birds for light mode, crickets for dark mode.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:12:10 +02:00
Lex Moulton c9647276b1 feat(rsvp): progress bar per chapter, speed selector dropdown, and UX improvements (#3723)
* fix(rsvp): rename pause setting to punctuation delay for clarity

* fix(rsvp): update chapter header as user crosses TOC sections

* fix(rsvp): stop playback at end of book instead of restarting last chapter

* feat(rsvp): convert WPM badge to speed selector dropdown

* feat(rsvp): scroll active chapter to center when chapter dropdown opens

* fix(rsvp): show correct chapter name in header instead of "Select Chapter"

* fix(rsvp): start from selected chapter when switching via dropdown

* fix(rsvp): restore saved position when resuming from a different section

* fix(rsvp): remove shrink animation from header buttons on click

* refactor(rsvp): process one spine section at a time, greadtly reducing complexity

* refactor(rsvp): remove dead state and derive progress from currentIndex

* feat(rsvp): allow pausing during countdown

* fix(rsvp): use relocate event instead of fixed 500ms delay for chapter transitions

* fix(rsvp): persist WPM and punctuation pause settings across sessions

* fix(rsvp): prefix unused bookKey field with underscore to satisfy lint

* Revert "fix(rsvp): prefix unused bookKey field with underscore to satisfy lint"

This reverts commit 7b3a34813f0c3771e9af8d00c7c7dc77c8db161c.

* fix(rsvp): remove unused bookKey field from RSVPController

* feat(rsvp): make progress bar draggable

* fix: revert settings.json changes

* fix(rsvp): restore genuinely useful comments

* i18n: update translations

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-04-02 03:31:58 +02:00
Mohammed Efaz 9ecb9b24d2 feat: make reading ruler selection and step navigation coherent (#3722)
* refactor(reader): extract reading ruler math helpers

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

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

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

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

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

* fix(reader): fall back to page turns at ruler edges
2026-04-01 18:59:44 +02:00
Huang Xin f0ab05bbde chore(agent): update agent skills and memories (#3721) 2026-04-01 16:44:52 +02:00
Huang Xin f1a08565e3 fix(storage): paginate stats query and align file size formatting (#3720)
Paginate Supabase select queries in the storage stats API to avoid
the 1000 row default limit. Align StorageManager's formatFileSize
with useQuotaStats calculation so quota values display consistently.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:44:15 +02:00
Huang Xin b8ddb5475e feat(sync): add full sync option for annotations in koplugin, closes #3710 (#3718)
Add "Full sync all annotations" menu item that pushes and pulls all
annotations regardless of the last sync timestamp, enabling users to
sync old highlights that were created before the plugin was installed.

Changes:
- Add full_sync parameter to push/pull that bypasses timestamp filter
  and uses since=0 for pulling all server annotations
- Deduplicate by annotation ID alongside position-based dedup
- Store server ID on pulled annotations and reuse it when pushing
- Parse ISO 8601 timestamps from server to preserve original
  created/updated dates instead of using current time
- Resolve KOReader page numbers from xpointers via getPageFromXPointer
- Resolve Readest page numbers from CFI via getCFIProgress on pull

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:35:48 +02:00
Huang Xin 74401fc1bb fix(library): always sort series books by index ascending, closes #3709 (#3717)
* fix(library): always sort series books by index ascending, closes #3709

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:01:48 +02:00
Huang Xin 29df8522fa chore(bump): bump Tauri to the latest version (#3716) 2026-04-01 10:06:23 +02:00
Teodor-Mihai Cosma 9f958a44e2 feat(i18n): add Romanian (ro) translation (#3708)
* feat(i18n): add Romanian (ro) translation

* chore: update tauri submodule for Romanian language support
2026-04-01 09:22:04 +02:00
Huang Xin 0e516f6e56 chore(test): add unit tests and enforce dash-case naming for test files (#3715)
Add ~290 new unit tests covering utils (lru, diff, css, a11y, walk,
usage, txt-worker), services (EdgeTTSClient, transformers), and
suppress noisy console output across all test files. Rename 27
camelCase test files to dash-case for consistency.

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

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

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:56:23 +02:00
Huang Xin f67930feb1 fix(opds): fix Copyparty books showing as "Untitled" in mixed feeds, closes #3667 (#3705) 2026-03-31 17:35:14 +02:00
Huang Xin 5e048ddab1 fix(iOS): use correct system theme mode in auto mode on iOS, closes #3698 (#3704) 2026-03-31 17:06:08 +02:00
Huang Xin e68dedd10d fix(layout): fix primary view detection on fractional DPR devices, closes #3681 (#3701) 2026-03-31 15:04:02 +02:00
Huang Xin d22b8bec1e i18n: update translations for aria label (#3697) 2026-03-30 19:10:31 +02:00
Huang Xin e9c5ebb696 fix(fonts): fix Adobe font deobfuscation and CSS var fallbacks, closes #3662 (#3696)
Two issues caused embedded EPUB fonts to fail:

1. Adobe font deobfuscation (http://ns.adobe.com/pdf/enc#RC) derived the
   XOR key from the wrong UUID. getUUID() picked the first UUID across all
   dc:identifier elements (e.g. Calibre's internal UUID) instead of the
   book's unique-identifier. Also fixed getElementById not working on
   DOMParser-parsed XML (no DTD), and made UUID regex case-insensitive.

2. transformStylesheet replaced generic font families (sans-serif, serif,
   monospace) with CSS var() references without fallback values. In
   fixed-layout iframes where setStyles doesn't inject the variables,
   the undefined var() invalidated the entire font-family declaration
   per CSS spec, causing all fonts to fall back to system defaults.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:06:22 +02:00
Huang Xin 956c71cd7b chore: migrate from ESLint to Biome for linting (#3694)
Replace ESLint with Biome for ~14x faster linting (~360ms vs ~5s).

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

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:25:04 +02:00
Huang Xin b4207bd742 chore(deps): bump vulnerable dependencies to address Dependabot alerts (#3693) 2026-03-30 15:59:03 +02:00
Huang Xin ec26ef4f29 fix(shortcuts): change bookmark shortcut from Ctrl+D to Ctrl+B (#3691)
* fix(shortcuts): change bookmark shortcut from Ctrl+D to Ctrl+B, closes #3675

Ctrl+D was bound to both Toggle Bookmark (General) and Dictionary
Lookup (Selection). When text was selected and Ctrl+D pressed, both
actions fired — opening the dictionary AND adding an unwanted bookmark.

Changed bookmark to Ctrl+B/Cmd+B to resolve the conflict. Added a
unit test that detects identical keybinding lists across actions to
prevent this class of bug in the future.

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

* i18n: mark shortcut section names for translation

Wrap SHORTCUT_SECTIONS values with stubTranslation() so i18next-scanner
picks them up. Translate the 4 new keys (General, Text to Speech, Zoom,
Window) across all 29 locales.

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-03-30 15:35:40 +02:00
Huang Xin b872868136 fix(layout): fixed infinite expand calls and freeze in the paginator, closes #3683 (#3692) 2026-03-30 15:16:56 +02:00
Huang Xin 797fe9c604 fix(layout): fixed infinite expand calls and freeze in the paginator, closes #3683 (#3690) 2026-03-30 14:50:59 +02:00
Huang Xin 8ed9290659 layout: don't truncate remaining progress info without status info, closes #3678 (#3685) 2026-03-30 05:52:09 +02:00
Huang Xin 8e6451863f css: add css selector for status badge, closes #3678 (#3684) 2026-03-30 05:19:29 +02:00
Huang Xin c688612888 chore(fdroid): build qcms wasm for fdroid (#3680) 2026-03-29 20:08:08 +02:00
Huang Xin b3333c384c chore(fdroid): get rid of wasm binaries in fdroid build (#3677) 2026-03-29 18:31:46 +02:00
Huang Xin 84349ab12d chore(release): exclude turso wasm in app builds (#3674) 2026-03-29 06:35:39 +02:00
Huang Xin c4e3315642 feat(scroll): add single section scroll option, closes #3663 (#3668) 2026-03-28 13:57:20 +08:00
Huang Xin bbfc82e50d feat(android): add foss flavor build without gms services (#3666) 2026-03-28 05:36:09 +01:00
Huang Xin 966f5e2acd fix(opds): fixed image download from ODPS server on the web, closes #3649 (#3658) 2026-03-27 13:21:52 +01:00
Huang Xin 45e5f0fa61 fix(eink): disable range editor loupe for annotations on Eink devices, closes #3655 (#3656) 2026-03-27 10:26:36 +01:00
Huang Xin 3d4d1482aa feat: add keyboard shortcuts help dialog (#3653)
Add a keyboard shortcuts help dialog toggled with '?' that displays all
shortcuts grouped by section with platform-appropriate key rendering.

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

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:59:38 +08:00
Huang Xin 5f897f648f feat(tts): add shortcuts to navigate and play/pause in TTS mode, closes #3620 (#3651) 2026-03-27 06:39:17 +01:00
Huang Xin 26fec924fc chore: bump turso to the latest version (#3650) 2026-03-27 06:28:59 +01:00
Huang Xin af9cf33936 fix(android): never try to fight with the navigation bar on Android ever, closes #3618 (#3646) 2026-03-26 17:40:49 +01:00
Huang Xin 52df478f21 fix: show proper background images in continuous scrolled mode, closes #3638 (#3645) 2026-03-26 16:41:55 +01:00
Huang Xin 97555a7e88 chore: bump next.js, opennextjs and wrangler to the latest versions (#3642) 2026-03-26 15:57:19 +01:00
Lee Kai Ze c7e82825f5 fix(koplugin): avoid resurrecting deleted annotations on pull (#3639)
Closes #3621.
2026-03-26 15:13:32 +01:00
dependabot[bot] bb82ab6c8a chore(deps): bump android-actions/setup-android (#3631) 2026-03-26 14:01:27 +08:00
Huang Xin e2faa9ad75 compat(android): disable native long-click on the WebView to prevent the system image context menu, closes #3629 (#3630) 2026-03-26 04:12:54 +01:00
Huang Xin f310305834 fix(library): mixed sorting for group and ungroupped books, closes #3596 (#3627) 2026-03-25 16:55:20 +01:00
Huang Xin 5a072e7d1f fix(pdf): apply theme colors for PDFs, closes #3593 (#3626) 2026-03-25 16:50:12 +01:00
Huang Xin 64675820f1 fix(koplugin): fixed koreader crash on logout, closes #3598 (#3603) 2026-03-23 15:08:51 +01:00
Huang Xin 1e942a23b4 chore(release): generate changelog from release notes for google play (#3591) 2026-03-22 16:38:26 +01:00
Huang Xin a92c357982 chore(release): disable linux-arm build for now as turso can't work on it for now (#3590) 2026-03-22 14:53:45 +01:00
Huang Xin 1ebf5e7b52 fix(release): skip architecture check for 32-bit ARM (#3589) 2026-03-22 14:22:22 +01:00
Huang Xin c11f79e843 release: version 0.10.1 (#3588) 2026-03-22 13:29:13 +01:00
Huang Xin 87f0240b0a compat(footnote): support footnote text in alt attribute of the image, closes #3576 (#3587)
refactor: remove unused continuous scroll
2026-03-22 12:17:02 +01:00
Huang Xin 2905506017 fix(layout): fixed total scrollable width in vertical scrolled mode, closes #3583 (#3586) 2026-03-22 11:56:12 +01:00
Huang Xin 1936136596 fix: resolve various tracked exceptions in ph (#3584)
* fix: handle synced bookmarks without cfi

* chore: clean up some exceptions in ph
2026-03-22 08:16:38 +01:00
Huang Xin e0cf7e8d9f fix: handle number input value clip in settings on mobile devices (#3582) 2026-03-22 05:26:21 +01:00
404 changed files with 43816 additions and 6104 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}}'
+6 -7
View File
@@ -124,11 +124,6 @@ jobs:
release: linux
arch: aarch64
rust_target: aarch64-unknown-linux-gnu
- os: ubuntu-22.04-arm
release: linux
arch: armhf
rust_target: arm-unknown-linux-gnueabihf
args: '--target arm-unknown-linux-gnueabihf --bundles deb'
- os: macos-latest
release: macos
arch: aarch64
@@ -171,7 +166,7 @@ jobs:
- name: setup Android SDK (for Android build only)
if: matrix.config.release == 'android'
uses: android-actions/setup-android@v3
uses: android-actions/setup-android@v4
- name: install NDK (for Android build only)
if: matrix.config.release == 'android'
@@ -196,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'
@@ -208,6 +203,7 @@ jobs:
echo 'PKG_CONFIG_PATH=/usr/lib/arm-linux-gnueabihf/pkgconfig:/usr/share/pkgconfig' >> $GITHUB_ENV
echo 'PKG_CONFIG_SYSROOT_DIR=/usr/arm-linux-gnueabihf' >> $GITHUB_ENV
echo 'CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc' >> $GITHUB_ENV
echo 'CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_RUSTFLAGS=--cfg=io_uring_skip_arch_check' >> $GITHUB_ENV
- name: create .env.local file for Next.js
run: |
@@ -395,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 }}
+2
View File
@@ -41,6 +41,7 @@ next-env.d.ts
target
fastlane/report.xml
fastlane/metadata/android/en-US/changelogs
*.koplugin.zip
@@ -48,6 +49,7 @@ fastlane/report.xml
result*
.playwright-mcp/
.gstack
.claude/worktrees
.claude/settings.local.json
+3
View File
@@ -16,3 +16,6 @@
[submodule "apps/readest-app/.claude/skills/gstack"]
path = apps/readest-app/.claude/skills/gstack
url = https://github.com/garrytan/gstack.git
[submodule "packages/qcms"]
path = packages/qcms
url = https://github.com/mozilla/pdf.js.qcms.git
Regular → Executable
View File
Generated
+448 -167
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -4,6 +4,9 @@ members = [
"packages/tauri/crates/tauri",
"packages/tauri-plugins/plugins/fs"
]
exclude = [
"packages/qcms"
]
resolver = "2"
[workspace.dependencies]
@@ -21,6 +24,9 @@ glob = "0.3"
zbus = "5.9"
dunce = "1"
url = "2"
tar = "0.4.45"
nix = "0.20.2"
glib = "0.20.0"
[workspace.package]
authors = ["Bilingify LLC"]
+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
+142
View File
@@ -0,0 +1,142 @@
# 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.
| Version | Supported |
| ------- | ------------------ |
| 0.10.x | :white_check_mark: |
| < 0.10 | :x: |
## Reporting a Vulnerability
Please report suspected vulnerabilities privately. Do not open a public GitHub
issue or discussion for security-sensitive reports.
Use GitHub's private vulnerability reporting for this repository:
<https://github.com/readest/readest/security/advisories/new>
When submitting a report, include:
- A clear description of the issue and the affected component
- Steps to reproduce, proof of concept, or a minimal test case
- The versions, platforms, or environments you tested
- Any suggested remediation or mitigating details, if available
What to expect after you report:
- We will aim to acknowledge receipt within 3 business days.
- We may contact you for additional details, reproduction steps, or validation.
- If the report is accepted, we will work on a fix and coordinate disclosure.
- If the report is declined, we will explain why, for example if the behavior is
expected, unsupported, or not reproducible.
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 |
+15
View File
@@ -16,6 +16,13 @@
- `src/app/reader/components/FoliateViewer.tsx` - Reader view orchestration
- `src/app/reader/components/annotator/Annotator.tsx` - Annotation lifecycle
## 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/`
- Multiview paginator: loads adjacent sections in background, multiple View/Overlayer instances per book
@@ -24,3 +31,11 @@
- TTS uses independent section tracking (`#ttsSectionIndex`) decoupled from view
- Safe area insets flow: Native plugin -> useSafeAreaInsets hook -> component styles
- 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,40 @@
---
name: D-pad Navigation Design
description: Android TV / Bluetooth remote D-pad navigation architecture, key files, and pitfalls encountered during implementation
type: project
---
## D-pad Navigation Architecture
D-pad support enables Bluetooth remote controller navigation on Android TV (and keyboard arrow navigation on desktop).
### Key Files
- `src/app/reader/hooks/useSpatialNavigation.ts` — Reader toolbar D-pad navigation. Left/Right navigates between buttons, Up/Down moves between header↔footer. Auto-focuses first button on show. Uses focus-probe technique for visibility detection.
- `src/app/library/hooks/useSpatialNavigation.ts` — Library grid D-pad navigation. Arrow keys move between BookshelfItem elements. ArrowDown from outside bookshelf (e.g. header) enters the grid via window-level listener.
- `src/helpers/shortcuts.ts``onToggleToolbar` (Enter key) toggles reader toolbar visibility.
- `src/app/reader/hooks/useBookShortcuts.ts``toggleToolbar` handler shows/hides header+footer bars. Skips when a `<button>` is focused (lets native click fire).
- `src/__tests__/hooks/useSpatialNavigation.test.tsx` — Unit tests for reader toolbar navigation.
### Design Decisions
- **No third-party library**: Tried `@noriginmedia/norigin-spatial-navigation` but it failed due to init timing issues (React child effects run before parent effects) and conflicts with the existing `useShortcuts` system. Custom solution is simpler and more reliable.
- **Two `useSpatialNavigation` hooks**: Same name in different directories — library version handles grid navigation, reader version handles toolbar button navigation. Different navigation patterns but same concept.
- **Platform-agnostic hooks**: Both `useSpatialNavigation` hooks work on all platforms, not just Android.
- **Focus-probe for visibility**: `offsetParent` is unreliable for detecting visible buttons (returns null inside `position: fixed` containers on mobile). Instead, try `btn.focus()` and check if `document.activeElement === btn` — this correctly handles all hiding methods (display:none, visibility:hidden, fixed positioning).
### Pitfalls
1. **WebView spatial navigation conflict**: Android WebView has built-in spatial navigation that intercepts D-pad arrow keys and moves DOM focus between `tabIndex>=0` elements. Added `tabIndex={-1}` to non-interactive overlay elements (HeaderBar trigger, ProgressBar, FooterBar trigger, SectionInfo) to prevent focus theft.
2. **`eventDispatcher.dispatchSync` short-circuits**: When multiple handlers are registered for `native-key-down`, the first handler returning `true` stops propagation. The FooterBar's Back handler fires before the Reader's. Both must independently call `blur()` — can't rely on the Reader's handler running.
3. **Must blur on toolbar dismiss**: When Back/Escape dismisses the toolbar, the focused button must be blurred. Otherwise `document.activeElement` remains a hidden button, and `toggleToolbar` skips Enter when `activeElement.tagName === 'BUTTON'`. Blur is called in FooterBar's handleKeyDown (for Back and Escape) and in Reader's handleKeyDown.
4. **Arrow key trapping must use `stopPropagation`**: Without it, arrow keys bubble to `window` where `useShortcuts` handles them as page turns. The toolbar keydown handler on the container div calls `e.stopPropagation()` + `e.preventDefault()` to prevent this.
5. **Library grid needs window-level listener**: The bookshelf container keydown handler only fires when focus is inside it. A separate `window` keydown listener handles ArrowDown from the header into the grid (when focus is outside the container).
6. **Auto-focus race on toolbar show**: Both header and footer bars auto-focus their first button when `isVisible` becomes true simultaneously. The last effect to run wins. This is acceptable — user can navigate between them with Up/Down.
7. **`offsetParent` null in fixed containers**: On mobile, `.footer-bar` uses `position: fixed`. All child buttons have `offsetParent === null`, making `offsetParent`-based visibility checks useless. The focus-probe approach (try focus, check activeElement) is the reliable alternative.
@@ -0,0 +1,11 @@
---
name: gstack upgrade location
description: Always upgrade gstack from the project directory (.claude/skills/gstack), not from a global install
type: feedback
---
When upgrading gstack, always run the upgrade from the current project's `.claude/skills/gstack` directory (local-git install), not from a global install path.
**Why:** The project uses a local-git gstack install at `apps/readest-app/.claude/skills/gstack`. Previous mistakes upgraded a global copy while the project's local copy stayed outdated.
**How to apply:** When `/gstack-upgrade` is invoked, ensure the `cd` and `git reset --hard origin/main && ./setup` happen inside the project's `.claude/skills/gstack` directory.
@@ -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: Always use a new branch for new PRs
description: Each new PR/issue should get its own fresh branch from main, never reuse an existing feature branch
type: feedback
---
Always create a new branch from main for each new PR or issue. Never reuse an existing feature branch for unrelated work.
**Why:** The user corrected this when a storage fix was committed on the `feat/full-sync-annotations` branch instead of a dedicated branch. Mixing unrelated changes on the same branch makes PRs harder to review and manage.
**How to apply:** Before committing fixes, create a new branch like `fix/<topic>` from `origin/main`. Only reuse a branch if the work is directly related to that branch's existing purpose.
@@ -0,0 +1,11 @@
---
name: Always rebase before PR
description: Rebase to origin/main before creating pull requests
type: feedback
---
Always rebase the branch onto origin/main before creating a pull request.
**Why:** The user wants PRs to be up-to-date with main to avoid merge conflicts and keep a clean history.
**How to apply:** Before running `gh pr create`, always run `git fetch origin && git rebase origin/main` first. If there are conflicts, resolve them before proceeding.
@@ -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 -1
View File
@@ -3,7 +3,7 @@ PDFJS_FONTS_PATH=../../packages/foliate-js/node_modules/pdfjs-dist
PDFJS_STYLE_PATH=../../packages/foliate-js/vendor/pdfjs
NEXT_PUBLIC_DEFAULT_POSTHOG_URL_BASE64="aHR0cHM6Ly91cy5pLnBvc3Rob2cuY29t"
NEXT_PUBLIC_DEFAULT_POSTHOG_KEY_BASE64="cGhjX2ViNXowbVRxWm8yZm5YYnZGNmE3bFh5TThpTmRSNTNsR1A3VFM3VGh4S08="
NEXT_PUBLIC_DEFAULT_POSTHOG_KEY_BASE64="cGhjX0x3ekhZRWtsZUVub3ZSc05ZQlRpTVRTV2MyS1NUOFdZMzBIWWFhN0ZPa1IK"
NEXT_PUBLIC_DEFAULT_SUPABASE_URL_BASE64="aHR0cHM6Ly9yZWFkZXN0LnN1cGFiYXNlLmNv"
NEXT_PUBLIC_DEFAULT_SUPABASE_KEY_BASE64="ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKemRYQmhZbUZ6WlNJc0luSmxaaUk2SW5aaWMzbDRablZ6YW1weFpIaHJhbkZzZVhOaklpd2ljbTlzWlNJNkltRnViMjRpTENKcFlYUWlPakUzTXpReE1qTTJOekVzSW1WNGNDSTZNakEwT1RZNU9UWTNNWDAuM1U1VXFhb3VfMVNnclZlMWVvOXJBcGMwdUtqcWhwUWRVWGh2d1VIbVVmZw=="
+2
View File
@@ -8,6 +8,8 @@
# testing
/coverage
.test-sandbox-node/
.vitest-attachments/
# next.js
/.next/
+10 -5
View File
@@ -22,7 +22,7 @@ pnpm tauri:dev:test # Start Tauri app with webdriver
pnpm test:tauri # Run Tauri integration tests
# Linting & Formatting
pnpm lint # ESLint
pnpm lint # Biome (linter) + tsgo (type check)
pnpm format # Prettier (runs from monorepo root)
pnpm format:check # Check formatting without writing
@@ -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
+125
View File
@@ -0,0 +1,125 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.9/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": { "ignoreUnknown": true },
"formatter": { "enabled": false },
"assist": { "enabled": false },
"css": { "linter": { "enabled": false } },
"linter": {
"enabled": true,
"includes": [
"**",
"!.next/**",
"!.open-next/**",
"!.wrangler/**",
"!.claude/**",
"!dist/**",
"!out/**",
"!build/**",
"!public/**",
"!src-tauri/**",
"!next-env.d.ts",
"!i18next-scanner.config.cjs"
],
"rules": {
"recommended": true,
"a11y": {
"recommended": true,
"useKeyWithClickEvents": "off",
"useKeyWithMouseEvents": "off",
"noSvgWithoutTitle": "off",
"noLabelWithoutControl": "off",
"useSemanticElements": "off",
"noAriaHiddenOnFocusable": "off",
"noInteractiveElementToNoninteractiveRole": "off",
"noNoninteractiveElementToInteractiveRole": "off",
"noNoninteractiveElementInteractions": "off",
"noStaticElementInteractions": "off",
"noNoninteractiveTabindex": "off",
"useButtonType": "off",
"useAriaPropsSupportedByRole": "off",
"useFocusableInteractive": "off",
"noAutofocus": "off"
},
"complexity": {
"noForEach": "off",
"noStaticOnlyClass": "off",
"noUselessSwitchCase": "off",
"noUselessFragments": "off",
"noUselessCatch": "off",
"useLiteralKeys": "off",
"useOptionalChain": "off",
"noThisInStatic": "off",
"useArrowFunction": "off",
"noUselessEscapeInRegex": "off"
},
"correctness": {
"noUnusedVariables": "warn",
"noUnusedImports": "error",
"useExhaustiveDependencies": "off",
"useHookAtTopLevel": "error",
"useJsxKeyInIterable": "error",
"noChildrenProp": "error",
"noNextAsyncClientComponent": "warn",
"noSwitchDeclarations": "off",
"noUndeclaredVariables": "off",
"noEmptyCharacterClassInRegex": "off",
"useParseIntRadix": "off",
"noEmptyPattern": "off"
},
"nursery": {
"noBeforeInteractiveScriptOutsideDocument": "warn",
"noDuplicateEnumValues": "error",
"noSyncScripts": "warn",
"useInlineScriptId": "error"
},
"performance": {
"noImgElement": "off",
"noUnwantedPolyfillio": "warn",
"useGoogleFontPreconnect": "warn"
},
"security": {
"noBlankTarget": "off",
"noDangerouslySetInnerHtmlWithChildren": "off",
"noDangerouslySetInnerHtml": "off"
},
"style": {
"noNonNullAssertion": "off",
"useImportType": "off",
"noParameterAssign": "off",
"useDefaultParameterLast": "off",
"noUselessElse": "off",
"noHeadElement": "warn",
"noCommonJs": "off",
"useFilenamingConvention": "off",
"useNamingConvention": "off",
"noUnusedTemplateLiteral": "off",
"useTemplate": "off",
"useExponentiationOperator": "off",
"useNodejsImportProtocol": "off"
},
"suspicious": {
"noExplicitAny": "error",
"noArrayIndexKey": "off",
"noAssignInExpressions": "off",
"noDoubleEquals": "off",
"noDocumentImportInPage": "error",
"noHeadImportInDocument": "error",
"useGoogleFontDisplay": "warn",
"noCommentText": "error",
"noDuplicateJsxProps": "error",
"noAsyncPromiseExecutor": "off",
"noImplicitAnyLet": "off",
"noControlCharactersInRegex": "off",
"noEmptyBlockStatements": "off",
"useIterableCallbackReturn": "off",
"noGlobalIsNan": "off",
"noConfusingVoidType": "off",
"noConstEnum": "off"
}
}
},
"javascript": {
"globals": ["React"]
}
}
+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`
-41
View File
@@ -1,41 +0,0 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import next from 'eslint-config-next';
import nextVitals from 'eslint-config-next/core-web-vitals';
import tseslint from 'eslint-config-next/typescript';
import jsxA11y from 'eslint-plugin-jsx-a11y';
const eslintConfig = defineConfig([
...tseslint,
...next,
...nextVitals,
{
rules: {
...jsxA11y.configs.recommended.rules,
'@next/next/no-img-element': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
globalIgnores([
'node_modules/**',
'.next/**',
'.open-next/**',
'.wrangler/**',
'.claude/**',
'dist/**',
'out/**',
'build/**',
'public/**',
'src-tauri/**',
'next-env.d.ts',
'i18next-scanner.config.cjs',
]),
]);
export default eslintConfig;
@@ -35,6 +35,8 @@ const options = {
'si',
'zh-CN',
'zh-TW',
'ro',
'hu',
],
ns: ['translation'],
defaultNs: 'translation',
+2
View File
@@ -31,12 +31,14 @@ const nextConfig = {
config.resolve.alias = {
...config.resolve.alias,
nunjucks: 'nunjucks/browser/nunjucks.js',
...(appPlatform !== 'web' ? { '@tursodatabase/database-wasm': false } : {}),
};
return config;
},
turbopack: {
resolveAlias: {
nunjucks: 'nunjucks/browser/nunjucks.js',
...(appPlatform !== 'web' ? { '@tursodatabase/database-wasm': './src/utils/stub.ts' } : {}),
},
},
transpilePackages: [
+24 -19
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.101",
"version": "0.10.6",
"private": true,
"type": "module",
"scripts": {
@@ -15,9 +15,10 @@
"start-web:vinext": "dotenv -e .env.web -- vinext start",
"build-tauri": "dotenv -e .env.tauri -- next build",
"i18n:extract": "i18next-scanner --config i18next-scanner.config.cjs",
"lint": "tsgo --noEmit && eslint --cache .",
"test": "dotenv -e .env -e .env.test.local vitest",
"test:browser": "vitest --config vitest.browser.config.mts --watch=false",
"lint": "tsgo --noEmit && biome check .",
"test": "dotenv -e .env -e .env.test.local -- vitest",
"test:coverage": "dotenv -e .env -e .env.test.local -- vitest run --coverage",
"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",
@@ -65,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",
@@ -79,7 +82,7 @@
"@emnapi/runtime": "^1.7.1",
"@fabianlars/tauri-plugin-oauth": "2",
"@napi-rs/wasm-runtime": "^1.1.1",
"@opennextjs/cloudflare": "^1.17.1",
"@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",
@@ -145,15 +148,15 @@
"lunr": "^2.3.9",
"marked": "^15.0.12",
"nanoid": "^5.1.6",
"next": "16.1.7",
"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",
@@ -193,13 +196,12 @@
"@types/tinycolor2": "^1.4.6",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.48.0",
"@typescript-eslint/parser": "^8.48.0",
"@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",
"@wdio/cli": "^9.25.0",
"@wdio/globals": "^9.23.0",
"@wdio/local-runner": "^9.24.0",
@@ -211,9 +213,6 @@
"cpx2": "^8.0.0",
"daisyui": "^4.12.24",
"dotenv-cli": "^7.4.4",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.6",
"eslint-plugin-jsx-a11y": "^6.10.2",
"i18next-scanner": "^4.6.0",
"jsdom": "^28.1.0",
"mkdirp": "^3.0.1",
@@ -223,14 +222,20 @@
"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.73.0"
}
"wrangler": "^4.81.1"
},
"browserslist": [
"chrome 92",
"edge 92",
"firefox 92",
"safari 15.2"
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -183,7 +183,6 @@
"RTL Direction": "الاتجاه من اليمين إلى اليسار",
"Maximum Column Height": "الارتفاع الأقصى للعمود",
"Maximum Column Width": "العرض الأقصى للعمود",
"Continuous Scroll": "التمرير المستمر",
"Fullscreen": "ملء الشاشة",
"No supported files found. Supported formats: {{formats}}": "لم يتم العثور على ملفات مدعومة. الصيغ المدعومة: {{formats}}",
"Drop to Import Books": "قم بالسحب والإسقاط لاستيراد الكتب",
@@ -1031,7 +1030,6 @@
"words": "كلمات",
"{{time}} left": "متبقي {{time}}",
"Reading progress": "تقدم القراءة",
"Click to seek": "انقر للتمرير",
"Skip back 15 words": "تخطي للخلف 15 كلمة",
"Back 15 words (Shift+Left)": "للخلف 15 كلمة (Shift+اليسار)",
"Pause (Space)": "إيقاف مؤقت (المسافة)",
@@ -1154,5 +1152,96 @@
"{{count}} books refreshed_many": "تم تحديث {{count}} كتابًا",
"{{count}} books refreshed_other": "تم تحديث {{count}} كتاب",
"Failed to refresh metadata": "فشل تحديث البيانات الوصفية",
"Refresh Metadata": "تحديث البيانات الوصفية"
"Refresh Metadata": "تحديث البيانات الوصفية",
"Keyboard Shortcuts": "اختصارات لوحة المفاتيح",
"View all keyboard shortcuts": "عرض جميع اختصارات لوحة المفاتيح",
"Switch Sidebar Tab": "تبديل علامة تبويب الشريط الجانبي",
"Toggle Notebook": "إظهار/إخفاء دفتر الملاحظات",
"Search in Book": "البحث في الكتاب",
"Toggle Scroll Mode": "تبديل وضع التمرير",
"Toggle Select Mode": "تبديل وضع التحديد",
"Toggle Bookmark": "تبديل الإشارة المرجعية",
"Toggle Text to Speech": "تبديل تحويل النص إلى كلام",
"Play / Pause TTS": "تشغيل / إيقاف القراءة الصوتية",
"Toggle Paragraph Mode": "تبديل وضع الفقرة",
"Highlight Selection": "تمييز التحديد",
"Underline Selection": "وضع خط تحت التحديد",
"Annotate Selection": "إضافة تعليق على التحديد",
"Search Selection": "البحث عن التحديد",
"Copy Selection": "نسخ التحديد",
"Translate Selection": "ترجمة التحديد",
"Dictionary Lookup": "البحث في القاموس",
"Wikipedia Lookup": "البحث في ويكيبيديا",
"Read Aloud Selection": "قراءة التحديد بصوت عالٍ",
"Proofread Selection": "تدقيق التحديد",
"Open Settings": "فتح الإعدادات",
"Open Command Palette": "فتح لوحة الأوامر",
"Show Keyboard Shortcuts": "عرض اختصارات لوحة المفاتيح",
"Open Books": "فتح الكتب",
"Toggle Fullscreen": "تبديل ملء الشاشة",
"Close Window": "إغلاق النافذة",
"Quit App": "إنهاء التطبيق",
"Go Left / Previous Page": "الانتقال لليسار / الصفحة السابقة",
"Go Right / Next Page": "الانتقال لليمين / الصفحة التالية",
"Go Up": "الانتقال لأعلى",
"Go Down": "الانتقال لأسفل",
"Previous Chapter": "الفصل السابق",
"Next Chapter": "الفصل التالي",
"Scroll Half Page Down": "تمرير نصف صفحة لأسفل",
"Scroll Half Page Up": "تمرير نصف صفحة لأعلى",
"Save Note": "حفظ الملاحظة",
"Single Section Scroll": "التمرير بقسم واحد",
"General": "عام",
"Text to Speech": "تحويل النص إلى كلام",
"Zoom": "تكبير",
"Window": "نافذة",
"Your Bookshelf": "رف الكتب الخاص بك",
"TTS": "تحويل النص إلى كلام",
"Media Info": "معلومات الوسائط",
"Update Frequency": "تردد التحديث",
"Every Sentence": "كل جملة",
"Every Paragraph": "كل فقرة",
"Every Chapter": "كل فصل",
"TTS Media Info Update Frequency": "تردد تحديث معلومات وسائط TTS",
"Select reading speed": "اختر سرعة القراءة",
"Drag to seek": "اسحب للتقديم",
"Punctuation Delay": "تأخير علامات الترقيم",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "يرجى التأكيد على أن اتصال OPDS هذا سيتم توجيهه عبر خوادم Readest على تطبيق الويب قبل المتابعة.",
"Custom Headers": "رؤوس مخصصة",
"Custom Headers (optional)": "رؤوس مخصصة (اختياري)",
"Add one header per line using \"Header-Name: value\".": "أضف رأسًا واحدًا لكل سطر باستخدام \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "أفهم أن اتصال OPDS هذا سيتم توجيهه عبر خوادم Readest على تطبيق الويب. إذا لم أثق بـ Readest فيما يتعلق ببيانات الاعتماد أو الرؤوس هذه، يجب أن أستخدم التطبيق الأصلي بدلاً من ذلك.",
"Unable to connect to Hardcover. Please check your network connection.": "تعذر الاتصال بـ Hardcover. يرجى التحقق من اتصال الشبكة.",
"Invalid Hardcover API token": "رمز API الخاص بـ Hardcover غير صالح",
"Disconnected from Hardcover": "تم قطع الاتصال بـ Hardcover",
"Hardcover Settings": "إعدادات Hardcover",
"Connected to Hardcover": "متصل بـ Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "اربط حسابك على Hardcover لمزامنة تقدم القراءة والملاحظات.",
"Get your API token from hardcover.app → Settings → API.": "احصل على رمز API من hardcover.app ← الإعدادات ← API.",
"API Token": "رمز API",
"Paste your Hardcover API token": "الصق رمز API الخاص بك في Hardcover",
"Hardcover sync enabled for this book": "تم تفعيل مزامنة Hardcover لهذا الكتاب",
"Hardcover sync disabled for this book": "تم تعطيل مزامنة Hardcover لهذا الكتاب",
"Hardcover Sync": "مزامنة Hardcover",
"Enable for This Book": "تفعيل لهذا الكتاب",
"Push Notes": "دفع الملاحظات",
"Enable Hardcover sync for this book first.": "قم بتفعيل مزامنة Hardcover لهذا الكتاب أولاً.",
"No annotations or excerpts to sync for this book.": "لا توجد تعليقات توضيحية أو مقتطفات لمزامنتها لهذا الكتاب.",
"Configure Hardcover in Settings first.": "قم بإعداد Hardcover في الإعدادات أولاً.",
"No new Hardcover note changes to sync.": "لا توجد تغييرات جديدة في ملاحظات Hardcover للمزامنة.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "تمت مزامنة Hardcover: {{inserted}} جديد، {{updated}} محدث، {{skipped}} بدون تغيير",
"Hardcover notes sync failed: {{error}}": "فشلت مزامنة ملاحظات Hardcover: {{error}}",
"Reading progress synced to Hardcover": "تمت مزامنة تقدم القراءة إلى Hardcover",
"Hardcover progress sync failed: {{error}}": "فشلت مزامنة تقدم Hardcover: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "الإبقاء على معرف المصدر الحالي",
"Toggle Toolbar": "تبديل شريط الأدوات",
"Split Hyphens": "تقسيم الواصلات",
"Apply Theme Colors to PDF": "تطبيق ألوان السمة على PDF",
"Name": "الاسم",
"Unavailable": "غير متاح",
"Remove": "إزالة",
"Edit OPDS Catalog": "تعديل كتالوج OPDS",
"Save Changes": "حفظ التغييرات",
"Use Book Layout": "استخدام تخطيط الكتاب"
}
@@ -199,7 +199,6 @@
"Auto": "অটো",
"Scroll": "স্ক্রল",
"Scrolled Mode": "স্ক্রল মোড",
"Continuous Scroll": "ক্রমাগত স্ক্রল",
"Overlap Pixels": "ওভারল্যাপ পিক্সেল",
"Disable Double Click": "ডাবল ক্লিক নিষ্ক্রিয়",
"Volume Keys for Page Flip": "পৃষ্ঠা উল্টানোর জন্য ভলিউম কী",
@@ -983,7 +982,6 @@
"words": "শব্দ",
"{{time}} left": "{{time}} বাকি",
"Reading progress": "পড়ার অগ্রগতি",
"Click to seek": "খুঁজতে ক্লিক করুন",
"Skip back 15 words": "১৫ শব্দ পিছিয়ে যান",
"Back 15 words (Shift+Left)": "১৫ শব্দ পিছিয়ে যান (Shift+Left)",
"Pause (Space)": "বিরতি (Space)",
@@ -1102,5 +1100,96 @@
"{{count}} books refreshed_one": "{{count}}টি বইয়ের মেটাডেটা রিফ্রেশ হয়েছে",
"{{count}} books refreshed_other": "{{count}}টি বইয়ের মেটাডেটা রিফ্রেশ হয়েছে",
"Failed to refresh metadata": "মেটাডেটা রিফ্রেশ ব্যর্থ",
"Refresh Metadata": "মেটাডেটা রিফ্রেশ"
"Refresh Metadata": "মেটাডেটা রিফ্রেশ",
"Keyboard Shortcuts": "কীবোর্ড শর্টকাট",
"View all keyboard shortcuts": "সমস্ত কীবোর্ড শর্টকাট দেখুন",
"Switch Sidebar Tab": "সাইডবার ট্যাব পরিবর্তন করুন",
"Toggle Notebook": "নোটবুক দেখান/লুকান",
"Search in Book": "বইয়ে অনুসন্ধান করুন",
"Toggle Scroll Mode": "স্ক্রল মোড টগল করুন",
"Toggle Select Mode": "নির্বাচন মোড টগল করুন",
"Toggle Bookmark": "বুকমার্ক টগল করুন",
"Toggle Text to Speech": "টেক্সট টু স্পিচ টগল করুন",
"Play / Pause TTS": "TTS চালান / বিরতি দিন",
"Toggle Paragraph Mode": "অনুচ্ছেদ মোড টগল করুন",
"Highlight Selection": "নির্বাচিত অংশ হাইলাইট করুন",
"Underline Selection": "নির্বাচিত অংশে আন্ডারলাইন করুন",
"Annotate Selection": "নির্বাচিত অংশে টীকা যোগ করুন",
"Search Selection": "নির্বাচিত অংশ অনুসন্ধান করুন",
"Copy Selection": "নির্বাচিত অংশ কপি করুন",
"Translate Selection": "নির্বাচিত অংশ অনুবাদ করুন",
"Dictionary Lookup": "অভিধানে খুঁজুন",
"Wikipedia Lookup": "উইকিপিডিয়ায় খুঁজুন",
"Read Aloud Selection": "নির্বাচিত অংশ জোরে পড়ুন",
"Proofread Selection": "নির্বাচিত অংশ প্রুফরিড করুন",
"Open Settings": "সেটিংস খুলুন",
"Open Command Palette": "কমান্ড প্যালেট খুলুন",
"Show Keyboard Shortcuts": "কীবোর্ড শর্টকাট দেখান",
"Open Books": "বই খুলুন",
"Toggle Fullscreen": "পূর্ণ পর্দা টগল করুন",
"Close Window": "উইন্ডো বন্ধ করুন",
"Quit App": "অ্যাপ থেকে প্রস্থান করুন",
"Go Left / Previous Page": "বামে যান / আগের পৃষ্ঠা",
"Go Right / Next Page": "ডানে যান / পরের পৃষ্ঠা",
"Go Up": "উপরে যান",
"Go Down": "নিচে যান",
"Previous Chapter": "আগের অধ্যায়",
"Next Chapter": "পরের অধ্যায়",
"Scroll Half Page Down": "অর্ধেক পৃষ্ঠা নিচে স্ক্রল করুন",
"Scroll Half Page Up": "অর্ধেক পৃষ্ঠা উপরে স্ক্রল করুন",
"Save Note": "নোট সংরক্ষণ করুন",
"Single Section Scroll": "একক অধ্যায় স্ক্রল",
"General": "সাধারণ",
"Text to Speech": "পাঠ থেকে বাক্য",
"Zoom": "জুম",
"Window": "উইন্ডো",
"Your Bookshelf": "আপনার বইয়ের তাক",
"TTS": "টেক্সট টু স্পিচ",
"Media Info": "মিডিয়া তথ্য",
"Update Frequency": "আপডেটের ফ্রিকোয়েন্সি",
"Every Sentence": "প্রতিটি বাক্য",
"Every Paragraph": "প্রতিটি অনুচ্ছেদ",
"Every Chapter": "প্রতিটি অধ্যায়",
"TTS Media Info Update Frequency": "TTS মিডিয়া তথ্য আপডেট ফ্রিকোয়েন্সি",
"Select reading speed": "পড়ার গতি নির্বাচন করুন",
"Drag to seek": "খুঁজতে টানুন",
"Punctuation Delay": "বিরাম চিহ্ন বিলম্ব",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "অনুগ্রহ করে নিশ্চিত করুন যে এই OPDS সংযোগটি ওয়েব অ্যাপে Readest সার্ভারের মাধ্যমে প্রক্সি করা হবে।",
"Custom Headers": "কাস্টম হেডার",
"Custom Headers (optional)": "কাস্টম হেডার (ঐচ্ছিক)",
"Add one header per line using \"Header-Name: value\".": "প্রতি লাইনে একটি হেডার যোগ করুন \"Header-Name: value\" ব্যবহার করে।",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "আমি বুঝতে পারছি যে এই OPDS সংযোগটি ওয়েব অ্যাপে Readest সার্ভারের মাধ্যমে প্রক্সি করা হবে। আমি যদি এই শংসাপত্র বা হেডারগুলির সাথে Readest-কে বিশ্বাস না করি, তবে আমার পরিবর্তে নেটিভ অ্যাপটি ব্যবহার করা উচিত।",
"Unable to connect to Hardcover. Please check your network connection.": "Hardcover-এ সংযোগ করা যাচ্ছে না। অনুগ্রহ করে আপনার নেটওয়ার্ক সংযোগ পরীক্ষা করুন।",
"Invalid Hardcover API token": "অবৈধ Hardcover API টোকেন",
"Disconnected from Hardcover": "Hardcover থেকে সংযোগ বিচ্ছিন্ন",
"Hardcover Settings": "Hardcover সেটিংস",
"Connected to Hardcover": "Hardcover-এ সংযুক্ত",
"Connect your Hardcover account to sync reading progress and notes.": "পড়ার অগ্রগতি এবং নোট সিঙ্ক করতে আপনার Hardcover অ্যাকাউন্ট সংযুক্ত করুন।",
"Get your API token from hardcover.app → Settings → API.": "hardcover.app → সেটিংস → API থেকে আপনার API টোকেন পান।",
"API Token": "API টোকেন",
"Paste your Hardcover API token": "আপনার Hardcover API টোকেন পেস্ট করুন",
"Hardcover sync enabled for this book": "এই বইয়ের জন্য Hardcover সিঙ্ক সক্রিয়",
"Hardcover sync disabled for this book": "এই বইয়ের জন্য Hardcover সিঙ্ক নিষ্ক্রিয়",
"Hardcover Sync": "Hardcover সিঙ্ক",
"Enable for This Book": "এই বইয়ের জন্য সক্রিয় করুন",
"Push Notes": "নোট পুশ করুন",
"Enable Hardcover sync for this book first.": "প্রথমে এই বইয়ের জন্য Hardcover সিঙ্ক সক্রিয় করুন।",
"No annotations or excerpts to sync for this book.": "এই বইয়ের জন্য সিঙ্ক করার মতো কোনো টীকা বা উদ্ধৃতি নেই।",
"Configure Hardcover in Settings first.": "প্রথমে সেটিংসে Hardcover কনফিগার করুন।",
"No new Hardcover note changes to sync.": "সিঙ্ক করার জন্য নতুন কোনো Hardcover নোট পরিবর্তন নেই।",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover সিঙ্ক সম্পন্ন: {{inserted}} নতুন, {{updated}} আপডেট, {{skipped}} অপরিবর্তিত",
"Hardcover notes sync failed: {{error}}": "Hardcover নোট সিঙ্ক ব্যর্থ: {{error}}",
"Reading progress synced to Hardcover": "পড়ার অগ্রগতি Hardcover-এ সিঙ্ক হয়েছে",
"Hardcover progress sync failed: {{error}}": "Hardcover অগ্রগতি সিঙ্ক ব্যর্থ: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "বিদ্যমান উৎস শনাক্তকারী রাখুন",
"Toggle Toolbar": "টুলবার টগল করুন",
"Split Hyphens": "হাইফেন বিভক্ত করুন",
"Apply Theme Colors to PDF": "PDF-এ থিম রং প্রয়োগ করুন",
"Name": "নাম",
"Unavailable": "অনুপলব্ধ",
"Remove": "সরান",
"Edit OPDS Catalog": "OPDS ক্যাটালগ সম্পাদনা",
"Save Changes": "পরিবর্তন সংরক্ষণ",
"Use Book Layout": "বইয়ের লেআউট ব্যবহার করুন"
}
@@ -184,7 +184,6 @@
"RTL Direction": "གཡས་ནས་གཡོན་དུ།",
"Maximum Column Height": "གྲལ་ཐིག་མཐོ་ཤོས།",
"Maximum Column Width": "གྲལ་ཐིག་རྒྱ་ཆེ་ཤོས།",
"Continuous Scroll": "རྒྱུན་མཐུད་འཁོར་རྒྱུག",
"Fullscreen": "བརྡ་ཁྱབ་ངོས་ཆ་ཚང་།",
"No supported files found. Supported formats: {{formats}}": "རྒྱབ་སྐྱོར་བྱེད་པའི་ཡིག་ཆ་མ་རྙེད། རྒྱབ་སྐྱོར་བྱེད་པའི་རྣམ་པ། {{formats}}",
"Drop to Import Books": "འདྲུད་འཇོག་བྱས་ནས་དཔེ་དེབ་ནང་འདྲེན།",
@@ -971,7 +970,6 @@
"words": "ཚིག",
"{{time}} left": "དུས་ཚོད་ {{time}} ལྷག་ཡོད།",
"Reading progress": "ཀློག་པའི་འཕེལ་རིམ།",
"Click to seek": "གནས་ས་འཚོལ་བར་གནོན་པ།",
"Skip back 15 words": "ཚིག་ ༡༥ རྒྱབ་ལ་བཤུད་པ།",
"Back 15 words (Shift+Left)": "ཚིག་ ༡༥ རྒྱབ་ལ་བཤུད་པ། (Shift+Left)",
"Pause (Space)": "མཚམས་འཇོག་པ། (Space)",
@@ -1089,5 +1087,96 @@
"Loading library...": "དཔེ་མཛོད་འཇུག་བཞིན་པ...",
"{{count}} books refreshed_other": "تم تحديث {{count}} كتب",
"Failed to refresh metadata": "མེ་ཊ་ཌེ་ཊ་གསར་བསྒྱུར་བྱེད་མ་ཐུབ",
"Refresh Metadata": "མེ་ཊ་ཌེ་ཊ་གསར་བསྒྱུར"
"Refresh Metadata": "མེ་ཊ་ཌེ་ཊ་གསར་བསྒྱུར",
"Keyboard Shortcuts": "མཐེབ་གཞོང་མྱུར་ལམ།",
"View all keyboard shortcuts": "མཐེབ་གཞོང་མྱུར་ལམ་ཚང་མ་ལྟ་བ།",
"Switch Sidebar Tab": "ཟུར་འབར་གྱི་ཤོག་བྱང་བརྗེ་བ།",
"Toggle Notebook": "ཟིན་དེབ་སྟོན་པ།/སྦས་པ།",
"Search in Book": "དཔེ་དེབ་ནང་འཚོལ་བ།",
"Toggle Scroll Mode": "འཁྱོག་སྒུལ་རྣམ་པ་བརྗེ་བ།",
"Toggle Select Mode": "འདེམས་རྣམ་པ་བརྗེ་བ།",
"Toggle Bookmark": "ཤོག་རྟགས་བརྗེ་བ།",
"Toggle Text to Speech": "ཡི་གེ་སྒྲ་ལ་བརྗེ་བ།",
"Play / Pause TTS": "སྒྲ་ཀློག་འགོ་འཛུགས / མཚམས་འཇོག",
"Toggle Paragraph Mode": "དུམ་བུ་རྣམ་པ་བརྗེ་བ།",
"Highlight Selection": "བདམས་པ་གསལ་བཀོད།",
"Underline Selection": "བདམས་པའི་འོག་ཐིག",
"Annotate Selection": "བདམས་པར་མཆན་འགོད།",
"Search Selection": "བདམས་པ་འཚོལ་བ།",
"Copy Selection": "བདམས་པ་བཤུ་བ།",
"Translate Selection": "བདམས་པ་ཡིག་སྒྱུར།",
"Dictionary Lookup": "ཚིག་མཛོད་ནང་འཚོལ་བ།",
"Wikipedia Lookup": "ཝེ་ཁེ་པི་ཌི་ཡར་འཚོལ་བ།",
"Read Aloud Selection": "བདམས་པ་སྒྲ་ཆེན་པོས་ཀློག",
"Proofread Selection": "བདམས་པ་ཞུ་དག",
"Open Settings": "སྒྲིག་འགོད་ཁ་ཕྱེ་བ།",
"Open Command Palette": "བཀའ་བརྡའི་པང་ཁ་ཕྱེ་བ།",
"Show Keyboard Shortcuts": "མཐེབ་གཞོང་མྱུར་ལམ་སྟོན་པ།",
"Open Books": "དཔེ་དེབ་ཁ་ཕྱེ་བ།",
"Toggle Fullscreen": "ཡོངས་ཁྱབ་བརྗེ་བ།",
"Close Window": "སྒེའུ་ཁུང་ཁ་རྒྱག",
"Quit App": "ཉེར་སྤྱོད་ཁ་རྒྱག",
"Go Left / Previous Page": "གཡོན་དུ / སྔོན་མའི་ཤོག་ངོས།",
"Go Right / Next Page": "གཡས་སུ / རྗེས་མའི་ཤོག་ངོས།",
"Go Up": "གོང་དུ།",
"Go Down": "འོག་ཏུ།",
"Previous Chapter": "སྔོན་མའི་ལེའུ།",
"Next Chapter": "རྗེས་མའི་ལེའུ།",
"Scroll Half Page Down": "ཤོག་ངོས་ཕྱེད་ཀ་འོག་ཏུ།",
"Scroll Half Page Up": "ཤོག་ངོས་ཕྱེད་ཀ་གོང་དུ།",
"Save Note": "ཟིན་བྲིས་ཉར་བ།",
"Single Section Scroll": "དོན་ཚན་གཅིག་འགུལ་བ།",
"General": "སྤྱིར་བཏང་།",
"Text to Speech": "ཡི་གེ་ནས་སྒྲ།",
"Zoom": "ཆེ་རུ་གཏོང་།",
"Window": "སྒེའུ་ཁུང་།",
"Your Bookshelf": "ཁྱོད་ཀྱི་དཔེ་མཛོད།",
"TTS": "ཡི་གེ་ནས་སྒྲ",
"Media Info": "སྨྱན་བྱད་ཆ་འཕྲིན",
"Update Frequency": "གསར་སྒྱུར་ཐེངས་གྲངས",
"Every Sentence": "ཚིག་གྲུབ་རེ་རེ",
"Every Paragraph": "དུམ་མཚམས་རེ་རེ",
"Every Chapter": "ལེའུ་རེ་རེ",
"TTS Media Info Update Frequency": "TTS སྨྱན་བྱད་ཆ་འཕྲིན་གསར་སྒྱུར་ཐེངས་གྲངས",
"Select reading speed": "ཀློག་པའི་མྱུར་ཚད་འདེམས།",
"Drag to seek": "འཚོལ་བར་འདྲུད།",
"Punctuation Delay": "ཚེག་ཤད་ཕྱིར་འགྱངས།",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "མུ་མཐུད་མ་བྱས་གོང་ OPDS འབྲེལ་མཐུད་འདི་དྲ་རྒྱའི་ཉེར་སྤྱོད་ཐོག་ Readest ཞབས་ཞུ་བ་བརྒྱུད་ནས་བསྐུར་རྒྱུ་ཡིན་པ་གཏན་འཁེལ་བྱོས།",
"Custom Headers": "སྲོལ་སྒྲིག་མགོ་བརྗེ",
"Custom Headers (optional)": "སྲོལ་སྒྲིག་མགོ་བརྗེ (གདམ་གའི)",
"Add one header per line using \"Header-Name: value\".": "\"Header-Name: value\" བེད་སྤྱོད་བྱས་ནས་ཕྲེང་རེ་རེའི་ནང་མགོ་བརྗེ་གཅིག་སྣོན།",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "OPDS འབྲེལ་མཐུད་འདི་དྲ་རྒྱའི་ཉེར་སྤྱོད་ཐོག་ Readest ཞབས་ཞུ་བ་བརྒྱུད་ནས་བསྐུར་རྒྱུ་ཡིན་པ་ངས་ཧ་གོ། ང་ Readest ལ་ངེས་འཛིན་འམ་མགོ་བརྗེ་འདི་དག་ཡིད་ཆེས་མེད་ན། ང་ས་གནས་ཉེར་སྤྱོད་བེད་སྤྱོད་བྱ་དགོས།",
"Unable to connect to Hardcover. Please check your network connection.": "Hardcover ལ་འབྲེལ་མཐུད་བྱེད་ཐུབ་མ་སོང་། ཁྱེད་ཀྱི་དྲ་རྒྱའི་འབྲེལ་མཐུད་ལ་ཞིབ་བཤེར་གནང་།",
"Invalid Hardcover API token": "Hardcover API ཐོབ་ཐང་མ་ཆིག་སྒྲིལ་མིན།",
"Disconnected from Hardcover": "Hardcover ནས་འབྲེལ་མཐུད་ཆད་སོང་།",
"Hardcover Settings": "Hardcover སྒྲིག་འགོད",
"Connected to Hardcover": "Hardcover ལ་འབྲེལ་མཐུད་བྱས་ཟིན།",
"Connect your Hardcover account to sync reading progress and notes.": "ཀློག་འགྲོས་དང་མཆན་ཟླ་སྒྲིག་བྱེད་པར་ཁྱེད་ཀྱི Hardcover ཐོ་ཁུངས་འབྲེལ་མཐུད་བྱོས།",
"Get your API token from hardcover.app → Settings → API.": "hardcover.app → སྒྲིག་འགོད → API ནས་ཁྱེད་ཀྱི API ཐོབ་ཐང་ལེན།",
"API Token": "API ཐོབ་ཐང་།",
"Paste your Hardcover API token": "ཁྱེད་ཀྱི Hardcover API ཐོབ་ཐང་སྦྱར།",
"Hardcover sync enabled for this book": "དེབ་འདིའི་ཆེད་ Hardcover ཟླ་སྒྲིག་སྤྱོད་འགོ་ཚུགས།",
"Hardcover sync disabled for this book": "དེབ་འདིའི་ཆེད་ Hardcover ཟླ་སྒྲིག་མཚམས་བཞག",
"Hardcover Sync": "Hardcover ཟླ་སྒྲིག",
"Enable for This Book": "དེབ་འདིའི་ཆེད་སྤྱོད་འགོ་ཚུགས།",
"Push Notes": "མཆན་གཏོང་བ",
"Enable Hardcover sync for this book first.": "སྔོན་ལ་དེབ་འདིའི་ཆེད་ Hardcover ཟླ་སྒྲིག་སྤྱོད་འགོ་ཚུགས།",
"No annotations or excerpts to sync for this book.": "དེབ་འདིའི་ཆེད་ཟླ་སྒྲིག་བྱེད་པའི་མཆན་ཡང་ན་དྲངས་ཡིག་མེད།",
"Configure Hardcover in Settings first.": "སྔོན་ལ་སྒྲིག་འགོད་ནང་ Hardcover རིགས་སྒྲིག་བྱོས།",
"No new Hardcover note changes to sync.": "ཟླ་སྒྲིག་བྱེད་པའི་ Hardcover མཆན་གསར་པ་མེད།",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover ཟླ་སྒྲིག་བྱས་ཟིན: {{inserted}} གསར་པ, {{updated}} གསར་བཅོས, {{skipped}} མི་འགྱུར",
"Hardcover notes sync failed: {{error}}": "Hardcover མཆན་ཟླ་སྒྲིག་མི་ཚོགས: {{error}}",
"Reading progress synced to Hardcover": "ཀློག་འགྲོས་ Hardcover ལ་ཟླ་སྒྲིག་བྱས་ཟིན།",
"Hardcover progress sync failed: {{error}}": "Hardcover ཀློག་འགྲོས་ཟླ་སྒྲིག་མི་ཚོགས: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "ཡོད་བཞིན་པའི་འབྱུང་ཁུངས་ཐོབ་ཐང་གཞག",
"Toggle Toolbar": "ལག་ཆའི་ཚན་བར་བརྗེ་བ།",
"Split Hyphens": "སྦྲེལ་རྟགས་གཅད་པ",
"Apply Theme Colors to PDF": "PDF ལ་བརྗོད་དོན་ཚོས་གཞི་སྤྱོད་པ",
"Name": "མིང",
"Unavailable": "རག་མི་ཐུབ",
"Remove": "བསུབ།",
"Edit OPDS Catalog": "OPDS དཀར་ཆག་རྩོམ་སྒྲིག",
"Save Changes": "བསྒྱུར་བཅོས་ཉར་ཚགས།",
"Use Book Layout": "དཔེ་ཆའི་བཀོད་སྒྲིག་སྤྱོད་པ"
}
@@ -183,7 +183,6 @@
"RTL Direction": "RTL-Richtung",
"Maximum Column Height": "Maximale Spaltenhöhe",
"Maximum Column Width": "Maximale Spaltenbreite",
"Continuous Scroll": "Kontinuierliches Scrollen",
"Fullscreen": "Vollbild",
"No supported files found. Supported formats: {{formats}}": "Keine unterstützten Dateien gefunden. Unterstützte Formate: {{formats}}",
"Drop to Import Books": "Zum Importieren von Büchern ablegen",
@@ -983,7 +982,6 @@
"words": "Wörter",
"{{time}} left": "{{time}} übrig",
"Reading progress": "Lesefortschritt",
"Click to seek": "Klicken zum Suchen",
"Skip back 15 words": "15 Wörter zurück",
"Back 15 words (Shift+Left)": "15 Wörter zurück (Shift+Links)",
"Pause (Space)": "Pause (Leertaste)",
@@ -1102,5 +1100,96 @@
"{{count}} books refreshed_one": "{{count}} Buch aktualisiert",
"{{count}} books refreshed_other": "{{count}} Bücher aktualisiert",
"Failed to refresh metadata": "Metadaten-Aktualisierung fehlgeschlagen",
"Refresh Metadata": "Metadaten aktualisieren"
"Refresh Metadata": "Metadaten aktualisieren",
"Keyboard Shortcuts": "Tastaturkürzel",
"View all keyboard shortcuts": "Alle Tastaturkürzel anzeigen",
"Switch Sidebar Tab": "Seitenleisten-Tab wechseln",
"Toggle Notebook": "Notizbuch ein-/ausblenden",
"Search in Book": "Im Buch suchen",
"Toggle Scroll Mode": "Scrollmodus umschalten",
"Toggle Select Mode": "Auswahlmodus umschalten",
"Toggle Bookmark": "Lesezeichen umschalten",
"Toggle Text to Speech": "Sprachausgabe umschalten",
"Play / Pause TTS": "Sprachausgabe abspielen / pausieren",
"Toggle Paragraph Mode": "Absatzmodus umschalten",
"Highlight Selection": "Auswahl hervorheben",
"Underline Selection": "Auswahl unterstreichen",
"Annotate Selection": "Auswahl kommentieren",
"Search Selection": "Auswahl suchen",
"Copy Selection": "Auswahl kopieren",
"Translate Selection": "Auswahl übersetzen",
"Dictionary Lookup": "Im Wörterbuch nachschlagen",
"Wikipedia Lookup": "In Wikipedia nachschlagen",
"Read Aloud Selection": "Auswahl vorlesen",
"Proofread Selection": "Auswahl korrekturlesen",
"Open Settings": "Einstellungen öffnen",
"Open Command Palette": "Befehlspalette öffnen",
"Show Keyboard Shortcuts": "Tastaturkürzel anzeigen",
"Open Books": "Bücher öffnen",
"Toggle Fullscreen": "Vollbild umschalten",
"Close Window": "Fenster schließen",
"Quit App": "App beenden",
"Go Left / Previous Page": "Nach links / Vorherige Seite",
"Go Right / Next Page": "Nach rechts / Nächste Seite",
"Go Up": "Nach oben",
"Go Down": "Nach unten",
"Previous Chapter": "Vorheriges Kapitel",
"Next Chapter": "Nächstes Kapitel",
"Scroll Half Page Down": "Halbe Seite nach unten scrollen",
"Scroll Half Page Up": "Halbe Seite nach oben scrollen",
"Save Note": "Notiz speichern",
"Single Section Scroll": "Einzelabschnitt-Scrollen",
"General": "Allgemein",
"Text to Speech": "Sprachausgabe",
"Zoom": "Zoom",
"Window": "Fenster",
"Your Bookshelf": "Dein Bücherregal",
"TTS": "Sprachausgabe",
"Media Info": "Medieninfo",
"Update Frequency": "Aktualisierungshäufigkeit",
"Every Sentence": "Jeden Satz",
"Every Paragraph": "Jeden Absatz",
"Every Chapter": "Jedes Kapitel",
"TTS Media Info Update Frequency": "TTS-Medieninfo-Aktualisierungshäufigkeit",
"Select reading speed": "Lesegeschwindigkeit wählen",
"Drag to seek": "Ziehen zum Suchen",
"Punctuation Delay": "Satzzeichen-Verzögerung",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Bitte bestätigen Sie, dass diese OPDS-Verbindung über Readest-Server in der Web-App weitergeleitet wird, bevor Sie fortfahren.",
"Custom Headers": "Benutzerdefinierte Header",
"Custom Headers (optional)": "Benutzerdefinierte Header (optional)",
"Add one header per line using \"Header-Name: value\".": "Fügen Sie pro Zeile einen Header im Format \"Header-Name: value\" hinzu.",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Ich verstehe, dass diese OPDS-Verbindung über Readest-Server in der Web-App weitergeleitet wird. Wenn ich Readest meine Anmeldedaten oder Header nicht anvertraue, sollte ich stattdessen die native App verwenden.",
"Unable to connect to Hardcover. Please check your network connection.": "Verbindung zu Hardcover nicht möglich. Bitte überprüfen Sie Ihre Netzwerkverbindung.",
"Invalid Hardcover API token": "Ungültiges Hardcover-API-Token",
"Disconnected from Hardcover": "Von Hardcover getrennt",
"Hardcover Settings": "Hardcover-Einstellungen",
"Connected to Hardcover": "Mit Hardcover verbunden",
"Connect your Hardcover account to sync reading progress and notes.": "Verbinden Sie Ihr Hardcover-Konto, um Lesefortschritt und Notizen zu synchronisieren.",
"Get your API token from hardcover.app → Settings → API.": "Holen Sie Ihr API-Token von hardcover.app → Einstellungen → API.",
"API Token": "API-Token",
"Paste your Hardcover API token": "Fügen Sie Ihr Hardcover-API-Token ein",
"Hardcover sync enabled for this book": "Hardcover-Synchronisierung für dieses Buch aktiviert",
"Hardcover sync disabled for this book": "Hardcover-Synchronisierung für dieses Buch deaktiviert",
"Hardcover Sync": "Hardcover-Synchronisierung",
"Enable for This Book": "Für dieses Buch aktivieren",
"Push Notes": "Notizen übertragen",
"Enable Hardcover sync for this book first.": "Aktivieren Sie zuerst die Hardcover-Synchronisierung für dieses Buch.",
"No annotations or excerpts to sync for this book.": "Keine Anmerkungen oder Auszüge zum Synchronisieren für dieses Buch.",
"Configure Hardcover in Settings first.": "Konfigurieren Sie zuerst Hardcover in den Einstellungen.",
"No new Hardcover note changes to sync.": "Keine neuen Hardcover-Notizänderungen zum Synchronisieren.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover synchronisiert: {{inserted}} neu, {{updated}} aktualisiert, {{skipped}} unverändert",
"Hardcover notes sync failed: {{error}}": "Hardcover-Notizsynchronisierung fehlgeschlagen: {{error}}",
"Reading progress synced to Hardcover": "Lesefortschritt mit Hardcover synchronisiert",
"Hardcover progress sync failed: {{error}}": "Hardcover-Fortschrittssynchronisierung fehlgeschlagen: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Vorhandene Quellkennung beibehalten",
"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"
}
@@ -184,7 +184,6 @@
"RTL Direction": "Κατεύθυνση RTL",
"Maximum Column Height": "Μέγιστο ύψος στήλης",
"Maximum Column Width": "Μέγιστο πλάτος στήλης",
"Continuous Scroll": "Συνεχής κύλιση",
"Fullscreen": "Πλήρης οθόνη",
"No supported files found. Supported formats: {{formats}}": "Δεν βρέθηκαν υποστηριζόμενα αρχεία. Υποστηριζόμενες μορφές: {{formats}}",
"Drop to Import Books": "Ρίξτε για εισαγωγή βιβλίων",
@@ -983,7 +982,6 @@
"words": "λέξεις",
"{{time}} left": "{{time}} απομένουν",
"Reading progress": "Πρόοδος ανάγνωσης",
"Click to seek": "Κάντε κλικ για αναζήτηση",
"Skip back 15 words": "Επιστροφή 15 λέξεων",
"Back 15 words (Shift+Left)": "Πίσω 15 λέξεις (Shift+Αριστερά)",
"Pause (Space)": "Παύση (Διάστημα)",
@@ -1102,5 +1100,96 @@
"{{count}} books refreshed_one": "{{count}} βιβλίο ανανεώθηκε",
"{{count}} books refreshed_other": "{{count}} βιβλία ανανεώθηκαν",
"Failed to refresh metadata": "Αποτυχία ανανέωσης μεταδεδομένων",
"Refresh Metadata": "Ανανέωση μεταδεδομένων"
"Refresh Metadata": "Ανανέωση μεταδεδομένων",
"Keyboard Shortcuts": "Συντομεύσεις πληκτρολογίου",
"View all keyboard shortcuts": "Προβολή όλων των συντομεύσεων",
"Switch Sidebar Tab": "Εναλλαγή καρτέλας πλαϊνής γραμμής",
"Toggle Notebook": "Εμφάνιση/Απόκρυψη σημειωματάριου",
"Search in Book": "Αναζήτηση στο βιβλίο",
"Toggle Scroll Mode": "Εναλλαγή λειτουργίας κύλισης",
"Toggle Select Mode": "Εναλλαγή λειτουργίας επιλογής",
"Toggle Bookmark": "Εναλλαγή σελιδοδείκτη",
"Toggle Text to Speech": "Εναλλαγή κειμένου σε ομιλία",
"Play / Pause TTS": "Αναπαραγωγή / Παύση TTS",
"Toggle Paragraph Mode": "Εναλλαγή λειτουργίας παραγράφου",
"Highlight Selection": "Επισήμανση επιλογής",
"Underline Selection": "Υπογράμμιση επιλογής",
"Annotate Selection": "Σχολιασμός επιλογής",
"Search Selection": "Αναζήτηση επιλογής",
"Copy Selection": "Αντιγραφή επιλογής",
"Translate Selection": "Μετάφραση επιλογής",
"Dictionary Lookup": "Αναζήτηση στο λεξικό",
"Wikipedia Lookup": "Αναζήτηση στη Wikipedia",
"Read Aloud Selection": "Ανάγνωση επιλογής",
"Proofread Selection": "Διόρθωση επιλογής",
"Open Settings": "Άνοιγμα ρυθμίσεων",
"Open Command Palette": "Άνοιγμα παλέτας εντολών",
"Show Keyboard Shortcuts": "Εμφάνιση συντομεύσεων",
"Open Books": "Άνοιγμα βιβλίων",
"Toggle Fullscreen": "Εναλλαγή πλήρους οθόνης",
"Close Window": "Κλείσιμο παραθύρου",
"Quit App": "Έξοδος εφαρμογής",
"Go Left / Previous Page": "Αριστερά / Προηγούμενη σελίδα",
"Go Right / Next Page": "Δεξιά / Επόμενη σελίδα",
"Go Up": "Πάνω",
"Go Down": "Κάτω",
"Previous Chapter": "Προηγούμενο κεφάλαιο",
"Next Chapter": "Επόμενο κεφάλαιο",
"Scroll Half Page Down": "Κύλιση μισής σελίδας κάτω",
"Scroll Half Page Up": "Κύλιση μισής σελίδας πάνω",
"Save Note": "Αποθήκευση σημείωσης",
"Single Section Scroll": "Κύλιση μεμονωμένης ενότητας",
"General": "Γενικά",
"Text to Speech": "Κείμενο σε ομιλία",
"Zoom": "Ζουμ",
"Window": "Παράθυρο",
"Your Bookshelf": "Η βιβλιοθήκη σου",
"TTS": "Εκφώνηση",
"Media Info": "Πληροφορίες μέσων",
"Update Frequency": "Συχνότητα ενημέρωσης",
"Every Sentence": "Κάθε πρόταση",
"Every Paragraph": "Κάθε παράγραφο",
"Every Chapter": "Κάθε κεφάλαιο",
"TTS Media Info Update Frequency": "Συχνότητα ενημέρωσης πληροφοριών μέσων TTS",
"Select reading speed": "Επιλέξτε ταχύτητα ανάγνωσης",
"Drag to seek": "Σύρετε για αναζήτηση",
"Punctuation Delay": "Καθυστέρηση στίξης",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Επιβεβαιώστε ότι αυτή η σύνδεση OPDS θα δρομολογηθεί μέσω των διακομιστών Readest στην εφαρμογή ιστού πριν συνεχίσετε.",
"Custom Headers": "Προσαρμοσμένες κεφαλίδες",
"Custom Headers (optional)": "Προσαρμοσμένες κεφαλίδες (προαιρετικό)",
"Add one header per line using \"Header-Name: value\".": "Προσθέστε μία κεφαλίδα ανά γραμμή χρησιμοποιώντας \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Κατανοώ ότι αυτή η σύνδεση OPDS θα δρομολογηθεί μέσω των διακομιστών Readest στην εφαρμογή ιστού. Αν δεν εμπιστεύομαι τo Readest με αυτά τα διαπιστευτήρια ή τις κεφαλίδες, θα πρέπει να χρησιμοποιήσω την εγγενή εφαρμογή.",
"Unable to connect to Hardcover. Please check your network connection.": "Αδυναμία σύνδεσης στο Hardcover. Ελέγξτε τη σύνδεση δικτύου σας.",
"Invalid Hardcover API token": "Μη έγκυρο Hardcover API token",
"Disconnected from Hardcover": "Αποσυνδέθηκε από το Hardcover",
"Hardcover Settings": "Ρυθμίσεις Hardcover",
"Connected to Hardcover": "Συνδεδεμένο στο Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Συνδέστε τον λογαριασμό σας Hardcover για συγχρονισμό προόδου ανάγνωσης και σημειώσεων.",
"Get your API token from hardcover.app → Settings → API.": "Λάβετε το API token σας από hardcover.app → Ρυθμίσεις → API.",
"API Token": "API Token",
"Paste your Hardcover API token": "Επικολλήστε το Hardcover API token σας",
"Hardcover sync enabled for this book": "Ο συγχρονισμός Hardcover ενεργοποιήθηκε για αυτό το βιβλίο",
"Hardcover sync disabled for this book": "Ο συγχρονισμός Hardcover απενεργοποιήθηκε για αυτό το βιβλίο",
"Hardcover Sync": "Συγχρονισμός Hardcover",
"Enable for This Book": "Ενεργοποίηση για αυτό το βιβλίο",
"Push Notes": "Αποστολή σημειώσεων",
"Enable Hardcover sync for this book first.": "Ενεργοποιήστε πρώτα τον συγχρονισμό Hardcover για αυτό το βιβλίο.",
"No annotations or excerpts to sync for this book.": "Δεν υπάρχουν σχολιασμοί ή αποσπάσματα για συγχρονισμό σε αυτό το βιβλίο.",
"Configure Hardcover in Settings first.": "Ρυθμίστε πρώτα το Hardcover στις Ρυθμίσεις.",
"No new Hardcover note changes to sync.": "Δεν υπάρχουν νέες αλλαγές σημειώσεων Hardcover για συγχρονισμό.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover συγχρονίστηκε: {{inserted}} νέα, {{updated}} ενημερωμένα, {{skipped}} αμετάβλητα",
"Hardcover notes sync failed: {{error}}": "Αποτυχία συγχρονισμού σημειώσεων Hardcover: {{error}}",
"Reading progress synced to Hardcover": "Η πρόοδος ανάγνωσης συγχρονίστηκε στο Hardcover",
"Hardcover progress sync failed: {{error}}": "Αποτυχία συγχρονισμού προόδου Hardcover: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Διατήρηση υπάρχοντος αναγνωριστικού πηγής",
"Toggle Toolbar": "Εναλλαγή γραμμής εργαλείων",
"Split Hyphens": "Διαχωρισμός παύλων",
"Apply Theme Colors to PDF": "Εφαρμογή χρωμάτων θέματος σε PDF",
"Name": "Όνομα",
"Unavailable": "Μη διαθέσιμο",
"Remove": "Αφαίρεση",
"Edit OPDS Catalog": "Επεξεργασία καταλόγου OPDS",
"Save Changes": "Αποθήκευση αλλαγών",
"Use Book Layout": "Χρήση διάταξης βιβλίου"
}
@@ -211,7 +211,6 @@
"RTL Direction": "Dirección RTL",
"Maximum Column Height": "Altura máxima de columna",
"Maximum Column Width": "Ancho máximo de columna",
"Continuous Scroll": "Desplazamiento continuo",
"Fullscreen": "Pantalla completa",
"No supported files found. Supported formats: {{formats}}": "No se encontraron archivos compatibles. Formatos compatibles: {{formats}}",
"Drop to Import Books": "Soltar para importar libros",
@@ -995,7 +994,6 @@
"words": "palabras",
"{{time}} left": "faltan {{time}}",
"Reading progress": "Progreso de lectura",
"Click to seek": "Hacer clic para buscar",
"Skip back 15 words": "Retroceder 15 palabras",
"Back 15 words (Shift+Left)": "Retroceder 15 palabras (Shift+Izquierda)",
"Pause (Space)": "Pausa (Espacio)",
@@ -1115,5 +1113,96 @@
"{{count}} books refreshed_many": "{{count}} libros actualizados",
"{{count}} books refreshed_other": "{{count}} libros actualizados",
"Failed to refresh metadata": "Error al actualizar metadatos",
"Refresh Metadata": "Actualizar metadatos"
"Refresh Metadata": "Actualizar metadatos",
"Keyboard Shortcuts": "Atajos de teclado",
"View all keyboard shortcuts": "Ver todos los atajos de teclado",
"Switch Sidebar Tab": "Cambiar pestaña de la barra lateral",
"Toggle Notebook": "Mostrar/ocultar cuaderno",
"Search in Book": "Buscar en el libro",
"Toggle Scroll Mode": "Alternar modo desplazamiento",
"Toggle Select Mode": "Alternar modo selección",
"Toggle Bookmark": "Alternar marcador",
"Toggle Text to Speech": "Alternar texto a voz",
"Play / Pause TTS": "Reproducir / Pausar TTS",
"Toggle Paragraph Mode": "Alternar modo párrafo",
"Highlight Selection": "Resaltar selección",
"Underline Selection": "Subrayar selección",
"Annotate Selection": "Anotar selección",
"Search Selection": "Buscar selección",
"Copy Selection": "Copiar selección",
"Translate Selection": "Traducir selección",
"Dictionary Lookup": "Buscar en diccionario",
"Wikipedia Lookup": "Buscar en Wikipedia",
"Read Aloud Selection": "Leer selección en voz alta",
"Proofread Selection": "Corregir selección",
"Open Settings": "Abrir ajustes",
"Open Command Palette": "Abrir paleta de comandos",
"Show Keyboard Shortcuts": "Mostrar atajos de teclado",
"Open Books": "Abrir libros",
"Toggle Fullscreen": "Alternar pantalla completa",
"Close Window": "Cerrar ventana",
"Quit App": "Salir de la aplicación",
"Go Left / Previous Page": "Ir a la izquierda / Página anterior",
"Go Right / Next Page": "Ir a la derecha / Página siguiente",
"Go Up": "Ir arriba",
"Go Down": "Ir abajo",
"Previous Chapter": "Capítulo anterior",
"Next Chapter": "Capítulo siguiente",
"Scroll Half Page Down": "Desplazar media página abajo",
"Scroll Half Page Up": "Desplazar media página arriba",
"Save Note": "Guardar nota",
"Single Section Scroll": "Desplazamiento por sección",
"General": "General",
"Text to Speech": "Texto a voz",
"Zoom": "Zoom",
"Window": "Ventana",
"Your Bookshelf": "Tu estantería",
"TTS": "Lectura en voz alta",
"Media Info": "Info multimedia",
"Update Frequency": "Frecuencia de actualización",
"Every Sentence": "Cada oración",
"Every Paragraph": "Cada párrafo",
"Every Chapter": "Cada capítulo",
"TTS Media Info Update Frequency": "Frecuencia de actualización de info multimedia TTS",
"Select reading speed": "Seleccionar velocidad de lectura",
"Drag to seek": "Arrastra para buscar",
"Punctuation Delay": "Retraso de puntuación",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Confirme que esta conexión OPDS se enrutará a través de los servidores de Readest en la aplicación web antes de continuar.",
"Custom Headers": "Encabezados personalizados",
"Custom Headers (optional)": "Encabezados personalizados (opcional)",
"Add one header per line using \"Header-Name: value\".": "Añada un encabezado por línea usando \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Entiendo que esta conexión OPDS se enrutará a través de los servidores de Readest en la aplicación web. Si no confío en Readest con estas credenciales o encabezados, debería usar la aplicación nativa.",
"Unable to connect to Hardcover. Please check your network connection.": "No se puede conectar a Hardcover. Compruebe su conexión de red.",
"Invalid Hardcover API token": "Token de API de Hardcover no válido",
"Disconnected from Hardcover": "Desconectado de Hardcover",
"Hardcover Settings": "Ajustes de Hardcover",
"Connected to Hardcover": "Conectado a Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Conecte su cuenta de Hardcover para sincronizar el progreso de lectura y las notas.",
"Get your API token from hardcover.app → Settings → API.": "Obtenga su token de API en hardcover.app → Ajustes → API.",
"API Token": "Token de API",
"Paste your Hardcover API token": "Pegue su token de API de Hardcover",
"Hardcover sync enabled for this book": "Sincronización de Hardcover activada para este libro",
"Hardcover sync disabled for this book": "Sincronización de Hardcover desactivada para este libro",
"Hardcover Sync": "Sincronización de Hardcover",
"Enable for This Book": "Activar para este libro",
"Push Notes": "Enviar notas",
"Enable Hardcover sync for this book first.": "Active primero la sincronización de Hardcover para este libro.",
"No annotations or excerpts to sync for this book.": "No hay anotaciones ni extractos para sincronizar en este libro.",
"Configure Hardcover in Settings first.": "Configure Hardcover en los ajustes primero.",
"No new Hardcover note changes to sync.": "No hay nuevos cambios en notas de Hardcover para sincronizar.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover sincronizado: {{inserted}} nuevos, {{updated}} actualizados, {{skipped}} sin cambios",
"Hardcover notes sync failed: {{error}}": "Error al sincronizar notas de Hardcover: {{error}}",
"Reading progress synced to Hardcover": "Progreso de lectura sincronizado con Hardcover",
"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",
"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"
}
@@ -183,7 +183,6 @@
"RTL Direction": "جهت راست‌به‌چپ",
"Maximum Column Height": "حداکثر ارتفاع ستون",
"Maximum Column Width": "حداکثر عرض ستون",
"Continuous Scroll": "پیمایش مداوم",
"Fullscreen": "تمام‌صفحه",
"No supported files found. Supported formats: {{formats}}": "فایل پشتیبانی‌شده‌ای یافت نشد. فرمت‌های قابل پشتیبانی: {{formats}}",
"Drop to Import Books": "برای وارد کردن کتاب‌ها، فایل را رها کنید",
@@ -983,7 +982,6 @@
"words": "کلمات",
"{{time}} left": "{{time}} باقی‌مانده",
"Reading progress": "پیشرفت مطالعه",
"Click to seek": "برای جستجو کلیک کنید",
"Skip back 15 words": "۱۵ کلمه به عقب",
"Back 15 words (Shift+Left)": "۱۵ کلمه به عقب (Shift+Left)",
"Pause (Space)": "توقف (Space)",
@@ -1102,5 +1100,96 @@
"{{count}} books refreshed_one": "{{count}} کتاب به‌روزرسانی شد",
"{{count}} books refreshed_other": "{{count}} کتاب به‌روزرسانی شد",
"Failed to refresh metadata": "به‌روزرسانی فراداده ناموفق",
"Refresh Metadata": "به‌روزرسانی فراداده"
"Refresh Metadata": "به‌روزرسانی فراداده",
"Keyboard Shortcuts": "میانبرهای صفحه‌کلید",
"View all keyboard shortcuts": "مشاهده همه میانبرهای صفحه‌کلید",
"Switch Sidebar Tab": "تغییر زبانه نوار کناری",
"Toggle Notebook": "نمایش/پنهان کردن دفترچه یادداشت",
"Search in Book": "جستجو در کتاب",
"Toggle Scroll Mode": "تغییر حالت پیمایش",
"Toggle Select Mode": "تغییر حالت انتخاب",
"Toggle Bookmark": "تغییر نشانک",
"Toggle Text to Speech": "تغییر متن به گفتار",
"Play / Pause TTS": "پخش / توقف TTS",
"Toggle Paragraph Mode": "تغییر حالت پاراگراف",
"Highlight Selection": "برجسته‌سازی انتخاب",
"Underline Selection": "زیرخط انتخاب",
"Annotate Selection": "افزودن یادداشت به انتخاب",
"Search Selection": "جستجوی انتخاب",
"Copy Selection": "کپی انتخاب",
"Translate Selection": "ترجمه انتخاب",
"Dictionary Lookup": "جستجو در فرهنگ لغت",
"Wikipedia Lookup": "جستجو در ویکی‌پدیا",
"Read Aloud Selection": "خواندن بلند انتخاب",
"Proofread Selection": "بازبینی انتخاب",
"Open Settings": "باز کردن تنظیمات",
"Open Command Palette": "باز کردن پالت فرمان",
"Show Keyboard Shortcuts": "نمایش میانبرهای صفحه‌کلید",
"Open Books": "باز کردن کتاب‌ها",
"Toggle Fullscreen": "تغییر تمام‌صفحه",
"Close Window": "بستن پنجره",
"Quit App": "خروج از برنامه",
"Go Left / Previous Page": "رفتن به چپ / صفحه قبلی",
"Go Right / Next Page": "رفتن به راست / صفحه بعدی",
"Go Up": "رفتن به بالا",
"Go Down": "رفتن به پایین",
"Previous Chapter": "فصل قبلی",
"Next Chapter": "فصل بعدی",
"Scroll Half Page Down": "پیمایش نیم صفحه پایین",
"Scroll Half Page Up": "پیمایش نیم صفحه بالا",
"Save Note": "ذخیره یادداشت",
"Single Section Scroll": "پیمایش تک‌بخشی",
"General": "عمومی",
"Text to Speech": "تبدیل متن به گفتار",
"Zoom": "بزرگ‌نمایی",
"Window": "پنجره",
"Your Bookshelf": "قفسه کتاب شما",
"TTS": "تبدیل متن به گفتار",
"Media Info": "اطلاعات رسانه",
"Update Frequency": "تناوب به‌روزرسانی",
"Every Sentence": "هر جمله",
"Every Paragraph": "هر پاراگراف",
"Every Chapter": "هر فصل",
"TTS Media Info Update Frequency": "تناوب به‌روزرسانی اطلاعات رسانه TTS",
"Select reading speed": "انتخاب سرعت خواندن",
"Drag to seek": "بکشید برای جستجو",
"Punctuation Delay": "تأخیر نشانه‌گذاری",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "لطفاً تأیید کنید که این اتصال OPDS از طریق سرورهای Readest در برنامه وب پروکسی خواهد شد.",
"Custom Headers": "سرآیندهای سفارشی",
"Custom Headers (optional)": "سرآیندهای سفارشی (اختیاری)",
"Add one header per line using \"Header-Name: value\".": "در هر خط یک سرآیند با استفاده از \"Header-Name: value\" اضافه کنید.",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "متوجه هستم که این اتصال OPDS از طریق سرورهای Readest در برنامه وب پروکسی خواهد شد. اگر به Readest برای این اعتبارنامه‌ها یا سرآیندها اعتماد ندارم، باید از برنامه بومی استفاده کنم.",
"Unable to connect to Hardcover. Please check your network connection.": "اتصال به Hardcover ممکن نیست. لطفاً اتصال شبکه خود را بررسی کنید.",
"Invalid Hardcover API token": "توکن API نامعتبر Hardcover",
"Disconnected from Hardcover": "قطع اتصال از Hardcover",
"Hardcover Settings": "تنظیمات Hardcover",
"Connected to Hardcover": "متصل به Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "حساب Hardcover خود را برای همگام‌سازی پیشرفت مطالعه و یادداشت‌ها متصل کنید.",
"Get your API token from hardcover.app → Settings → API.": "توکن API خود را از hardcover.app ← تنظیمات ← API دریافت کنید.",
"API Token": "توکن API",
"Paste your Hardcover API token": "توکن API Hardcover خود را جای‌گذاری کنید",
"Hardcover sync enabled for this book": "همگام‌سازی Hardcover برای این کتاب فعال شد",
"Hardcover sync disabled for this book": "همگام‌سازی Hardcover برای این کتاب غیرفعال شد",
"Hardcover Sync": "همگام‌سازی Hardcover",
"Enable for This Book": "فعال‌سازی برای این کتاب",
"Push Notes": "ارسال یادداشت‌ها",
"Enable Hardcover sync for this book first.": "ابتدا همگام‌سازی Hardcover را برای این کتاب فعال کنید.",
"No annotations or excerpts to sync for this book.": "هیچ حاشیه‌نویسی یا گزیده‌ای برای همگام‌سازی این کتاب وجود ندارد.",
"Configure Hardcover in Settings first.": "ابتدا Hardcover را در تنظیمات پیکربندی کنید.",
"No new Hardcover note changes to sync.": "تغییرات جدیدی در یادداشت‌های Hardcover برای همگام‌سازی وجود ندارد.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover همگام‌سازی شد: {{inserted}} جدید، {{updated}} به‌روزرسانی، {{skipped}} بدون تغییر",
"Hardcover notes sync failed: {{error}}": "همگام‌سازی یادداشت‌های Hardcover ناموفق: {{error}}",
"Reading progress synced to Hardcover": "پیشرفت مطالعه با Hardcover همگام‌سازی شد",
"Hardcover progress sync failed: {{error}}": "همگام‌سازی پیشرفت Hardcover ناموفق: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "حفظ شناسه منبع موجود",
"Toggle Toolbar": "تغییر نوار ابزار",
"Split Hyphens": "تقسیم خط‌تیره‌ها",
"Apply Theme Colors to PDF": "اعمال رنگ‌های پوسته روی PDF",
"Name": "نام",
"Unavailable": "ناموجود",
"Remove": "حذف",
"Edit OPDS Catalog": "ویرایش کاتالوگ OPDS",
"Save Changes": "ذخیره تغییرات",
"Use Book Layout": "استفاده از چیدمان کتاب"
}
@@ -183,7 +183,6 @@
"RTL Direction": "Direction RTL",
"Maximum Column Height": "Hauteur maximale de colonne",
"Maximum Column Width": "Largeur maximale de colonne",
"Continuous Scroll": "Défilement continu",
"Fullscreen": "Plein écran",
"No supported files found. Supported formats: {{formats}}": "Aucun fichier pris en charge trouvé. Formats pris en charge : {{formats}}",
"Drop to Import Books": "Déposez pour importer des livres",
@@ -995,7 +994,6 @@
"words": "mots",
"{{time}} left": "{{time}} restant",
"Reading progress": "Progression de la lecture",
"Click to seek": "Cliquer pour chercher",
"Skip back 15 words": "Reculer de 15 mots",
"Back 15 words (Shift+Left)": "Reculer de 15 mots (Shift+Left)",
"Pause (Space)": "Pause (Espace)",
@@ -1115,5 +1113,96 @@
"{{count}} books refreshed_many": "{{count}} livres rafraîchis",
"{{count}} books refreshed_other": "{{count}} livres rafraîchis",
"Failed to refresh metadata": "Échec du rafraîchissement des métadonnées",
"Refresh Metadata": "Rafraîchir les métadonnées"
"Refresh Metadata": "Rafraîchir les métadonnées",
"Keyboard Shortcuts": "Raccourcis clavier",
"View all keyboard shortcuts": "Voir tous les raccourcis clavier",
"Switch Sidebar Tab": "Changer l'onglet de la barre latérale",
"Toggle Notebook": "Afficher/masquer le carnet",
"Search in Book": "Rechercher dans le livre",
"Toggle Scroll Mode": "Basculer le mode défilement",
"Toggle Select Mode": "Basculer le mode sélection",
"Toggle Bookmark": "Basculer le signet",
"Toggle Text to Speech": "Basculer la synthèse vocale",
"Play / Pause TTS": "Lecture / Pause TTS",
"Toggle Paragraph Mode": "Basculer le mode paragraphe",
"Highlight Selection": "Surligner la sélection",
"Underline Selection": "Souligner la sélection",
"Annotate Selection": "Annoter la sélection",
"Search Selection": "Rechercher la sélection",
"Copy Selection": "Copier la sélection",
"Translate Selection": "Traduire la sélection",
"Dictionary Lookup": "Recherche dans le dictionnaire",
"Wikipedia Lookup": "Recherche sur Wikipédia",
"Read Aloud Selection": "Lire la sélection à voix haute",
"Proofread Selection": "Relire la sélection",
"Open Settings": "Ouvrir les paramètres",
"Open Command Palette": "Ouvrir la palette de commandes",
"Show Keyboard Shortcuts": "Afficher les raccourcis clavier",
"Open Books": "Ouvrir les livres",
"Toggle Fullscreen": "Basculer en plein écran",
"Close Window": "Fermer la fenêtre",
"Quit App": "Quitter l'application",
"Go Left / Previous Page": "Aller à gauche / Page précédente",
"Go Right / Next Page": "Aller à droite / Page suivante",
"Go Up": "Aller en haut",
"Go Down": "Aller en bas",
"Previous Chapter": "Chapitre précédent",
"Next Chapter": "Chapitre suivant",
"Scroll Half Page Down": "Défiler d'une demi-page vers le bas",
"Scroll Half Page Up": "Défiler d'une demi-page vers le haut",
"Save Note": "Enregistrer la note",
"Single Section Scroll": "Défilement par section",
"General": "Général",
"Text to Speech": "Synthèse vocale",
"Zoom": "Zoom",
"Window": "Fenêtre",
"Your Bookshelf": "Votre bibliothèque",
"TTS": "Synthèse vocale",
"Media Info": "Infos média",
"Update Frequency": "Fréquence de mise à jour",
"Every Sentence": "Chaque phrase",
"Every Paragraph": "Chaque paragraphe",
"Every Chapter": "Chaque chapitre",
"TTS Media Info Update Frequency": "Fréquence de mise à jour des infos média TTS",
"Select reading speed": "Sélectionner la vitesse de lecture",
"Drag to seek": "Glisser pour chercher",
"Punctuation Delay": "Délai de ponctuation",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Veuillez confirmer que cette connexion OPDS sera acheminée via les serveurs Readest sur l'application web avant de continuer.",
"Custom Headers": "En-têtes personnalisés",
"Custom Headers (optional)": "En-têtes personnalisés (facultatif)",
"Add one header per line using \"Header-Name: value\".": "Ajoutez un en-tête par ligne en utilisant \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Je comprends que cette connexion OPDS sera acheminée via les serveurs Readest sur l'application web. Si je ne fais pas confiance à Readest pour ces identifiants ou en-têtes, je devrais utiliser l'application native.",
"Unable to connect to Hardcover. Please check your network connection.": "Impossible de se connecter à Hardcover. Veuillez vérifier votre connexion réseau.",
"Invalid Hardcover API token": "Jeton API Hardcover invalide",
"Disconnected from Hardcover": "Déconnecté de Hardcover",
"Hardcover Settings": "Paramètres Hardcover",
"Connected to Hardcover": "Connecté à Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Connectez votre compte Hardcover pour synchroniser la progression de lecture et les notes.",
"Get your API token from hardcover.app → Settings → API.": "Obtenez votre jeton API depuis hardcover.app → Paramètres → API.",
"API Token": "Jeton API",
"Paste your Hardcover API token": "Collez votre jeton API Hardcover",
"Hardcover sync enabled for this book": "Synchronisation Hardcover activée pour ce livre",
"Hardcover sync disabled for this book": "Synchronisation Hardcover désactivée pour ce livre",
"Hardcover Sync": "Synchronisation Hardcover",
"Enable for This Book": "Activer pour ce livre",
"Push Notes": "Envoyer les notes",
"Enable Hardcover sync for this book first.": "Activez d'abord la synchronisation Hardcover pour ce livre.",
"No annotations or excerpts to sync for this book.": "Aucune annotation ni extrait à synchroniser pour ce livre.",
"Configure Hardcover in Settings first.": "Configurez d'abord Hardcover dans les paramètres.",
"No new Hardcover note changes to sync.": "Aucun nouveau changement de notes Hardcover à synchroniser.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover synchronisé : {{inserted}} nouveaux, {{updated}} mis à jour, {{skipped}} inchangés",
"Hardcover notes sync failed: {{error}}": "Échec de la synchronisation des notes Hardcover : {{error}}",
"Reading progress synced to Hardcover": "Progression de lecture synchronisée avec Hardcover",
"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",
"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"
}
@@ -493,7 +493,6 @@
"words": "מילים",
"{{time}} left": "נותרו {{time}}",
"Reading progress": "התקדמות קריאה",
"Click to seek": "לחץ לחיפוש",
"Skip back 15 words": "קפוץ 15 מילים אחורה",
"Back 15 words (Shift+Left)": "15 מילים אחורה (Shift+Left)",
"Pause": "השהה",
@@ -853,7 +852,6 @@
"Override Book Color": "דרוס צבע ספר",
"None": "ללא",
"Scroll": "גלילה",
"Continuous Scroll": "גלילה רציפה",
"Overlap Pixels": "פיקסלים לחפיפה",
"Pagination": "עימוד (Pagination)",
"Tap to Paginate": "הקש לעימוד",
@@ -1115,5 +1113,96 @@
"{{count}} books refreshed_two": "{{count}} ספרים רועננו",
"{{count}} books refreshed_other": "{{count}} ספרים רועננו",
"Failed to refresh metadata": "רענון המטא-נתונים נכשל",
"Refresh Metadata": "רענון מטא-נתונים"
"Refresh Metadata": "רענון מטא-נתונים",
"Keyboard Shortcuts": "קיצורי מקלדת",
"View all keyboard shortcuts": "הצג את כל קיצורי המקלדת",
"Switch Sidebar Tab": "החלף לשונית סרגל צד",
"Toggle Notebook": "הצג/הסתר מחברת",
"Search in Book": "חפש בספר",
"Toggle Scroll Mode": "החלף מצב גלילה",
"Toggle Select Mode": "החלף מצב בחירה",
"Toggle Bookmark": "החלף סימנייה",
"Toggle Text to Speech": "החלף טקסט לדיבור",
"Play / Pause TTS": "נגן / השהה TTS",
"Toggle Paragraph Mode": "החלף מצב פסקה",
"Highlight Selection": "הדגש בחירה",
"Underline Selection": "קו תחתון לבחירה",
"Annotate Selection": "הוסף הערה לבחירה",
"Search Selection": "חפש בחירה",
"Copy Selection": "העתק בחירה",
"Translate Selection": "תרגם בחירה",
"Dictionary Lookup": "חיפוש במילון",
"Wikipedia Lookup": "חיפוש בוויקיפדיה",
"Read Aloud Selection": "קרא בקול את הבחירה",
"Proofread Selection": "הגה בחירה",
"Open Settings": "פתח הגדרות",
"Open Command Palette": "פתח לוח פקודות",
"Show Keyboard Shortcuts": "הצג קיצורי מקלדת",
"Open Books": "פתח ספרים",
"Toggle Fullscreen": "החלף מסך מלא",
"Close Window": "סגור חלון",
"Quit App": "צא מהאפליקציה",
"Go Left / Previous Page": "שמאלה / עמוד קודם",
"Go Right / Next Page": "ימינה / עמוד הבא",
"Go Up": "למעלה",
"Go Down": "למטה",
"Previous Chapter": "פרק קודם",
"Next Chapter": "פרק הבא",
"Scroll Half Page Down": "גלול חצי עמוד למטה",
"Scroll Half Page Up": "גלול חצי עמוד למעלה",
"Save Note": "שמור הערה",
"Single Section Scroll": "גלילת קטע בודד",
"General": "כללי",
"Text to Speech": "טקסט לדיבור",
"Zoom": "זום",
"Window": "חלון",
"Your Bookshelf": "מדף הספרים שלך",
"TTS": "הקראה",
"Media Info": "פרטי מדיה",
"Update Frequency": "תדירות עדכון",
"Every Sentence": "כל משפט",
"Every Paragraph": "כל פסקה",
"Every Chapter": "כל פרק",
"TTS Media Info Update Frequency": "תדירות עדכון פרטי מדיה TTS",
"Select reading speed": "בחר מהירות קריאה",
"Drag to seek": "גרור לחיפוש",
"Punctuation Delay": "השהיית פיסוק",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "אנא אשר שחיבור OPDS זה יועבר דרך שרתי Readest באפליקציית האינטרנט לפני שתמשיך.",
"Custom Headers": "כותרות מותאמות אישית",
"Custom Headers (optional)": "כותרות מותאמות אישית (אופציונלי)",
"Add one header per line using \"Header-Name: value\".": "הוסף כותרת אחת לכל שורה בפורמט \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "אני מבין שחיבור OPDS זה יועבר דרך שרתי Readest באפליקציית האינטרנט. אם אינני סומך על Readest עם פרטי ההתחברות או הכותרות האלה, עליי להשתמש באפליקציה המקורית.",
"Unable to connect to Hardcover. Please check your network connection.": "לא ניתן להתחבר ל-Hardcover. אנא בדוק את חיבור הרשת שלך.",
"Invalid Hardcover API token": "טוקן API של Hardcover לא חוקי",
"Disconnected from Hardcover": "מנותק מ-Hardcover",
"Hardcover Settings": "הגדרות Hardcover",
"Connected to Hardcover": "מחובר ל-Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "חבר את חשבון ה-Hardcover שלך לסנכרון התקדמות קריאה והערות.",
"Get your API token from hardcover.app → Settings → API.": "קבל את טוקן ה-API שלך מ-hardcover.app ← הגדרות ← API.",
"API Token": "טוקן API",
"Paste your Hardcover API token": "הדבק את טוקן ה-API של Hardcover שלך",
"Hardcover sync enabled for this book": "סנכרון Hardcover הופעל לספר זה",
"Hardcover sync disabled for this book": "סנכרון Hardcover הושבת לספר זה",
"Hardcover Sync": "סנכרון Hardcover",
"Enable for This Book": "הפעל לספר זה",
"Push Notes": "שלח הערות",
"Enable Hardcover sync for this book first.": "הפעל תחילה סנכרון Hardcover לספר זה.",
"No annotations or excerpts to sync for this book.": "אין הערות או ציטוטים לסנכרון עבור ספר זה.",
"Configure Hardcover in Settings first.": "הגדר תחילה את Hardcover בהגדרות.",
"No new Hardcover note changes to sync.": "אין שינויים חדשים בהערות Hardcover לסנכרון.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover סונכרן: {{inserted}} חדשים, {{updated}} עודכנו, {{skipped}} ללא שינוי",
"Hardcover notes sync failed: {{error}}": "סנכרון הערות Hardcover נכשל: {{error}}",
"Reading progress synced to Hardcover": "התקדמות הקריאה סונכרנה ל-Hardcover",
"Hardcover progress sync failed: {{error}}": "סנכרון התקדמות Hardcover נכשל: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "שמור על מזהה המקור הקיים",
"Toggle Toolbar": "הצג/הסתר סרגל כלים",
"Split Hyphens": "פיצול מקפים",
"Apply Theme Colors to PDF": "החל צבעי ערכת נושא על PDF",
"Name": "שם",
"Unavailable": "לא זמין",
"Remove": "הסר",
"Edit OPDS Catalog": "עריכת קטלוג OPDS",
"Save Changes": "שמור שינויים",
"Use Book Layout": "השתמש בפריסת הספר"
}
@@ -184,7 +184,6 @@
"RTL Direction": "RTL दिशा",
"Maximum Column Height": "कॉलम की अधिकतम ऊँचाई",
"Maximum Column Width": "कॉलम की अधिकतम चौड़ाई",
"Continuous Scroll": "निरंतर स्क्रॉल",
"Fullscreen": "पूर्णस्क्रीन",
"No supported files found. Supported formats: {{formats}}": "कोई समर्थित फ़ाइलें नहीं मिलीं। समर्थित प्रारूप: {{formats}}",
"Drop to Import Books": "पुस्तकें आयात करने के लिए छोड़ें",
@@ -983,7 +982,6 @@
"words": "शब्द",
"{{time}} left": "{{time}} शेष",
"Reading progress": "पठन प्रगति",
"Click to seek": "सीक करने के लिए क्लिक करें",
"Skip back 15 words": "15 शब्द पीछे",
"Back 15 words (Shift+Left)": "15 शब्द पीछे (Shift+Left)",
"Pause (Space)": "विराम (Space)",
@@ -1102,5 +1100,96 @@
"{{count}} books refreshed_one": "{{count}} पुस्तक रिफ्रेश हुई",
"{{count}} books refreshed_other": "{{count}} पुस्तकें रिफ्रेश हुईं",
"Failed to refresh metadata": "मेटाडेटा रिफ्रेश विफल",
"Refresh Metadata": "मेटाडेटा रिफ्रेश करें"
"Refresh Metadata": "मेटाडेटा रिफ्रेश करें",
"Keyboard Shortcuts": "कीबोर्ड शॉर्टकट",
"View all keyboard shortcuts": "सभी कीबोर्ड शॉर्टकट देखें",
"Switch Sidebar Tab": "साइडबार टैब बदलें",
"Toggle Notebook": "नोटबुक दिखाएँ/छुपाएँ",
"Search in Book": "पुस्तक में खोजें",
"Toggle Scroll Mode": "स्क्रॉल मोड टॉगल करें",
"Toggle Select Mode": "चयन मोड टॉगल करें",
"Toggle Bookmark": "बुकमार्क टॉगल करें",
"Toggle Text to Speech": "टेक्स्ट टू स्पीच टॉगल करें",
"Play / Pause TTS": "TTS चलाएँ / रोकें",
"Toggle Paragraph Mode": "अनुच्छेद मोड टॉगल करें",
"Highlight Selection": "चयन हाइलाइट करें",
"Underline Selection": "चयन अंडरलाइन करें",
"Annotate Selection": "चयन पर टिप्पणी करें",
"Search Selection": "चयन खोजें",
"Copy Selection": "चयन कॉपी करें",
"Translate Selection": "चयन का अनुवाद करें",
"Dictionary Lookup": "शब्दकोश में खोजें",
"Wikipedia Lookup": "विकिपीडिया पर खोजें",
"Read Aloud Selection": "चयन को ज़ोर से पढ़ें",
"Proofread Selection": "चयन की प्रूफरीडिंग करें",
"Open Settings": "सेटिंग्स खोलें",
"Open Command Palette": "कमांड पैलेट खोलें",
"Show Keyboard Shortcuts": "कीबोर्ड शॉर्टकट दिखाएँ",
"Open Books": "पुस्तकें खोलें",
"Toggle Fullscreen": "पूर्ण स्क्रीन टॉगल करें",
"Close Window": "विंडो बंद करें",
"Quit App": "ऐप से बाहर निकलें",
"Go Left / Previous Page": "बाएँ जाएँ / पिछला पृष्ठ",
"Go Right / Next Page": "दाएँ जाएँ / अगला पृष्ठ",
"Go Up": "ऊपर जाएँ",
"Go Down": "नीचे जाएँ",
"Previous Chapter": "पिछला अध्याय",
"Next Chapter": "अगला अध्याय",
"Scroll Half Page Down": "आधा पृष्ठ नीचे स्क्रॉल करें",
"Scroll Half Page Up": "आधा पृष्ठ ऊपर स्क्रॉल करें",
"Save Note": "नोट सहेजें",
"Single Section Scroll": "एकल अनुभाग स्क्रॉल",
"General": "सामान्य",
"Text to Speech": "पाठ से वाक्",
"Zoom": "ज़ूम",
"Window": "विंडो",
"Your Bookshelf": "आपकी किताबों की अलमारी",
"TTS": "पाठ से वाक्",
"Media Info": "मीडिया जानकारी",
"Update Frequency": "अपडेट आवृत्ति",
"Every Sentence": "हर वाक्य",
"Every Paragraph": "हर पैराग्राफ",
"Every Chapter": "हर अध्याय",
"TTS Media Info Update Frequency": "TTS मीडिया जानकारी अपडेट आवृत्ति",
"Select reading speed": "पढ़ने की गति चुनें",
"Drag to seek": "खोजने के लिए खींचें",
"Punctuation Delay": "विराम चिह्न विलंब",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "कृपया जारी रखने से पहले पुष्टि करें कि यह OPDS कनेक्शन वेब ऐप पर Readest सर्वर के माध्यम से प्रॉक्सी किया जाएगा।",
"Custom Headers": "कस्टम हेडर",
"Custom Headers (optional)": "कस्टम हेडर (वैकल्पिक)",
"Add one header per line using \"Header-Name: value\".": "\"Header-Name: value\" का उपयोग करके प्रति पंक्ति एक हेडर जोड़ें।",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "मैं समझता/समझती हूँ कि यह OPDS कनेक्शन वेब ऐप पर Readest सर्वर के माध्यम से प्रॉक्सी किया जाएगा। यदि मुझे इन क्रेडेंशियल या हेडर के साथ Readest पर भरोसा नहीं है, तो मुझे नेटिव ऐप का उपयोग करना चाहिए।",
"Unable to connect to Hardcover. Please check your network connection.": "Hardcover से कनेक्ट करने में असमर्थ। कृपया अपना नेटवर्क कनेक्शन जाँचें।",
"Invalid Hardcover API token": "अमान्य Hardcover API टोकन",
"Disconnected from Hardcover": "Hardcover से डिस्कनेक्ट हो गया",
"Hardcover Settings": "Hardcover सेटिंग्स",
"Connected to Hardcover": "Hardcover से कनेक्टेड",
"Connect your Hardcover account to sync reading progress and notes.": "पढ़ने की प्रगति और नोट्स सिंक करने के लिए अपना Hardcover खाता कनेक्ट करें।",
"Get your API token from hardcover.app → Settings → API.": "hardcover.app → सेटिंग्स → API से अपना API टोकन प्राप्त करें।",
"API Token": "API टोकन",
"Paste your Hardcover API token": "अपना Hardcover API टोकन पेस्ट करें",
"Hardcover sync enabled for this book": "इस पुस्तक के लिए Hardcover सिंक सक्षम",
"Hardcover sync disabled for this book": "इस पुस्तक के लिए Hardcover सिंक अक्षम",
"Hardcover Sync": "Hardcover सिंक",
"Enable for This Book": "इस पुस्तक के लिए सक्षम करें",
"Push Notes": "नोट्स भेजें",
"Enable Hardcover sync for this book first.": "पहले इस पुस्तक के लिए Hardcover सिंक सक्षम करें।",
"No annotations or excerpts to sync for this book.": "इस पुस्तक के लिए सिंक करने हेतु कोई एनोटेशन या अंश नहीं हैं।",
"Configure Hardcover in Settings first.": "पहले सेटिंग्स में Hardcover कॉन्फ़िगर करें।",
"No new Hardcover note changes to sync.": "सिंक करने के लिए कोई नया Hardcover नोट परिवर्तन नहीं है।",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover सिंक हुआ: {{inserted}} नए, {{updated}} अपडेटेड, {{skipped}} अपरिवर्तित",
"Hardcover notes sync failed: {{error}}": "Hardcover नोट्स सिंक विफल: {{error}}",
"Reading progress synced to Hardcover": "पढ़ने की प्रगति Hardcover से सिंक हो गई",
"Hardcover progress sync failed: {{error}}": "Hardcover प्रगति सिंक विफल: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "मौजूदा स्रोत पहचानकर्ता रखें",
"Toggle Toolbar": "टूलबार टॉगल करें",
"Split Hyphens": "हाइफ़न विभाजित करें",
"Apply Theme Colors to PDF": "PDF पर थीम रंग लागू करें",
"Name": "नाम",
"Unavailable": "अनुपलब्ध",
"Remove": "हटाएँ",
"Edit OPDS Catalog": "OPDS कैटलॉग संपादित करें",
"Save Changes": "परिवर्तन सहेजें",
"Use Book Layout": "पुस्तक का लेआउट उपयोग करें"
}
File diff suppressed because it is too large Load Diff
@@ -184,7 +184,6 @@
"RTL Direction": "Arah RTL",
"Maximum Column Height": "Tinggi Kolom Maksimum",
"Maximum Column Width": "Lebar Kolom Maksimum",
"Continuous Scroll": "Gulir Terus",
"Fullscreen": "Layar Penuh",
"No supported files found. Supported formats: {{formats}}": "Tidak ada file yang didukung ditemukan. Format yang didukung: {{formats}}",
"Drop to Import Books": "Jatuhkan untuk Mengimpor Buku",
@@ -971,7 +970,6 @@
"words": "kata",
"{{time}} left": "{{time}} tersisa",
"Reading progress": "Kemajuan membaca",
"Click to seek": "Klik untuk mencari",
"Skip back 15 words": "Lompat mundur 15 kata",
"Back 15 words (Shift+Left)": "Mundur 15 kata (Shift+Kiri)",
"Pause (Space)": "Jeda (Spasi)",
@@ -1089,5 +1087,96 @@
"Loading library...": "Memuat perpustakaan...",
"{{count}} books refreshed_other": "{{count}} buku diperbarui",
"Failed to refresh metadata": "Gagal memperbarui metadata",
"Refresh Metadata": "Perbarui Metadata"
"Refresh Metadata": "Perbarui Metadata",
"Keyboard Shortcuts": "Pintasan Keyboard",
"View all keyboard shortcuts": "Lihat semua pintasan keyboard",
"Switch Sidebar Tab": "Ganti tab bilah sisi",
"Toggle Notebook": "Tampilkan/sembunyikan buku catatan",
"Search in Book": "Cari di buku",
"Toggle Scroll Mode": "Alihkan mode gulir",
"Toggle Select Mode": "Alihkan mode pilih",
"Toggle Bookmark": "Alihkan penanda",
"Toggle Text to Speech": "Alihkan teks ke suara",
"Play / Pause TTS": "Putar / Jeda TTS",
"Toggle Paragraph Mode": "Alihkan mode paragraf",
"Highlight Selection": "Sorot pilihan",
"Underline Selection": "Garis bawahi pilihan",
"Annotate Selection": "Beri anotasi pilihan",
"Search Selection": "Cari pilihan",
"Copy Selection": "Salin pilihan",
"Translate Selection": "Terjemahkan pilihan",
"Dictionary Lookup": "Cari di kamus",
"Wikipedia Lookup": "Cari di Wikipedia",
"Read Aloud Selection": "Bacakan pilihan",
"Proofread Selection": "Periksa ejaan pilihan",
"Open Settings": "Buka pengaturan",
"Open Command Palette": "Buka palet perintah",
"Show Keyboard Shortcuts": "Tampilkan pintasan keyboard",
"Open Books": "Buka buku",
"Toggle Fullscreen": "Alihkan layar penuh",
"Close Window": "Tutup jendela",
"Quit App": "Keluar dari aplikasi",
"Go Left / Previous Page": "Ke kiri / Halaman sebelumnya",
"Go Right / Next Page": "Ke kanan / Halaman berikutnya",
"Go Up": "Ke atas",
"Go Down": "Ke bawah",
"Previous Chapter": "Bab sebelumnya",
"Next Chapter": "Bab berikutnya",
"Scroll Half Page Down": "Gulir setengah halaman ke bawah",
"Scroll Half Page Up": "Gulir setengah halaman ke atas",
"Save Note": "Simpan catatan",
"Single Section Scroll": "Gulir Satu Bagian",
"General": "Umum",
"Text to Speech": "Teks ke Suara",
"Zoom": "Zoom",
"Window": "Jendela",
"Your Bookshelf": "Rak Buku Anda",
"TTS": "Teks ke Suara",
"Media Info": "Info Media",
"Update Frequency": "Frekuensi Pembaruan",
"Every Sentence": "Setiap Kalimat",
"Every Paragraph": "Setiap Paragraf",
"Every Chapter": "Setiap Bab",
"TTS Media Info Update Frequency": "Frekuensi Pembaruan Info Media TTS",
"Select reading speed": "Pilih kecepatan membaca",
"Drag to seek": "Seret untuk mencari",
"Punctuation Delay": "Jeda tanda baca",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Harap konfirmasi bahwa koneksi OPDS ini akan diteruskan melalui server Readest di aplikasi web sebelum melanjutkan.",
"Custom Headers": "Header Kustom",
"Custom Headers (optional)": "Header Kustom (opsional)",
"Add one header per line using \"Header-Name: value\".": "Tambahkan satu header per baris menggunakan \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Saya memahami bahwa koneksi OPDS ini akan diteruskan melalui server Readest di aplikasi web. Jika saya tidak mempercayai Readest dengan kredensial atau header ini, saya harus menggunakan aplikasi native.",
"Unable to connect to Hardcover. Please check your network connection.": "Tidak dapat terhubung ke Hardcover. Periksa koneksi jaringan Anda.",
"Invalid Hardcover API token": "Token API Hardcover tidak valid",
"Disconnected from Hardcover": "Terputus dari Hardcover",
"Hardcover Settings": "Pengaturan Hardcover",
"Connected to Hardcover": "Terhubung ke Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Hubungkan akun Hardcover Anda untuk menyinkronkan progres membaca dan catatan.",
"Get your API token from hardcover.app → Settings → API.": "Dapatkan token API Anda dari hardcover.app → Pengaturan → API.",
"API Token": "Token API",
"Paste your Hardcover API token": "Tempel token API Hardcover Anda",
"Hardcover sync enabled for this book": "Sinkronisasi Hardcover diaktifkan untuk buku ini",
"Hardcover sync disabled for this book": "Sinkronisasi Hardcover dinonaktifkan untuk buku ini",
"Hardcover Sync": "Sinkronisasi Hardcover",
"Enable for This Book": "Aktifkan untuk Buku Ini",
"Push Notes": "Kirim Catatan",
"Enable Hardcover sync for this book first.": "Aktifkan sinkronisasi Hardcover untuk buku ini terlebih dahulu.",
"No annotations or excerpts to sync for this book.": "Tidak ada anotasi atau kutipan untuk disinkronkan pada buku ini.",
"Configure Hardcover in Settings first.": "Konfigurasikan Hardcover di Pengaturan terlebih dahulu.",
"No new Hardcover note changes to sync.": "Tidak ada perubahan catatan Hardcover baru untuk disinkronkan.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover disinkronkan: {{inserted}} baru, {{updated}} diperbarui, {{skipped}} tidak berubah",
"Hardcover notes sync failed: {{error}}": "Sinkronisasi catatan Hardcover gagal: {{error}}",
"Reading progress synced to Hardcover": "Progres membaca disinkronkan ke Hardcover",
"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",
"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"
}
@@ -184,7 +184,6 @@
"RTL Direction": "Direzione RTL",
"Maximum Column Height": "Altezza massima colonna",
"Maximum Column Width": "Larghezza massima colonna",
"Continuous Scroll": "Scorrimento continuo",
"Fullscreen": "Schermo intero",
"No supported files found. Supported formats: {{formats}}": "Nessun file supportato trovato. Formati supportati: {{formats}}",
"Drop to Import Books": "Rilascia per importare libri",
@@ -995,7 +994,6 @@
"words": "parole",
"{{time}} left": "{{time}} rimanenti",
"Reading progress": "Progresso lettura",
"Click to seek": "Clicca per cercare",
"Skip back 15 words": "Indietro di 15 parole",
"Back 15 words (Shift+Left)": "Indietro di 15 parole (Shift+Sinistra)",
"Pause (Space)": "Pausa (Spazio)",
@@ -1115,5 +1113,96 @@
"{{count}} books refreshed_many": "{{count}} libri aggiornati",
"{{count}} books refreshed_other": "{{count}} libri aggiornati",
"Failed to refresh metadata": "Aggiornamento metadati fallito",
"Refresh Metadata": "Aggiorna metadati"
"Refresh Metadata": "Aggiorna metadati",
"Keyboard Shortcuts": "Scorciatoie da tastiera",
"View all keyboard shortcuts": "Visualizza tutte le scorciatoie",
"Switch Sidebar Tab": "Cambia scheda barra laterale",
"Toggle Notebook": "Mostra/nascondi blocco note",
"Search in Book": "Cerca nel libro",
"Toggle Scroll Mode": "Attiva/disattiva scorrimento",
"Toggle Select Mode": "Attiva/disattiva selezione",
"Toggle Bookmark": "Attiva/disattiva segnalibro",
"Toggle Text to Speech": "Attiva/disattiva sintesi vocale",
"Play / Pause TTS": "Riproduci / Pausa TTS",
"Toggle Paragraph Mode": "Attiva/disattiva modo paragrafo",
"Highlight Selection": "Evidenzia selezione",
"Underline Selection": "Sottolinea selezione",
"Annotate Selection": "Annota selezione",
"Search Selection": "Cerca selezione",
"Copy Selection": "Copia selezione",
"Translate Selection": "Traduci selezione",
"Dictionary Lookup": "Cerca nel dizionario",
"Wikipedia Lookup": "Cerca su Wikipedia",
"Read Aloud Selection": "Leggi la selezione ad alta voce",
"Proofread Selection": "Correggi selezione",
"Open Settings": "Apri impostazioni",
"Open Command Palette": "Apri tavolozza comandi",
"Show Keyboard Shortcuts": "Mostra scorciatoie da tastiera",
"Open Books": "Apri libri",
"Toggle Fullscreen": "Attiva/disattiva schermo intero",
"Close Window": "Chiudi finestra",
"Quit App": "Esci dall'app",
"Go Left / Previous Page": "Vai a sinistra / Pagina precedente",
"Go Right / Next Page": "Vai a destra / Pagina successiva",
"Go Up": "Vai su",
"Go Down": "Vai giù",
"Previous Chapter": "Capitolo precedente",
"Next Chapter": "Capitolo successivo",
"Scroll Half Page Down": "Scorri mezza pagina in basso",
"Scroll Half Page Up": "Scorri mezza pagina in alto",
"Save Note": "Salva nota",
"Single Section Scroll": "Scorrimento a sezione singola",
"General": "Generale",
"Text to Speech": "Sintesi vocale",
"Zoom": "Zoom",
"Window": "Finestra",
"Your Bookshelf": "La tua libreria",
"TTS": "Sintesi vocale",
"Media Info": "Info multimediali",
"Update Frequency": "Frequenza aggiornamento",
"Every Sentence": "Ogni frase",
"Every Paragraph": "Ogni paragrafo",
"Every Chapter": "Ogni capitolo",
"TTS Media Info Update Frequency": "Frequenza aggiornamento info multimediali TTS",
"Select reading speed": "Seleziona velocità di lettura",
"Drag to seek": "Trascina per cercare",
"Punctuation Delay": "Ritardo punteggiatura",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Conferma che questa connessione OPDS verrà instradata attraverso i server Readest nell'app web prima di continuare.",
"Custom Headers": "Intestazioni personalizzate",
"Custom Headers (optional)": "Intestazioni personalizzate (facoltativo)",
"Add one header per line using \"Header-Name: value\".": "Aggiungi un'intestazione per riga usando \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Capisco che questa connessione OPDS verrà instradata attraverso i server Readest nell'app web. Se non mi fido di Readest con queste credenziali o intestazioni, dovrei usare l'app nativa.",
"Unable to connect to Hardcover. Please check your network connection.": "Impossibile connettersi a Hardcover. Controlla la connessione di rete.",
"Invalid Hardcover API token": "Token API Hardcover non valido",
"Disconnected from Hardcover": "Disconnesso da Hardcover",
"Hardcover Settings": "Impostazioni Hardcover",
"Connected to Hardcover": "Connesso a Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Collega il tuo account Hardcover per sincronizzare i progressi di lettura e le note.",
"Get your API token from hardcover.app → Settings → API.": "Ottieni il tuo token API da hardcover.app → Impostazioni → API.",
"API Token": "Token API",
"Paste your Hardcover API token": "Incolla il tuo token API Hardcover",
"Hardcover sync enabled for this book": "Sincronizzazione Hardcover attivata per questo libro",
"Hardcover sync disabled for this book": "Sincronizzazione Hardcover disattivata per questo libro",
"Hardcover Sync": "Sincronizzazione Hardcover",
"Enable for This Book": "Attiva per questo libro",
"Push Notes": "Invia note",
"Enable Hardcover sync for this book first.": "Attiva prima la sincronizzazione Hardcover per questo libro.",
"No annotations or excerpts to sync for this book.": "Nessuna annotazione o estratto da sincronizzare per questo libro.",
"Configure Hardcover in Settings first.": "Configura prima Hardcover nelle impostazioni.",
"No new Hardcover note changes to sync.": "Nessuna nuova modifica alle note Hardcover da sincronizzare.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover sincronizzato: {{inserted}} nuovi, {{updated}} aggiornati, {{skipped}} invariati",
"Hardcover notes sync failed: {{error}}": "Sincronizzazione note Hardcover non riuscita: {{error}}",
"Reading progress synced to Hardcover": "Progressi di lettura sincronizzati con Hardcover",
"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",
"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"
}
@@ -184,7 +184,6 @@
"RTL Direction": "RTL方向",
"Maximum Column Height": "最大列高",
"Maximum Column Width": "最大列幅",
"Continuous Scroll": "連続スクロール",
"Fullscreen": "全画面",
"No supported files found. Supported formats: {{formats}}": "サポートされているファイルが見つかりません。サポートされている形式:{{formats}}",
"Drop to Import Books": "書籍をインポートするにはドロップ",
@@ -971,7 +970,6 @@
"words": "単語",
"{{time}} left": "残り {{time}}",
"Reading progress": "読書の進捗",
"Click to seek": "クリックしてシーク",
"Skip back 15 words": "15単語戻る",
"Back 15 words (Shift+Left)": "15単語戻る (Shift+左)",
"Pause (Space)": "一時停止 (Space)",
@@ -1089,5 +1087,96 @@
"Loading library...": "ライブラリを読み込み中...",
"{{count}} books refreshed_other": "{{count}}冊のメタデータを更新しました",
"Failed to refresh metadata": "メタデータの更新に失敗しました",
"Refresh Metadata": "メタデータを更新"
"Refresh Metadata": "メタデータを更新",
"Keyboard Shortcuts": "キーボードショートカット",
"View all keyboard shortcuts": "すべてのキーボードショートカットを表示",
"Switch Sidebar Tab": "サイドバータブを切り替え",
"Toggle Notebook": "ノートブックの表示/非表示",
"Search in Book": "本の中を検索",
"Toggle Scroll Mode": "スクロールモードの切り替え",
"Toggle Select Mode": "選択モードの切り替え",
"Toggle Bookmark": "ブックマークの切り替え",
"Toggle Text to Speech": "音声読み上げの切り替え",
"Play / Pause TTS": "読み上げ再生 / 一時停止",
"Toggle Paragraph Mode": "段落モードの切り替え",
"Highlight Selection": "選択範囲をハイライト",
"Underline Selection": "選択範囲に下線",
"Annotate Selection": "選択範囲に注釈",
"Search Selection": "選択範囲を検索",
"Copy Selection": "選択範囲をコピー",
"Translate Selection": "選択範囲を翻訳",
"Dictionary Lookup": "辞書で調べる",
"Wikipedia Lookup": "Wikipediaで調べる",
"Read Aloud Selection": "選択範囲を読み上げ",
"Proofread Selection": "選択範囲を校正",
"Open Settings": "設定を開く",
"Open Command Palette": "コマンドパレットを開く",
"Show Keyboard Shortcuts": "キーボードショートカットを表示",
"Open Books": "本を開く",
"Toggle Fullscreen": "フルスクリーンの切り替え",
"Close Window": "ウィンドウを閉じる",
"Quit App": "アプリを終了",
"Go Left / Previous Page": "左へ / 前のページ",
"Go Right / Next Page": "右へ / 次のページ",
"Go Up": "上へ",
"Go Down": "下へ",
"Previous Chapter": "前の章",
"Next Chapter": "次の章",
"Scroll Half Page Down": "半ページ下にスクロール",
"Scroll Half Page Up": "半ページ上にスクロール",
"Save Note": "メモを保存",
"Single Section Scroll": "単一セクションスクロール",
"General": "一般",
"Text to Speech": "テキスト読み上げ",
"Zoom": "ズーム",
"Window": "ウィンドウ",
"Your Bookshelf": "あなたの本棚",
"TTS": "読み上げ",
"Media Info": "メディア情報",
"Update Frequency": "更新頻度",
"Every Sentence": "文ごと",
"Every Paragraph": "段落ごと",
"Every Chapter": "章ごと",
"TTS Media Info Update Frequency": "TTSメディア情報の更新頻度",
"Select reading speed": "読み上げ速度を選択",
"Drag to seek": "ドラッグしてシーク",
"Punctuation Delay": "句読点の遅延",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "続行する前に、このOPDS接続がWebアプリのReadestサーバーを経由してプロキシされることを確認してください。",
"Custom Headers": "カスタムヘッダー",
"Custom Headers (optional)": "カスタムヘッダー(任意)",
"Add one header per line using \"Header-Name: value\".": "「Header-Name: value」の形式で1行に1つのヘッダーを追加してください。",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "このOPDS接続がWebアプリのReadestサーバーを経由してプロキシされることを理解しています。これらの認証情報やヘッダーについてReadestを信頼しない場合は、ネイティブアプリを使用してください。",
"Unable to connect to Hardcover. Please check your network connection.": "Hardcoverに接続できません。ネットワーク接続を確認してください。",
"Invalid Hardcover API token": "Hardcover APIトークンが無効です",
"Disconnected from Hardcover": "Hardcoverから切断されました",
"Hardcover Settings": "Hardcover設定",
"Connected to Hardcover": "Hardcoverに接続済み",
"Connect your Hardcover account to sync reading progress and notes.": "Hardcoverアカウントを接続して、読書の進捗やメモを同期しましょう。",
"Get your API token from hardcover.app → Settings → API.": "hardcover.app → 設定 → APIからAPIトークンを取得してください。",
"API Token": "APIトークン",
"Paste your Hardcover API token": "HardcoverのAPIトークンを貼り付け",
"Hardcover sync enabled for this book": "この本のHardcover同期が有効になりました",
"Hardcover sync disabled for this book": "この本のHardcover同期が無効になりました",
"Hardcover Sync": "Hardcover同期",
"Enable for This Book": "この本で有効にする",
"Push Notes": "メモを送信",
"Enable Hardcover sync for this book first.": "まずこの本のHardcover同期を有効にしてください。",
"No annotations or excerpts to sync for this book.": "この本に同期する注釈や抜粋がありません。",
"Configure Hardcover in Settings first.": "まず設定でHardcoverを構成してください。",
"No new Hardcover note changes to sync.": "同期するHardcoverメモの新しい変更はありません。",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover同期完了: {{inserted}}件追加、{{updated}}件更新、{{skipped}}件変更なし",
"Hardcover notes sync failed: {{error}}": "Hardcoverメモの同期に失敗: {{error}}",
"Reading progress synced to Hardcover": "読書の進捗がHardcoverに同期されました",
"Hardcover progress sync failed: {{error}}": "Hardcover進捗の同期に失敗: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "既存のソース識別子を保持",
"Toggle Toolbar": "ツールバーの表示切替",
"Split Hyphens": "ハイフンで分割",
"Apply Theme Colors to PDF": "テーマカラーをPDFに適用",
"Name": "名前",
"Unavailable": "利用不可",
"Remove": "削除",
"Edit OPDS Catalog": "OPDSカタログを編集",
"Save Changes": "変更を保存",
"Use Book Layout": "書籍のレイアウトを使用"
}
@@ -184,7 +184,6 @@
"RTL Direction": "RTL 방향",
"Maximum Column Height": "최대 열 높이",
"Maximum Column Width": "최대 열 너비",
"Continuous Scroll": "연속 스크롤",
"Fullscreen": "전체 화면",
"No supported files found. Supported formats: {{formats}}": "지원되는 파일이 없습니다. 지원되는 형식: {{formats}}",
"Drop to Import Books": "책 가져오기 위해 드롭",
@@ -971,7 +970,6 @@
"words": "단어",
"{{time}} left": "{{time}} 남음",
"Reading progress": "독서 진행 상황",
"Click to seek": "클릭하여 탐색",
"Skip back 15 words": "15단어 뒤로",
"Back 15 words (Shift+Left)": "15단어 뒤로 (Shift+왼쪽 화살표)",
"Pause (Space)": "일시정지 (스페이스바)",
@@ -1089,5 +1087,96 @@
"Loading library...": "라이브러리 로딩 중...",
"{{count}} books refreshed_other": "{{count}}권 새로고침됨",
"Failed to refresh metadata": "메타데이터 새로고침 실패",
"Refresh Metadata": "메타데이터 새로고침"
"Refresh Metadata": "메타데이터 새로고침",
"Keyboard Shortcuts": "키보드 단축키",
"View all keyboard shortcuts": "모든 키보드 단축키 보기",
"Switch Sidebar Tab": "사이드바 탭 전환",
"Toggle Notebook": "노트북 표시/숨기기",
"Search in Book": "책에서 검색",
"Toggle Scroll Mode": "스크롤 모드 전환",
"Toggle Select Mode": "선택 모드 전환",
"Toggle Bookmark": "북마크 전환",
"Toggle Text to Speech": "텍스트 음성 변환 전환",
"Play / Pause TTS": "TTS 재생 / 일시정지",
"Toggle Paragraph Mode": "문단 모드 전환",
"Highlight Selection": "선택 영역 강조",
"Underline Selection": "선택 영역 밑줄",
"Annotate Selection": "선택 영역 주석",
"Search Selection": "선택 영역 검색",
"Copy Selection": "선택 영역 복사",
"Translate Selection": "선택 영역 번역",
"Dictionary Lookup": "사전 검색",
"Wikipedia Lookup": "위키백과 검색",
"Read Aloud Selection": "선택 영역 소리 내어 읽기",
"Proofread Selection": "선택 영역 교정",
"Open Settings": "설정 열기",
"Open Command Palette": "명령 팔레트 열기",
"Show Keyboard Shortcuts": "키보드 단축키 표시",
"Open Books": "책 열기",
"Toggle Fullscreen": "전체 화면 전환",
"Close Window": "창 닫기",
"Quit App": "앱 종료",
"Go Left / Previous Page": "왼쪽으로 / 이전 페이지",
"Go Right / Next Page": "오른쪽으로 / 다음 페이지",
"Go Up": "위로",
"Go Down": "아래로",
"Previous Chapter": "이전 장",
"Next Chapter": "다음 장",
"Scroll Half Page Down": "반 페이지 아래로 스크롤",
"Scroll Half Page Up": "반 페이지 위로 스크롤",
"Save Note": "메모 저장",
"Single Section Scroll": "단일 섹션 스크롤",
"General": "일반",
"Text to Speech": "텍스트 음성 변환",
"Zoom": "확대/축소",
"Window": "창",
"Your Bookshelf": "내 책장",
"TTS": "음성 읽기",
"Media Info": "미디어 정보",
"Update Frequency": "업데이트 빈도",
"Every Sentence": "문장마다",
"Every Paragraph": "단락마다",
"Every Chapter": "챕터마다",
"TTS Media Info Update Frequency": "TTS 미디어 정보 업데이트 빈도",
"Select reading speed": "읽기 속도 선택",
"Drag to seek": "드래그하여 탐색",
"Punctuation Delay": "구두점 지연",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "계속하기 전에 이 OPDS 연결이 웹 앱의 Readest 서버를 통해 프록시될 것임을 확인해 주세요.",
"Custom Headers": "사용자 정의 헤더",
"Custom Headers (optional)": "사용자 정의 헤더 (선택 사항)",
"Add one header per line using \"Header-Name: value\".": "\"Header-Name: value\" 형식으로 한 줄에 하나의 헤더를 추가하세요.",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "이 OPDS 연결이 웹 앱의 Readest 서버를 통해 프록시될 것임을 이해합니다. 이 자격 증명이나 헤더에 대해 Readest를 신뢰하지 않는다면 네이티브 앱을 사용해야 합니다.",
"Unable to connect to Hardcover. Please check your network connection.": "Hardcover에 연결할 수 없습니다. 네트워크 연결을 확인해 주세요.",
"Invalid Hardcover API token": "유효하지 않은 Hardcover API 토큰",
"Disconnected from Hardcover": "Hardcover에서 연결 해제됨",
"Hardcover Settings": "Hardcover 설정",
"Connected to Hardcover": "Hardcover에 연결됨",
"Connect your Hardcover account to sync reading progress and notes.": "독서 진행 상황과 메모를 동기화하려면 Hardcover 계정을 연결하세요.",
"Get your API token from hardcover.app → Settings → API.": "hardcover.app → 설정 → API에서 API 토큰을 받으세요.",
"API Token": "API 토큰",
"Paste your Hardcover API token": "Hardcover API 토큰을 붙여넣기",
"Hardcover sync enabled for this book": "이 책의 Hardcover 동기화가 활성화됨",
"Hardcover sync disabled for this book": "이 책의 Hardcover 동기화가 비활성화됨",
"Hardcover Sync": "Hardcover 동기화",
"Enable for This Book": "이 책에 대해 활성화",
"Push Notes": "메모 보내기",
"Enable Hardcover sync for this book first.": "먼저 이 책의 Hardcover 동기화를 활성화하세요.",
"No annotations or excerpts to sync for this book.": "이 책에 동기화할 주석이나 발췌가 없습니다.",
"Configure Hardcover in Settings first.": "먼저 설정에서 Hardcover를 구성하세요.",
"No new Hardcover note changes to sync.": "동기화할 새로운 Hardcover 메모 변경 사항이 없습니다.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover 동기화 완료: {{inserted}}개 추가, {{updated}}개 업데이트, {{skipped}}개 변경 없음",
"Hardcover notes sync failed: {{error}}": "Hardcover 메모 동기화 실패: {{error}}",
"Reading progress synced to Hardcover": "독서 진행 상황이 Hardcover에 동기화됨",
"Hardcover progress sync failed: {{error}}": "Hardcover 진행 상황 동기화 실패: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "기존 소스 식별자 유지",
"Toggle Toolbar": "도구 모음 전환",
"Split Hyphens": "하이픈 분리",
"Apply Theme Colors to PDF": "PDF에 테마 색상 적용",
"Name": "이름",
"Unavailable": "사용 불가",
"Remove": "삭제",
"Edit OPDS Catalog": "OPDS 카탈로그 편집",
"Save Changes": "변경 사항 저장",
"Use Book Layout": "책 레이아웃 사용"
}
@@ -500,7 +500,6 @@
"Quick Colors": "Warna Pantas",
"Override Book Color": "Ganti Warna Buku",
"Scroll": "Tatal",
"Continuous Scroll": "Tatal Berterusan",
"Overlap Pixels": "Piksel Bertindih",
"Pagination": "Penomboran Halaman",
"Tap to Paginate": "Ketik untuk Nomborkan Halaman",
@@ -971,7 +970,6 @@
"words": "perkataan",
"{{time}} left": "{{time}} tinggal",
"Reading progress": "Kemajuan membaca",
"Click to seek": "Klik untuk cari",
"Skip back 15 words": "Langkau belakang 15 perkataan",
"Back 15 words (Shift+Left)": "Belakang 15 perkataan (Shift+Kiri)",
"Pause (Space)": "Jeda (Ruang)",
@@ -1089,5 +1087,96 @@
"Loading library...": "Memuatkan perpustakaan...",
"{{count}} books refreshed_other": "{{count}} buku dimuat semula",
"Failed to refresh metadata": "Gagal memuat semula metadata",
"Refresh Metadata": "Muat Semula Metadata"
"Refresh Metadata": "Muat Semula Metadata",
"Keyboard Shortcuts": "Pintasan Papan Kekunci",
"View all keyboard shortcuts": "Lihat semua pintasan papan kekunci",
"Switch Sidebar Tab": "Tukar tab bar sisi",
"Toggle Notebook": "Tunjuk/sembunyikan buku nota",
"Search in Book": "Cari dalam buku",
"Toggle Scroll Mode": "Togol mod tatal",
"Toggle Select Mode": "Togol mod pilih",
"Toggle Bookmark": "Togol penanda buku",
"Toggle Text to Speech": "Togol teks ke pertuturan",
"Play / Pause TTS": "Main / Jeda TTS",
"Toggle Paragraph Mode": "Togol mod perenggan",
"Highlight Selection": "Serlahkan pilihan",
"Underline Selection": "Garis bawah pilihan",
"Annotate Selection": "Anotasi pilihan",
"Search Selection": "Cari pilihan",
"Copy Selection": "Salin pilihan",
"Translate Selection": "Terjemah pilihan",
"Dictionary Lookup": "Cari dalam kamus",
"Wikipedia Lookup": "Cari di Wikipedia",
"Read Aloud Selection": "Baca kuat pilihan",
"Proofread Selection": "Semak pilihan",
"Open Settings": "Buka tetapan",
"Open Command Palette": "Buka palet arahan",
"Show Keyboard Shortcuts": "Tunjukkan pintasan papan kekunci",
"Open Books": "Buka buku",
"Toggle Fullscreen": "Togol skrin penuh",
"Close Window": "Tutup tetingkap",
"Quit App": "Keluar dari aplikasi",
"Go Left / Previous Page": "Ke kiri / Halaman sebelumnya",
"Go Right / Next Page": "Ke kanan / Halaman seterusnya",
"Go Up": "Ke atas",
"Go Down": "Ke bawah",
"Previous Chapter": "Bab sebelumnya",
"Next Chapter": "Bab seterusnya",
"Scroll Half Page Down": "Tatal separuh halaman ke bawah",
"Scroll Half Page Up": "Tatal separuh halaman ke atas",
"Save Note": "Simpan nota",
"Single Section Scroll": "Tatal Satu Bahagian",
"General": "Umum",
"Text to Speech": "Teks ke Pertuturan",
"Zoom": "Zum",
"Window": "Tetingkap",
"Your Bookshelf": "Rak Buku Anda",
"TTS": "Teks ke Suara",
"Media Info": "Info Media",
"Update Frequency": "Kekerapan Kemas Kini",
"Every Sentence": "Setiap Ayat",
"Every Paragraph": "Setiap Perenggan",
"Every Chapter": "Setiap Bab",
"TTS Media Info Update Frequency": "Kekerapan Kemas Kini Info Media TTS",
"Select reading speed": "Pilih kelajuan membaca",
"Drag to seek": "Seret untuk mencari",
"Punctuation Delay": "Kelewatan tanda baca",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Sila sahkan bahawa sambungan OPDS ini akan diproksi melalui pelayan Readest pada aplikasi web sebelum meneruskan.",
"Custom Headers": "Pengepala Tersuai",
"Custom Headers (optional)": "Pengepala Tersuai (pilihan)",
"Add one header per line using \"Header-Name: value\".": "Tambah satu pengepala setiap baris menggunakan \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Saya faham bahawa sambungan OPDS ini akan diproksi melalui pelayan Readest pada aplikasi web. Jika saya tidak mempercayai Readest dengan kelayakan atau pengepala ini, saya perlu menggunakan aplikasi natif.",
"Unable to connect to Hardcover. Please check your network connection.": "Tidak dapat menyambung ke Hardcover. Sila semak sambungan rangkaian anda.",
"Invalid Hardcover API token": "Token API Hardcover tidak sah",
"Disconnected from Hardcover": "Diputuskan dari Hardcover",
"Hardcover Settings": "Tetapan Hardcover",
"Connected to Hardcover": "Disambungkan ke Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Sambungkan akaun Hardcover anda untuk menyegerakkan kemajuan bacaan dan nota.",
"Get your API token from hardcover.app → Settings → API.": "Dapatkan token API anda dari hardcover.app → Tetapan → API.",
"API Token": "Token API",
"Paste your Hardcover API token": "Tampal token API Hardcover anda",
"Hardcover sync enabled for this book": "Penyegerakan Hardcover diaktifkan untuk buku ini",
"Hardcover sync disabled for this book": "Penyegerakan Hardcover dinyahaktifkan untuk buku ini",
"Hardcover Sync": "Penyegerakan Hardcover",
"Enable for This Book": "Aktifkan untuk Buku Ini",
"Push Notes": "Hantar Nota",
"Enable Hardcover sync for this book first.": "Aktifkan penyegerakan Hardcover untuk buku ini terlebih dahulu.",
"No annotations or excerpts to sync for this book.": "Tiada anotasi atau petikan untuk disegerakkan bagi buku ini.",
"Configure Hardcover in Settings first.": "Konfigurasikan Hardcover dalam Tetapan terlebih dahulu.",
"No new Hardcover note changes to sync.": "Tiada perubahan nota Hardcover baharu untuk disegerakkan.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover disegerakkan: {{inserted}} baharu, {{updated}} dikemas kini, {{skipped}} tidak berubah",
"Hardcover notes sync failed: {{error}}": "Penyegerakan nota Hardcover gagal: {{error}}",
"Reading progress synced to Hardcover": "Kemajuan bacaan disegerakkan ke Hardcover",
"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",
"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"
}
@@ -179,7 +179,6 @@
"Portrait": "Portret",
"Landscape": "Landschap",
"Behavior": "Gedrag",
"Continuous Scroll": "Doorlopend scrollen",
"Volume Keys for Page Flip": "Volumetoetsen voor pagina omslaan",
"Apply": "Toepassen",
"Font": "Lettertype",
@@ -983,7 +982,6 @@
"words": "woorden",
"{{time}} left": "{{time}} over",
"Reading progress": "Leesvoortgang",
"Click to seek": "Klik om te zoeken",
"Skip back 15 words": "15 woorden terug",
"Back 15 words (Shift+Left)": "15 woorden terug (Shift+Links)",
"Pause (Space)": "Pauze (Spatie)",
@@ -1102,5 +1100,96 @@
"{{count}} books refreshed_one": "{{count}} boek vernieuwd",
"{{count}} books refreshed_other": "{{count}} boeken vernieuwd",
"Failed to refresh metadata": "Metadata vernieuwen mislukt",
"Refresh Metadata": "Metadata vernieuwen"
"Refresh Metadata": "Metadata vernieuwen",
"Keyboard Shortcuts": "Sneltoetsen",
"View all keyboard shortcuts": "Alle sneltoetsen bekijken",
"Switch Sidebar Tab": "Zijbalktabblad wisselen",
"Toggle Notebook": "Notitieboek tonen/verbergen",
"Search in Book": "Zoeken in boek",
"Toggle Scroll Mode": "Scrollmodus wisselen",
"Toggle Select Mode": "Selectiemodus wisselen",
"Toggle Bookmark": "Bladwijzer wisselen",
"Toggle Text to Speech": "Tekst-naar-spraak wisselen",
"Play / Pause TTS": "TTS afspelen / pauzeren",
"Toggle Paragraph Mode": "Alineamodus wisselen",
"Highlight Selection": "Selectie markeren",
"Underline Selection": "Selectie onderstrepen",
"Annotate Selection": "Selectie annoteren",
"Search Selection": "Selectie zoeken",
"Copy Selection": "Selectie kopiëren",
"Translate Selection": "Selectie vertalen",
"Dictionary Lookup": "Opzoeken in woordenboek",
"Wikipedia Lookup": "Opzoeken op Wikipedia",
"Read Aloud Selection": "Selectie voorlezen",
"Proofread Selection": "Selectie proeflezen",
"Open Settings": "Instellingen openen",
"Open Command Palette": "Opdrachtenpalet openen",
"Show Keyboard Shortcuts": "Sneltoetsen tonen",
"Open Books": "Boeken openen",
"Toggle Fullscreen": "Volledig scherm wisselen",
"Close Window": "Venster sluiten",
"Quit App": "App afsluiten",
"Go Left / Previous Page": "Naar links / Vorige pagina",
"Go Right / Next Page": "Naar rechts / Volgende pagina",
"Go Up": "Omhoog",
"Go Down": "Omlaag",
"Previous Chapter": "Vorig hoofdstuk",
"Next Chapter": "Volgend hoofdstuk",
"Scroll Half Page Down": "Halve pagina naar beneden scrollen",
"Scroll Half Page Up": "Halve pagina naar boven scrollen",
"Save Note": "Notitie opslaan",
"Single Section Scroll": "Scrollen per sectie",
"General": "Algemeen",
"Text to Speech": "Tekst naar spraak",
"Zoom": "Zoom",
"Window": "Venster",
"Your Bookshelf": "Jouw boekenkast",
"TTS": "Spraak",
"Media Info": "Media-info",
"Update Frequency": "Updatefrequentie",
"Every Sentence": "Elke zin",
"Every Paragraph": "Elke alinea",
"Every Chapter": "Elk hoofdstuk",
"TTS Media Info Update Frequency": "TTS media-info updatefrequentie",
"Select reading speed": "Leessnelheid selecteren",
"Drag to seek": "Sleep om te zoeken",
"Punctuation Delay": "Leesteken-vertraging",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Bevestig dat deze OPDS-verbinding via Readest-servers in de webapp wordt doorgestuurd voordat u doorgaat.",
"Custom Headers": "Aangepaste headers",
"Custom Headers (optional)": "Aangepaste headers (optioneel)",
"Add one header per line using \"Header-Name: value\".": "Voeg één header per regel toe met \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Ik begrijp dat deze OPDS-verbinding via Readest-servers in de webapp wordt doorgestuurd. Als ik Readest niet vertrouw met deze inloggegevens of headers, moet ik de native app gebruiken.",
"Unable to connect to Hardcover. Please check your network connection.": "Kan geen verbinding maken met Hardcover. Controleer uw netwerkverbinding.",
"Invalid Hardcover API token": "Ongeldig Hardcover API-token",
"Disconnected from Hardcover": "Verbinding met Hardcover verbroken",
"Hardcover Settings": "Hardcover-instellingen",
"Connected to Hardcover": "Verbonden met Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Verbind uw Hardcover-account om leesvoortgang en notities te synchroniseren.",
"Get your API token from hardcover.app → Settings → API.": "Haal uw API-token op via hardcover.app → Instellingen → API.",
"API Token": "API-token",
"Paste your Hardcover API token": "Plak uw Hardcover API-token",
"Hardcover sync enabled for this book": "Hardcover-synchronisatie ingeschakeld voor dit boek",
"Hardcover sync disabled for this book": "Hardcover-synchronisatie uitgeschakeld voor dit boek",
"Hardcover Sync": "Hardcover-synchronisatie",
"Enable for This Book": "Inschakelen voor dit boek",
"Push Notes": "Notities verzenden",
"Enable Hardcover sync for this book first.": "Schakel eerst Hardcover-synchronisatie in voor dit boek.",
"No annotations or excerpts to sync for this book.": "Geen annotaties of fragmenten om te synchroniseren voor dit boek.",
"Configure Hardcover in Settings first.": "Configureer eerst Hardcover in de instellingen.",
"No new Hardcover note changes to sync.": "Geen nieuwe Hardcover-notitiewijzigingen om te synchroniseren.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover gesynchroniseerd: {{inserted}} nieuw, {{updated}} bijgewerkt, {{skipped}} ongewijzigd",
"Hardcover notes sync failed: {{error}}": "Synchronisatie van Hardcover-notities mislukt: {{error}}",
"Reading progress synced to Hardcover": "Leesvoortgang gesynchroniseerd met Hardcover",
"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",
"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"
}
@@ -184,7 +184,6 @@
"RTL Direction": "Kierunek RTL",
"Maximum Column Height": "Maksymalna wysokość kolumny",
"Maximum Column Width": "Maksymalna szerokość kolumny",
"Continuous Scroll": "Przewijanie ciągłe",
"Fullscreen": "Pełny ekran",
"No supported files found. Supported formats: {{formats}}": "Nie znaleziono obsługiwanych plików. Obsługiwane formaty: {{formats}}",
"Drop to Import Books": "Upuść, aby zaimportować książki",
@@ -1007,7 +1006,6 @@
"words": "słowa",
"{{time}} left": "pozostało {{time}}",
"Reading progress": "Postęp czytania",
"Click to seek": "Kliknij, aby wyszukać",
"Skip back 15 words": "Cofnij o 15 słów",
"Back 15 words (Shift+Left)": "Cofnij o 15 słów (Shift+Lewo)",
"Pause (Space)": "Pauze (Spacja)",
@@ -1128,5 +1126,96 @@
"{{count}} books refreshed_many": "Odświeżono {{count}} książek",
"{{count}} books refreshed_other": "Odświeżono {{count}} książek",
"Failed to refresh metadata": "Nie udało się odświeżyć metadanych",
"Refresh Metadata": "Odśwież metadane"
"Refresh Metadata": "Odśwież metadane",
"Keyboard Shortcuts": "Skróty klawiszowe",
"View all keyboard shortcuts": "Zobacz wszystkie skróty klawiszowe",
"Switch Sidebar Tab": "Przełącz kartę paska bocznego",
"Toggle Notebook": "Pokaż/ukryj notatnik",
"Search in Book": "Szukaj w książce",
"Toggle Scroll Mode": "Przełącz tryb przewijania",
"Toggle Select Mode": "Przełącz tryb zaznaczania",
"Toggle Bookmark": "Przełącz zakładkę",
"Toggle Text to Speech": "Przełącz tekst na mowę",
"Play / Pause TTS": "Odtwórz / Wstrzymaj TTS",
"Toggle Paragraph Mode": "Przełącz tryb akapitu",
"Highlight Selection": "Podświetl zaznaczenie",
"Underline Selection": "Podkreśl zaznaczenie",
"Annotate Selection": "Dodaj adnotację do zaznaczenia",
"Search Selection": "Wyszukaj zaznaczenie",
"Copy Selection": "Kopiuj zaznaczenie",
"Translate Selection": "Przetłumacz zaznaczenie",
"Dictionary Lookup": "Wyszukaj w słowniku",
"Wikipedia Lookup": "Wyszukaj w Wikipedii",
"Read Aloud Selection": "Odczytaj zaznaczenie na głos",
"Proofread Selection": "Sprawdź zaznaczenie",
"Open Settings": "Otwórz ustawienia",
"Open Command Palette": "Otwórz paletę poleceń",
"Show Keyboard Shortcuts": "Pokaż skróty klawiszowe",
"Open Books": "Otwórz książki",
"Toggle Fullscreen": "Przełącz pełny ekran",
"Close Window": "Zamknij okno",
"Quit App": "Zamknij aplikację",
"Go Left / Previous Page": "W lewo / Poprzednia strona",
"Go Right / Next Page": "W prawo / Następna strona",
"Go Up": "W górę",
"Go Down": "W dół",
"Previous Chapter": "Poprzedni rozdział",
"Next Chapter": "Następny rozdział",
"Scroll Half Page Down": "Przewiń o pół strony w dół",
"Scroll Half Page Up": "Przewiń o pół strony w górę",
"Save Note": "Zapisz notatkę",
"Single Section Scroll": "Przewijanie pojedynczej sekcji",
"General": "Ogólne",
"Text to Speech": "Tekst na mowę",
"Zoom": "Powiększenie",
"Window": "Okno",
"Your Bookshelf": "Twoja półka z książkami",
"TTS": "Czytanie na głos",
"Media Info": "Informacje o mediach",
"Update Frequency": "Częstotliwość aktualizacji",
"Every Sentence": "Co zdanie",
"Every Paragraph": "Co akapit",
"Every Chapter": "Co rozdział",
"TTS Media Info Update Frequency": "Częstotliwość aktualizacji informacji o mediach TTS",
"Select reading speed": "Wybierz prędkość czytania",
"Drag to seek": "Przeciągnij, aby przewinąć",
"Punctuation Delay": "Opóźnienie interpunkcji",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Potwierdź, że to połączenie OPDS będzie przekierowywane przez serwery Readest w aplikacji webowej, zanim przejdziesz dalej.",
"Custom Headers": "Niestandardowe nagłówki",
"Custom Headers (optional)": "Niestandardowe nagłówki (opcjonalnie)",
"Add one header per line using \"Header-Name: value\".": "Dodaj jeden nagłówek w każdym wierszu, używając formatu \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Rozumiem, że to połączenie OPDS będzie przekierowywane przez serwery Readest w aplikacji webowej. Jeśli nie ufam Readest w kwestii tych danych uwierzytelniających lub nagłówków, powinienem użyć aplikacji natywnej.",
"Unable to connect to Hardcover. Please check your network connection.": "Nie można połączyć się z Hardcover. Sprawdź połączenie sieciowe.",
"Invalid Hardcover API token": "Nieprawidłowy token API Hardcover",
"Disconnected from Hardcover": "Rozłączono z Hardcover",
"Hardcover Settings": "Ustawienia Hardcover",
"Connected to Hardcover": "Połączono z Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Połącz swoje konto Hardcover, aby synchronizować postęp czytania i notatki.",
"Get your API token from hardcover.app → Settings → API.": "Pobierz token API z hardcover.app → Ustawienia → API.",
"API Token": "Token API",
"Paste your Hardcover API token": "Wklej swój token API Hardcover",
"Hardcover sync enabled for this book": "Synchronizacja Hardcover włączona dla tej książki",
"Hardcover sync disabled for this book": "Synchronizacja Hardcover wyłączona dla tej książki",
"Hardcover Sync": "Synchronizacja Hardcover",
"Enable for This Book": "Włącz dla tej książki",
"Push Notes": "Wyślij notatki",
"Enable Hardcover sync for this book first.": "Najpierw włącz synchronizację Hardcover dla tej książki.",
"No annotations or excerpts to sync for this book.": "Brak adnotacji lub fragmentów do zsynchronizowania dla tej książki.",
"Configure Hardcover in Settings first.": "Najpierw skonfiguruj Hardcover w ustawieniach.",
"No new Hardcover note changes to sync.": "Brak nowych zmian notatek Hardcover do zsynchronizowania.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover zsynchronizowano: {{inserted}} nowych, {{updated}} zaktualizowanych, {{skipped}} bez zmian",
"Hardcover notes sync failed: {{error}}": "Synchronizacja notatek Hardcover nie powiodła się: {{error}}",
"Reading progress synced to Hardcover": "Postęp czytania zsynchronizowany z Hardcover",
"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",
"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"
}
@@ -184,7 +184,6 @@
"RTL Direction": "Direção RTL",
"Maximum Column Height": "Altura Máxima da Coluna",
"Maximum Column Width": "Largura Máxima da Coluna",
"Continuous Scroll": "Rolagem Contínua",
"Fullscreen": "Tela Cheia",
"No supported files found. Supported formats: {{formats}}": "Nenhum arquivo suportado encontrado. Formatos suportados: {{formats}}",
"Drop to Import Books": "Arraste para Importar Livros",
@@ -995,7 +994,6 @@
"words": "palavras",
"{{time}} left": "{{time}} restante",
"Reading progress": "Progresso de leitura",
"Click to seek": "Clique para navegar",
"Skip back 15 words": "Voltar 15 palavras",
"Back 15 words (Shift+Left)": "Voltar 15 palavras (Shift+Esquerda)",
"Pause (Space)": "Pausar (Espaço)",
@@ -1115,5 +1113,96 @@
"{{count}} books refreshed_many": "{{count}} livros atualizados",
"{{count}} books refreshed_other": "{{count}} livros atualizados",
"Failed to refresh metadata": "Falha ao atualizar metadados",
"Refresh Metadata": "Atualizar metadados"
"Refresh Metadata": "Atualizar metadados",
"Keyboard Shortcuts": "Atalhos de teclado",
"View all keyboard shortcuts": "Ver todos os atalhos de teclado",
"Switch Sidebar Tab": "Alternar aba da barra lateral",
"Toggle Notebook": "Mostrar/ocultar caderno",
"Search in Book": "Pesquisar no livro",
"Toggle Scroll Mode": "Alternar modo rolagem",
"Toggle Select Mode": "Alternar modo seleção",
"Toggle Bookmark": "Alternar marcador",
"Toggle Text to Speech": "Alternar texto para fala",
"Play / Pause TTS": "Reproduzir / Pausar TTS",
"Toggle Paragraph Mode": "Alternar modo parágrafo",
"Highlight Selection": "Destacar seleção",
"Underline Selection": "Sublinhar seleção",
"Annotate Selection": "Anotar seleção",
"Search Selection": "Pesquisar seleção",
"Copy Selection": "Copiar seleção",
"Translate Selection": "Traduzir seleção",
"Dictionary Lookup": "Pesquisar no dicionário",
"Wikipedia Lookup": "Pesquisar na Wikipédia",
"Read Aloud Selection": "Ler seleção em voz alta",
"Proofread Selection": "Revisar seleção",
"Open Settings": "Abrir configurações",
"Open Command Palette": "Abrir paleta de comandos",
"Show Keyboard Shortcuts": "Mostrar atalhos de teclado",
"Open Books": "Abrir livros",
"Toggle Fullscreen": "Alternar tela cheia",
"Close Window": "Fechar janela",
"Quit App": "Sair do aplicativo",
"Go Left / Previous Page": "Ir para a esquerda / Página anterior",
"Go Right / Next Page": "Ir para a direita / Próxima página",
"Go Up": "Ir para cima",
"Go Down": "Ir para baixo",
"Previous Chapter": "Capítulo anterior",
"Next Chapter": "Próximo capítulo",
"Scroll Half Page Down": "Rolar meia página para baixo",
"Scroll Half Page Up": "Rolar meia página para cima",
"Save Note": "Salvar nota",
"Single Section Scroll": "Rolagem por seção",
"General": "Geral",
"Text to Speech": "Texto para fala",
"Zoom": "Zoom",
"Window": "Janela",
"Your Bookshelf": "Sua estante",
"TTS": "Leitura em voz alta",
"Media Info": "Info de mídia",
"Update Frequency": "Frequência de atualização",
"Every Sentence": "A cada frase",
"Every Paragraph": "A cada parágrafo",
"Every Chapter": "A cada capítulo",
"TTS Media Info Update Frequency": "Frequência de atualização de info de mídia TTS",
"Select reading speed": "Selecionar velocidade de leitura",
"Drag to seek": "Arraste para procurar",
"Punctuation Delay": "Atraso de pontuação",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Confirme que esta conexão OPDS será encaminhada através dos servidores Readest no aplicativo web antes de continuar.",
"Custom Headers": "Cabeçalhos personalizados",
"Custom Headers (optional)": "Cabeçalhos personalizados (opcional)",
"Add one header per line using \"Header-Name: value\".": "Adicione um cabeçalho por linha usando \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Entendo que esta conexão OPDS será encaminhada através dos servidores Readest no aplicativo web. Se não confio no Readest com essas credenciais ou cabeçalhos, devo usar o aplicativo nativo.",
"Unable to connect to Hardcover. Please check your network connection.": "Não foi possível conectar ao Hardcover. Verifique sua conexão de rede.",
"Invalid Hardcover API token": "Token de API do Hardcover inválido",
"Disconnected from Hardcover": "Desconectado do Hardcover",
"Hardcover Settings": "Configurações do Hardcover",
"Connected to Hardcover": "Conectado ao Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Conecte sua conta Hardcover para sincronizar o progresso de leitura e notas.",
"Get your API token from hardcover.app → Settings → API.": "Obtenha seu token de API em hardcover.app → Configurações → API.",
"API Token": "Token de API",
"Paste your Hardcover API token": "Cole seu token de API do Hardcover",
"Hardcover sync enabled for this book": "Sincronização do Hardcover ativada para este livro",
"Hardcover sync disabled for this book": "Sincronização do Hardcover desativada para este livro",
"Hardcover Sync": "Sincronização do Hardcover",
"Enable for This Book": "Ativar para este livro",
"Push Notes": "Enviar notas",
"Enable Hardcover sync for this book first.": "Ative primeiro a sincronização do Hardcover para este livro.",
"No annotations or excerpts to sync for this book.": "Não há anotações ou trechos para sincronizar neste livro.",
"Configure Hardcover in Settings first.": "Configure o Hardcover nas configurações primeiro.",
"No new Hardcover note changes to sync.": "Não há novas alterações de notas do Hardcover para sincronizar.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover sincronizado: {{inserted}} novos, {{updated}} atualizados, {{skipped}} inalterados",
"Hardcover notes sync failed: {{error}}": "Falha na sincronização de notas do Hardcover: {{error}}",
"Reading progress synced to Hardcover": "Progresso de leitura sincronizado com o Hardcover",
"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",
"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"
}
File diff suppressed because it is too large Load Diff
@@ -184,7 +184,6 @@
"RTL Direction": "Направление RTL",
"Maximum Column Height": "Максимальная высота колонки",
"Maximum Column Width": "Максимальная ширина колонки",
"Continuous Scroll": "Непрерывная прокрутка",
"Fullscreen": "Полноэкранный режим",
"No supported files found. Supported formats: {{formats}}": "Поддерживаемые файлы не найдены. Поддерживаемые форматы: {{formats}}",
"Drop to Import Books": "Перетащите сюда книги для импорта",
@@ -1007,7 +1006,6 @@
"words": "слов",
"{{time}} left": "осталось {{time}}",
"Reading progress": "Прогресс чтения",
"Click to seek": "Нажмите для поиска",
"Skip back 15 words": "Назад на 15 слов",
"Back 15 words (Shift+Left)": "Назад на 15 слов (Shift+Влево)",
"Pause (Space)": "Пауза (Пробел)",
@@ -1128,5 +1126,96 @@
"{{count}} books refreshed_many": "Обновлено {{count}} книг",
"{{count}} books refreshed_other": "Обновлено {{count}} книг",
"Failed to refresh metadata": "Не удалось обновить метаданные",
"Refresh Metadata": "Обновить метаданные"
"Refresh Metadata": "Обновить метаданные",
"Keyboard Shortcuts": "Сочетания клавиш",
"View all keyboard shortcuts": "Показать все сочетания клавиш",
"Switch Sidebar Tab": "Переключить вкладку боковой панели",
"Toggle Notebook": "Показать/скрыть записную книжку",
"Search in Book": "Поиск в книге",
"Toggle Scroll Mode": "Переключить режим прокрутки",
"Toggle Select Mode": "Переключить режим выделения",
"Toggle Bookmark": "Переключить закладку",
"Toggle Text to Speech": "Переключить озвучивание текста",
"Play / Pause TTS": "Воспроизвести / Пауза TTS",
"Toggle Paragraph Mode": "Переключить режим абзаца",
"Highlight Selection": "Выделить выбранное",
"Underline Selection": "Подчеркнуть выбранное",
"Annotate Selection": "Добавить примечание к выбранному",
"Search Selection": "Искать выбранное",
"Copy Selection": "Копировать выбранное",
"Translate Selection": "Перевести выбранное",
"Dictionary Lookup": "Поиск в словаре",
"Wikipedia Lookup": "Поиск в Википедии",
"Read Aloud Selection": "Прочитать выбранное вслух",
"Proofread Selection": "Проверить выбранное",
"Open Settings": "Открыть настройки",
"Open Command Palette": "Открыть палитру команд",
"Show Keyboard Shortcuts": "Показать сочетания клавиш",
"Open Books": "Открыть книги",
"Toggle Fullscreen": "Переключить полноэкранный режим",
"Close Window": "Закрыть окно",
"Quit App": "Выйти из приложения",
"Go Left / Previous Page": "Влево / Предыдущая страница",
"Go Right / Next Page": "Вправо / Следующая страница",
"Go Up": "Вверх",
"Go Down": "Вниз",
"Previous Chapter": "Предыдущая глава",
"Next Chapter": "Следующая глава",
"Scroll Half Page Down": "Прокрутить на полстраницы вниз",
"Scroll Half Page Up": "Прокрутить на полстраницы вверх",
"Save Note": "Сохранить заметку",
"Single Section Scroll": "Прокрутка одного раздела",
"General": "Общие",
"Text to Speech": "Озвучивание",
"Zoom": "Масштаб",
"Window": "Окно",
"Your Bookshelf": "Ваша книжная полка",
"TTS": "Озвучивание",
"Media Info": "Медиа-информация",
"Update Frequency": "Частота обновления",
"Every Sentence": "Каждое предложение",
"Every Paragraph": "Каждый абзац",
"Every Chapter": "Каждая глава",
"TTS Media Info Update Frequency": "Частота обновления медиа-информации TTS",
"Select reading speed": "Выберите скорость чтения",
"Drag to seek": "Перетащите для перемотки",
"Punctuation Delay": "Задержка на знаках препинания",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Подтвердите, что это OPDS-соединение будет проксироваться через серверы Readest в веб-приложении, прежде чем продолжить.",
"Custom Headers": "Пользовательские заголовки",
"Custom Headers (optional)": "Пользовательские заголовки (необязательно)",
"Add one header per line using \"Header-Name: value\".": "Добавляйте по одному заголовку на строку в формате \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Я понимаю, что это OPDS-соединение будет проксироваться через серверы Readest в веб-приложении. Если я не доверяю Readest свои учётные данные или заголовки, мне следует использовать нативное приложение.",
"Unable to connect to Hardcover. Please check your network connection.": "Не удалось подключиться к Hardcover. Проверьте сетевое подключение.",
"Invalid Hardcover API token": "Недействительный API-токен Hardcover",
"Disconnected from Hardcover": "Отключено от Hardcover",
"Hardcover Settings": "Настройки Hardcover",
"Connected to Hardcover": "Подключено к Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Подключите свой аккаунт Hardcover для синхронизации прогресса чтения и заметок.",
"Get your API token from hardcover.app → Settings → API.": "Получите API-токен на hardcover.app → Настройки → API.",
"API Token": "API-токен",
"Paste your Hardcover API token": "Вставьте ваш API-токен Hardcover",
"Hardcover sync enabled for this book": "Синхронизация Hardcover включена для этой книги",
"Hardcover sync disabled for this book": "Синхронизация Hardcover отключена для этой книги",
"Hardcover Sync": "Синхронизация Hardcover",
"Enable for This Book": "Включить для этой книги",
"Push Notes": "Отправить заметки",
"Enable Hardcover sync for this book first.": "Сначала включите синхронизацию Hardcover для этой книги.",
"No annotations or excerpts to sync for this book.": "Нет аннотаций или выдержек для синхронизации для этой книги.",
"Configure Hardcover in Settings first.": "Сначала настройте Hardcover в параметрах.",
"No new Hardcover note changes to sync.": "Нет новых изменений заметок Hardcover для синхронизации.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover синхронизирован: {{inserted}} новых, {{updated}} обновлённых, {{skipped}} без изменений",
"Hardcover notes sync failed: {{error}}": "Ошибка синхронизации заметок Hardcover: {{error}}",
"Reading progress synced to Hardcover": "Прогресс чтения синхронизирован с Hardcover",
"Hardcover progress sync failed: {{error}}": "Ошибка синхронизации прогресса Hardcover: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Сохранить существующий идентификатор источника",
"Toggle Toolbar": "Переключить панель инструментов",
"Split Hyphens": "Разделить дефисы",
"Apply Theme Colors to PDF": "Применить цвета темы к PDF",
"Name": "Имя",
"Unavailable": "Недоступно",
"Remove": "Удалить",
"Edit OPDS Catalog": "Редактировать каталог OPDS",
"Save Changes": "Сохранить изменения",
"Use Book Layout": "Использовать макет книги"
}
@@ -199,7 +199,6 @@
"Auto": "ස්වයංක්‍රීය",
"Scroll": "අනුචලනය",
"Scrolled Mode": "අනුචලන ආකාරය",
"Continuous Scroll": "අඛණ්ඩ අනුචලනය",
"Overlap Pixels": "අතිච්ඡාදන පික්සල්",
"Disable Double Click": "ද්විත්ව ක්ලික් අබල කරන්න",
"Volume Keys for Page Flip": "පිටු පෙරැළීම සඳහා ශබ්ද යතුර",
@@ -983,7 +982,6 @@
"words": "වචන",
"{{time}} left": "{{time}} ඉතිරිව ඇත",
"Reading progress": "කියවීමේ ප්‍රගතිය",
"Click to seek": "සොයා බැලීමට ක්ලික් කරන්න",
"Skip back 15 words": "වචන 15ක් පසුපසට",
"Back 15 words (Shift+Left)": "වචන 15ක් පසුපසට (Shift+Left)",
"Pause (Space)": "විරාමය (Space)",
@@ -1102,5 +1100,96 @@
"{{count}} books refreshed_one": "පොත් {{count}}ක් යාවත්කාලීන විය",
"{{count}} books refreshed_other": "පොත් {{count}}ක් යාවත්කාලීන විය",
"Failed to refresh metadata": "පාරදත්ත යාවත්කාලීන කිරීම අසාර්ථකයි",
"Refresh Metadata": "පාරදත්ත යාවත්කාලීන කරන්න"
"Refresh Metadata": "පාරදත්ත යාවත්කාලීන කරන්න",
"Keyboard Shortcuts": "යතුරුපුවරු කෙටිමං",
"View all keyboard shortcuts": "සියලුම යතුරුපුවරු කෙටිමං බලන්න",
"Switch Sidebar Tab": "පැති තීරු ටැබය මාරු කරන්න",
"Toggle Notebook": "සටහන් පොත පෙන්වන්න/සඟවන්න",
"Search in Book": "පොතේ සොයන්න",
"Toggle Scroll Mode": "අනුචලන ප්‍රකාරය මාරු කරන්න",
"Toggle Select Mode": "තේරීම් ප්‍රකාරය මාරු කරන්න",
"Toggle Bookmark": "පිටු සලකුණ මාරු කරන්න",
"Toggle Text to Speech": "පෙළ-කථනය මාරු කරන්න",
"Play / Pause TTS": "TTS ධාවනය / විරාමය",
"Toggle Paragraph Mode": "ඡේද ප්‍රකාරය මාරු කරන්න",
"Highlight Selection": "තේරීම උද්දීපනය කරන්න",
"Underline Selection": "තේරීමට යටින් ඉරක් අදින්න",
"Annotate Selection": "තේරීමට සටහනක් එක් කරන්න",
"Search Selection": "තේරීම සොයන්න",
"Copy Selection": "තේරීම පිටපත් කරන්න",
"Translate Selection": "තේරීම පරිවර්තනය කරන්න",
"Dictionary Lookup": "ශබ්දකෝෂයේ සොයන්න",
"Wikipedia Lookup": "විකිපීඩියාවේ සොයන්න",
"Read Aloud Selection": "තේරීම ශබ්ද නගා කියවන්න",
"Proofread Selection": "තේරීම සෝදුපත් කරන්න",
"Open Settings": "සැකසුම් විවෘත කරන්න",
"Open Command Palette": "විධාන පැලට් එක විවෘත කරන්න",
"Show Keyboard Shortcuts": "යතුරුපුවරු කෙටිමං පෙන්වන්න",
"Open Books": "පොත් විවෘත කරන්න",
"Toggle Fullscreen": "පූර්ණ තිරය මාරු කරන්න",
"Close Window": "කවුළුව වසන්න",
"Quit App": "යෙදුමෙන් පිටවන්න",
"Go Left / Previous Page": "වමට / පෙර පිටුව",
"Go Right / Next Page": "දකුණට / ඊළඟ පිටුව",
"Go Up": "ඉහළට",
"Go Down": "පහළට",
"Previous Chapter": "පෙර පරිච්ඡේදය",
"Next Chapter": "ඊළඟ පරිච්ඡේදය",
"Scroll Half Page Down": "අඩ පිටුවක් පහළට අනුචලනය",
"Scroll Half Page Up": "අඩ පිටුවක් ඉහළට අනුචලනය",
"Save Note": "සටහන සුරකින්න",
"Single Section Scroll": "තනි කොටස අනුචලනය",
"General": "සාමාන්‍ය",
"Text to Speech": "පෙළ කථනය",
"Zoom": "විශාලනය",
"Window": "කවුළුව",
"Your Bookshelf": "ඔබේ පොත් රාක්කය",
"TTS": "කියවීම",
"Media Info": "මාධ්‍ය තොරතුරු",
"Update Frequency": "යාවත්කාලීන සංඛ්‍යාතය",
"Every Sentence": "සෑම වාක්‍යයක්ම",
"Every Paragraph": "සෑම ඡේදයක්ම",
"Every Chapter": "සෑම පරිච්ඡේදයක්ම",
"TTS Media Info Update Frequency": "TTS මාධ්‍ය තොරතුරු යාවත්කාලීන සංඛ්‍යාතය",
"Select reading speed": "කියවීමේ වේගය තෝරන්න",
"Drag to seek": "සෙවීමට ඇදගන්න",
"Punctuation Delay": "විරාම ලකුණු ප්‍රමාදය",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "ඉදිරියට යාමට පෙර මෙම OPDS සම්බන්ධතාවය වෙබ් යෙදුමේ Readest සේවාදායක හරහා ප්‍රොක්සි කරන බව තහවුරු කරන්න.",
"Custom Headers": "අභිරුචි ශීර්ෂ",
"Custom Headers (optional)": "අභිරුචි ශීර්ෂ (විකල්ප)",
"Add one header per line using \"Header-Name: value\".": "\"Header-Name: value\" භාවිතයෙන් පේළියකට එක ශීර්ෂයක් එක් කරන්න.",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "මෙම OPDS සම්බන්ධතාවය වෙබ් යෙදුමේ Readest සේවාදායක හරහා ප්‍රොක්සි කරන බව මට තේරෙනවා. මෙම අක්තපත්‍ර හෝ ශීර්ෂ සමඟ Readest විශ්වාස නොකරන්නේ නම්, මම ස්වදේශීය යෙදුම භාවිතා කළ යුතුය.",
"Unable to connect to Hardcover. Please check your network connection.": "Hardcover වෙත සම්බන්ධ විය නොහැක. ඔබේ ජාල සම්බන්ධතාවය පරීක්ෂා කරන්න.",
"Invalid Hardcover API token": "අවලංගු Hardcover API ටෝකනය",
"Disconnected from Hardcover": "Hardcover වෙතින් විසන්ධි විය",
"Hardcover Settings": "Hardcover සැකසීම්",
"Connected to Hardcover": "Hardcover වෙත සම්බන්ධයි",
"Connect your Hardcover account to sync reading progress and notes.": "කියවීමේ ප්‍රගතිය සහ සටහන් සමමුහුර්ත කිරීමට ඔබේ Hardcover ගිණුම සම්බන්ධ කරන්න.",
"Get your API token from hardcover.app → Settings → API.": "hardcover.app → සැකසීම් → API වෙතින් ඔබේ API ටෝකනය ලබාගන්න.",
"API Token": "API ටෝකනය",
"Paste your Hardcover API token": "ඔබේ Hardcover API ටෝකනය අලවන්න",
"Hardcover sync enabled for this book": "මෙම පොත සඳහා Hardcover සමමුහුර්තකරණය සක්‍රීයයි",
"Hardcover sync disabled for this book": "මෙම පොත සඳහා Hardcover සමමුහුර්තකරණය අක්‍රීයයි",
"Hardcover Sync": "Hardcover සමමුහුර්තකරණය",
"Enable for This Book": "මෙම පොත සඳහා සක්‍රීය කරන්න",
"Push Notes": "සටහන් යවන්න",
"Enable Hardcover sync for this book first.": "පළමුව මෙම පොත සඳහා Hardcover සමමුහුර්තකරණය සක්‍රීය කරන්න.",
"No annotations or excerpts to sync for this book.": "මෙම පොත සඳහා සමමුහුර්ත කිරීමට සටහන් හෝ උපුටා ගැනීම් නැත.",
"Configure Hardcover in Settings first.": "පළමුව සැකසීම් තුළ Hardcover වින්‍යාස කරන්න.",
"No new Hardcover note changes to sync.": "සමමුහුර්ත කිරීමට නව Hardcover සටහන් වෙනස්කම් නැත.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover සමමුහුර්ත විය: {{inserted}} නව, {{updated}} යාවත්කාලීන, {{skipped}} වෙනස් නොවූ",
"Hardcover notes sync failed: {{error}}": "Hardcover සටහන් සමමුහුර්තකරණය අසාර්ථකයි: {{error}}",
"Reading progress synced to Hardcover": "කියවීමේ ප්‍රගතිය Hardcover වෙත සමමුහුර්ත විය",
"Hardcover progress sync failed: {{error}}": "Hardcover ප්‍රගති සමමුහුර්තකරණය අසාර්ථකයි: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "පවතින මූලාශ්‍ර හඳුනාගැනීම තබාගන්න",
"Toggle Toolbar": "මෙවලම් තීරුව ටොගල් කරන්න",
"Split Hyphens": "හයිෆන් බෙදන්න",
"Apply Theme Colors to PDF": "PDF වෙත තේමා වර්ණ යොදන්න",
"Name": "නම",
"Unavailable": "ලබාගත නොහැක",
"Remove": "ඉවත් කරන්න",
"Edit OPDS Catalog": "OPDS නාමාවලිය සංස්කරණය",
"Save Changes": "වෙනස්කම් සුරකින්න",
"Use Book Layout": "පොතේ සැකැස්ම භාවිත කරන්න"
}
@@ -523,7 +523,6 @@
"words": "besed",
"{{time}} left": "{{time}} do konca",
"Reading progress": "Napredek branja",
"Click to seek": "Kliknite za iskanje",
"Skip back 15 words": "Skoči nazaj za 15 besed",
"Back 15 words (Shift+Left)": "Nazaj za 15 besed (Shift+Levo)",
"Pause": "Premor",
@@ -896,7 +895,6 @@
"Override Book Color": "Prezri barvo knjige",
"None": "Brez",
"Scroll": "Pomikanje",
"Continuous Scroll": "Neprekinjeno pomikanje",
"Overlap Pixels": "Prekrivanje pikslov",
"Pagination": "Paginacija",
"Tap to Paginate": "Tapnite za paginacijo",
@@ -1128,5 +1126,96 @@
"{{count}} books refreshed_few": "Osvežene {{count}} knjige",
"{{count}} books refreshed_other": "Osveženih {{count}} knjig",
"Failed to refresh metadata": "Osveževanje metapodatkov ni uspelo",
"Refresh Metadata": "Osveži metapodatke"
"Refresh Metadata": "Osveži metapodatke",
"Keyboard Shortcuts": "Bližnjice na tipkovnici",
"View all keyboard shortcuts": "Prikaži vse bližnjice na tipkovnici",
"Switch Sidebar Tab": "Preklopi zavihek stranske vrstice",
"Toggle Notebook": "Prikaži/skrij beležnico",
"Search in Book": "Išči v knjigi",
"Toggle Scroll Mode": "Preklopi način drsenja",
"Toggle Select Mode": "Preklopi način izbire",
"Toggle Bookmark": "Preklopi zaznamek",
"Toggle Text to Speech": "Preklopi besedilo v govor",
"Play / Pause TTS": "Predvajaj / Zaustavi TTS",
"Toggle Paragraph Mode": "Preklopi način odstavka",
"Highlight Selection": "Označi izbiro",
"Underline Selection": "Podčrtaj izbiro",
"Annotate Selection": "Dodaj opombo k izbiri",
"Search Selection": "Išči izbiro",
"Copy Selection": "Kopiraj izbiro",
"Translate Selection": "Prevedi izbiro",
"Dictionary Lookup": "Iskanje v slovarju",
"Wikipedia Lookup": "Iskanje na Wikipediji",
"Read Aloud Selection": "Preberi izbiro na glas",
"Proofread Selection": "Lektoriraj izbiro",
"Open Settings": "Odpri nastavitve",
"Open Command Palette": "Odpri ukazno paleto",
"Show Keyboard Shortcuts": "Prikaži bližnjice na tipkovnici",
"Open Books": "Odpri knjige",
"Toggle Fullscreen": "Preklopi celoten zaslon",
"Close Window": "Zapri okno",
"Quit App": "Zapri aplikacijo",
"Go Left / Previous Page": "Levo / Prejšnja stran",
"Go Right / Next Page": "Desno / Naslednja stran",
"Go Up": "Navzgor",
"Go Down": "Navzdol",
"Previous Chapter": "Prejšnje poglavje",
"Next Chapter": "Naslednje poglavje",
"Scroll Half Page Down": "Drsi pol strani navzdol",
"Scroll Half Page Up": "Drsi pol strani navzgor",
"Save Note": "Shrani opombo",
"Single Section Scroll": "Drsenje posameznega razdelka",
"General": "Splošno",
"Text to Speech": "Besedilo v govor",
"Zoom": "Povečava",
"Window": "Okno",
"Your Bookshelf": "Vaša knjižna polica",
"TTS": "Branje na glas",
"Media Info": "Podatki o medijih",
"Update Frequency": "Pogostost posodabljanja",
"Every Sentence": "Vsak stavek",
"Every Paragraph": "Vsak odstavek",
"Every Chapter": "Vsako poglavje",
"TTS Media Info Update Frequency": "Pogostost posodabljanja podatkov o medijih TTS",
"Select reading speed": "Izberite hitrost branja",
"Drag to seek": "Povlecite za iskanje",
"Punctuation Delay": "Zamik ločil",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Pred nadaljevanjem potrdite, da bo ta povezava OPDS preusmerjena prek strežnikov Readest v spletni aplikaciji.",
"Custom Headers": "Glave po meri",
"Custom Headers (optional)": "Glave po meri (neobvezno)",
"Add one header per line using \"Header-Name: value\".": "Dodajte eno glavo na vrstico v obliki \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Razumem, da bo ta povezava OPDS preusmerjena prek strežnikov Readest v spletni aplikaciji. Če ne zaupam Readest s temi poverilnicami ali glavami, moram uporabiti izvorno aplikacijo.",
"Unable to connect to Hardcover. Please check your network connection.": "Povezava s Hardcover ni mogoča. Preverite omrežno povezavo.",
"Invalid Hardcover API token": "Neveljaven žeton API za Hardcover",
"Disconnected from Hardcover": "Prekinjeno s Hardcover",
"Hardcover Settings": "Nastavitve Hardcover",
"Connected to Hardcover": "Povezano s Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Povežite svoj račun Hardcover za sinhronizacijo napredka branja in zapiskov.",
"Get your API token from hardcover.app → Settings → API.": "Pridobite žeton API na hardcover.app → Nastavitve → API.",
"API Token": "Žeton API",
"Paste your Hardcover API token": "Prilepite žeton API za Hardcover",
"Hardcover sync enabled for this book": "Sinhronizacija Hardcover je omogočena za to knjigo",
"Hardcover sync disabled for this book": "Sinhronizacija Hardcover je onemogočena za to knjigo",
"Hardcover Sync": "Sinhronizacija Hardcover",
"Enable for This Book": "Omogoči za to knjigo",
"Push Notes": "Pošlji zapiske",
"Enable Hardcover sync for this book first.": "Najprej omogočite sinhronizacijo Hardcover za to knjigo.",
"No annotations or excerpts to sync for this book.": "Za to knjigo ni opomb ali izpiskov za sinhronizacijo.",
"Configure Hardcover in Settings first.": "Najprej nastavite Hardcover v nastavitvah.",
"No new Hardcover note changes to sync.": "Ni novih sprememb zapiskov Hardcover za sinhronizacijo.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover sinhronizirano: {{inserted}} novih, {{updated}} posodobljenih, {{skipped}} nespremenjenih",
"Hardcover notes sync failed: {{error}}": "Sinhronizacija zapiskov Hardcover ni uspela: {{error}}",
"Reading progress synced to Hardcover": "Napredek branja sinhroniziran s Hardcover",
"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",
"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"
}
@@ -446,7 +446,6 @@
"Enable Highlighting": "Aktivera markering",
"Code Language": "Kodspråk",
"Scroll": "Rullning",
"Continuous Scroll": "Kontinuerlig rullning",
"Overlap Pixels": "Överlappande pixlar",
"Disable Double Click": "Inaktivera dubbelklick",
"Volume Keys for Page Flip": "Volymknappar för sidbyte",
@@ -983,7 +982,6 @@
"words": "ord",
"{{time}} left": "{{time}} kvar",
"Reading progress": "Läsningens framsteg",
"Click to seek": "Klicka för att söka",
"Skip back 15 words": "Hoppa bakåt 15 ord",
"Back 15 words (Shift+Left)": "Bakåt 15 ord (Shift+Vänster)",
"Pause (Space)": "Pausa (Mellanslag)",
@@ -1102,5 +1100,96 @@
"{{count}} books refreshed_one": "{{count}} bok uppdaterad",
"{{count}} books refreshed_other": "{{count}} böcker uppdaterade",
"Failed to refresh metadata": "Kunde inte uppdatera metadata",
"Refresh Metadata": "Uppdatera metadata"
"Refresh Metadata": "Uppdatera metadata",
"Keyboard Shortcuts": "Tangentbordsgenvägar",
"View all keyboard shortcuts": "Visa alla tangentbordsgenvägar",
"Switch Sidebar Tab": "Byt flik i sidofältet",
"Toggle Notebook": "Visa/dölj anteckningsbok",
"Search in Book": "Sök i boken",
"Toggle Scroll Mode": "Växla rullningsläge",
"Toggle Select Mode": "Växla markeringsläge",
"Toggle Bookmark": "Växla bokmärke",
"Toggle Text to Speech": "Växla text-till-tal",
"Play / Pause TTS": "Spela upp / Pausa TTS",
"Toggle Paragraph Mode": "Växla styckeläge",
"Highlight Selection": "Markera urval",
"Underline Selection": "Stryka under urval",
"Annotate Selection": "Kommentera urval",
"Search Selection": "Sök urval",
"Copy Selection": "Kopiera urval",
"Translate Selection": "Översätt urval",
"Dictionary Lookup": "Slå upp i ordbok",
"Wikipedia Lookup": "Slå upp på Wikipedia",
"Read Aloud Selection": "Läs upp urval",
"Proofread Selection": "Korrekturläs urval",
"Open Settings": "Öppna inställningar",
"Open Command Palette": "Öppna kommandopaletten",
"Show Keyboard Shortcuts": "Visa tangentbordsgenvägar",
"Open Books": "Öppna böcker",
"Toggle Fullscreen": "Växla helskärm",
"Close Window": "Stäng fönster",
"Quit App": "Avsluta appen",
"Go Left / Previous Page": "Vänster / Föregående sida",
"Go Right / Next Page": "Höger / Nästa sida",
"Go Up": "Uppåt",
"Go Down": "Nedåt",
"Previous Chapter": "Föregående kapitel",
"Next Chapter": "Nästa kapitel",
"Scroll Half Page Down": "Rulla en halv sida nedåt",
"Scroll Half Page Up": "Rulla en halv sida uppåt",
"Save Note": "Spara anteckning",
"Single Section Scroll": "Rullning per avsnitt",
"General": "Allmänt",
"Text to Speech": "Text till tal",
"Zoom": "Zoom",
"Window": "Fönster",
"Your Bookshelf": "Din bokhylla",
"TTS": "Uppläsning",
"Media Info": "Medieinformation",
"Update Frequency": "Uppdateringsfrekvens",
"Every Sentence": "Varje mening",
"Every Paragraph": "Varje stycke",
"Every Chapter": "Varje kapitel",
"TTS Media Info Update Frequency": "Uppdateringsfrekvens för TTS-medieinformation",
"Select reading speed": "Välj läshastighet",
"Drag to seek": "Dra för att söka",
"Punctuation Delay": "Skiljeteckenfördröjning",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Bekräfta att denna OPDS-anslutning kommer att dirigeras via Readest-servrar i webbappen innan du fortsätter.",
"Custom Headers": "Anpassade rubriker",
"Custom Headers (optional)": "Anpassade rubriker (valfritt)",
"Add one header per line using \"Header-Name: value\".": "Lägg till en rubrik per rad med formatet \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Jag förstår att denna OPDS-anslutning kommer att dirigeras via Readest-servrar i webbappen. Om jag inte litar på Readest med dessa autentiseringsuppgifter eller rubriker bör jag använda den inbyggda appen istället.",
"Unable to connect to Hardcover. Please check your network connection.": "Kan inte ansluta till Hardcover. Kontrollera din nätverksanslutning.",
"Invalid Hardcover API token": "Ogiltig Hardcover API-token",
"Disconnected from Hardcover": "Frånkopplad från Hardcover",
"Hardcover Settings": "Hardcover-inställningar",
"Connected to Hardcover": "Ansluten till Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Anslut ditt Hardcover-konto för att synkronisera läsframsteg och anteckningar.",
"Get your API token from hardcover.app → Settings → API.": "Hämta din API-token från hardcover.app → Inställningar → API.",
"API Token": "API-token",
"Paste your Hardcover API token": "Klistra in din Hardcover API-token",
"Hardcover sync enabled for this book": "Hardcover-synkronisering aktiverad för den här boken",
"Hardcover sync disabled for this book": "Hardcover-synkronisering inaktiverad för den här boken",
"Hardcover Sync": "Hardcover-synkronisering",
"Enable for This Book": "Aktivera för den här boken",
"Push Notes": "Skicka anteckningar",
"Enable Hardcover sync for this book first.": "Aktivera först Hardcover-synkronisering för den här boken.",
"No annotations or excerpts to sync for this book.": "Inga anteckningar eller utdrag att synkronisera för den här boken.",
"Configure Hardcover in Settings first.": "Konfigurera först Hardcover i inställningarna.",
"No new Hardcover note changes to sync.": "Inga nya ändringar av Hardcover-anteckningar att synkronisera.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover synkroniserat: {{inserted}} nya, {{updated}} uppdaterade, {{skipped}} oförändrade",
"Hardcover notes sync failed: {{error}}": "Synkronisering av Hardcover-anteckningar misslyckades: {{error}}",
"Reading progress synced to Hardcover": "Läsframsteg synkroniserat med Hardcover",
"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",
"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"
}
@@ -199,7 +199,6 @@
"Auto": "தானியங்கி",
"Scroll": "உருட்டவும்",
"Scrolled Mode": "உருட்டல் பயன்முறை",
"Continuous Scroll": "தொடர்ச்சியான உருட்டல்",
"Overlap Pixels": "ஒன்றுடன் ஒன்று பிக்சல்கள்",
"Disable Double Click": "இரட்டை கிளிக்கை முடக்கவும்",
"Volume Keys for Page Flip": "பக்கம் புரட்டுவதற்கு ஒலி விசைகள்",
@@ -983,7 +982,6 @@
"words": "வார்த்தைகள்",
"{{time}} left": "{{time}} மீதமுள்ளது",
"Reading progress": "வாசிப்பு முன்னேற்றம்",
"Click to seek": "தேட கிளிக் செய்க",
"Skip back 15 words": "15 வார்த்தைகள் பின்னால் செல்",
"Back 15 words (Shift+Left)": "15 வார்த்தைகள் பின்னால் செல் (Shift+Left)",
"Pause (Space)": "நிறுத்து (Space)",
@@ -1102,5 +1100,96 @@
"{{count}} books refreshed_one": "{{count}} புத்தகம் புதுப்பிக்கப்பட்டது",
"{{count}} books refreshed_other": "{{count}} புத்தகங்கள் புதுப்பிக்கப்பட்டன",
"Failed to refresh metadata": "மெட்டாடேட்டா புதுப்பிப்பு தோல்வி",
"Refresh Metadata": "மெட்டாடேட்டா புதுப்பிக்கவும்"
"Refresh Metadata": "மெட்டாடேட்டா புதுப்பிக்கவும்",
"Keyboard Shortcuts": "விசைப்பலகை குறுக்குவழிகள்",
"View all keyboard shortcuts": "அனைத்து விசைப்பலகை குறுக்குவழிகளையும் காண்க",
"Switch Sidebar Tab": "பக்கப்பட்டி தாவலை மாற்றுக",
"Toggle Notebook": "குறிப்பேட்டைக் காட்டு/மறை",
"Search in Book": "புத்தகத்தில் தேடுக",
"Toggle Scroll Mode": "உருள் பயன்முறையை மாற்றுக",
"Toggle Select Mode": "தேர்வு பயன்முறையை மாற்றுக",
"Toggle Bookmark": "புக்மார்க்கை மாற்றுக",
"Toggle Text to Speech": "உரையிலிருந்து பேச்சுக்கு மாற்றுக",
"Play / Pause TTS": "TTS இயக்கு / இடைநிறுத்து",
"Toggle Paragraph Mode": "பத்தி பயன்முறையை மாற்றுக",
"Highlight Selection": "தேர்வை சிறப்பிக்கவும்",
"Underline Selection": "தேர்வுக்கு அடிக்கோடிடுக",
"Annotate Selection": "தேர்வுக்கு குறிப்பு சேர்க்கவும்",
"Search Selection": "தேர்வைத் தேடுக",
"Copy Selection": "தேர்வை நகலெடுக்கவும்",
"Translate Selection": "தேர்வை மொழிபெயர்க்கவும்",
"Dictionary Lookup": "அகராதியில் தேடுக",
"Wikipedia Lookup": "விக்கிப்பீடியாவில் தேடுக",
"Read Aloud Selection": "தேர்வை சத்தமாக படிக்கவும்",
"Proofread Selection": "தேர்வை சரிபார்க்கவும்",
"Open Settings": "அமைப்புகளைத் திறக்கவும்",
"Open Command Palette": "கட்டளை தட்டை திறக்கவும்",
"Show Keyboard Shortcuts": "விசைப்பலகை குறுக்குவழிகளைக் காட்டுக",
"Open Books": "புத்தகங்களைத் திறக்கவும்",
"Toggle Fullscreen": "முழுத்திரையை மாற்றுக",
"Close Window": "சாளரத்தை மூடுக",
"Quit App": "பயன்பாட்டிலிருந்து வெளியேறுக",
"Go Left / Previous Page": "இடது / முந்தைய பக்கம்",
"Go Right / Next Page": "வலது / அடுத்த பக்கம்",
"Go Up": "மேலே",
"Go Down": "கீழே",
"Previous Chapter": "முந்தைய அத்தியாயம்",
"Next Chapter": "அடுத்த அத்தியாயம்",
"Scroll Half Page Down": "அரை பக்கம் கீழே உருட்டுக",
"Scroll Half Page Up": "அரை பக்கம் மேலே உருட்டுக",
"Save Note": "குறிப்பைச் சேமிக்கவும்",
"Single Section Scroll": "ஒற்றைப் பகுதி உருட்டல்",
"General": "பொது",
"Text to Speech": "உரையிலிருந்து பேச்சு",
"Zoom": "பெரிதாக்கு",
"Window": "சாளரம்",
"Your Bookshelf": "உங்கள் புத்தக அலமாரி",
"TTS": "உரை வாசிப்பு",
"Media Info": "ஊடக தகவல்",
"Update Frequency": "புதுப்பிப்பு அதிர்வெண்",
"Every Sentence": "ஒவ்வொரு வாக்கியமும்",
"Every Paragraph": "ஒவ்வொரு பத்தியும்",
"Every Chapter": "ஒவ்வொரு அத்தியாயமும்",
"TTS Media Info Update Frequency": "TTS ஊடக தகவல் புதுப்பிப்பு அதிர்வெண்",
"Select reading speed": "படிக்கும் வேகத்தைத் தேர்ந்தெடுக்கவும்",
"Drag to seek": "தேட இழுக்கவும்",
"Punctuation Delay": "நிறுத்தக்குறி தாமதம்",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "தொடர்வதற்கு முன், இந்த OPDS இணைப்பு வலை பயன்பாட்டில் Readest சேவையகங்கள் வழியாக ப்ராக்ஸி செய்யப்படும் என்பதை உறுதிப்படுத்தவும்.",
"Custom Headers": "தனிப்பயன் தலைப்புகள்",
"Custom Headers (optional)": "தனிப்பயன் தலைப்புகள் (விரும்பினால்)",
"Add one header per line using \"Header-Name: value\".": "\"Header-Name: value\" வடிவமைப்பைப் பயன்படுத்தி ஒவ்வொரு வரியிலும் ஒரு தலைப்பைச் சேர்க்கவும்.",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "இந்த OPDS இணைப்பு வலை பயன்பாட்டில் Readest சேவையகங்கள் வழியாக ப்ராக்ஸி செய்யப்படும் என்பதை நான் புரிந்துகொள்கிறேன். இந்த சான்றுகள் அல்லது தலைப்புகளுடன் Readest-ஐ நம்பவில்லை என்றால், நான் நேட்டிவ் பயன்பாட்டைப் பயன்படுத்த வேண்டும்.",
"Unable to connect to Hardcover. Please check your network connection.": "Hardcover உடன் இணைக்க முடியவில்லை. உங்கள் நெட்வொர்க் இணைப்பைச் சரிபார்க்கவும்.",
"Invalid Hardcover API token": "தவறான Hardcover API டோக்கன்",
"Disconnected from Hardcover": "Hardcover இலிருந்து துண்டிக்கப்பட்டது",
"Hardcover Settings": "Hardcover அமைப்புகள்",
"Connected to Hardcover": "Hardcover உடன் இணைக்கப்பட்டது",
"Connect your Hardcover account to sync reading progress and notes.": "வாசிப்பு முன்னேற்றம் மற்றும் குறிப்புகளை ஒத்திசைக்க உங்கள் Hardcover கணக்கை இணைக்கவும்.",
"Get your API token from hardcover.app → Settings → API.": "hardcover.app → அமைப்புகள் → API இலிருந்து உங்கள் API டோக்கனைப் பெறவும்.",
"API Token": "API டோக்கன்",
"Paste your Hardcover API token": "உங்கள் Hardcover API டோக்கனை ஒட்டவும்",
"Hardcover sync enabled for this book": "இந்த புத்தகத்திற்கு Hardcover ஒத்திசைவு இயக்கப்பட்டது",
"Hardcover sync disabled for this book": "இந்த புத்தகத்திற்கு Hardcover ஒத்திசைவு முடக்கப்பட்டது",
"Hardcover Sync": "Hardcover ஒத்திசைவு",
"Enable for This Book": "இந்த புத்தகத்திற்கு இயக்கு",
"Push Notes": "குறிப்புகளை அனுப்பு",
"Enable Hardcover sync for this book first.": "முதலில் இந்த புத்தகத்திற்கு Hardcover ஒத்திசைவை இயக்கவும்.",
"No annotations or excerpts to sync for this book.": "இந்த புத்தகத்திற்கு ஒத்திசைக்க குறிப்புகள் அல்லது பகுதிகள் இல்லை.",
"Configure Hardcover in Settings first.": "முதலில் அமைப்புகளில் Hardcover-ஐ கட்டமைக்கவும்.",
"No new Hardcover note changes to sync.": "ஒத்திசைக்க புதிய Hardcover குறிப்பு மாற்றங்கள் இல்லை.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover ஒத்திசைவு: {{inserted}} புதிய, {{updated}} புதுப்பிக்கப்பட்ட, {{skipped}} மாற்றமற்ற",
"Hardcover notes sync failed: {{error}}": "Hardcover குறிப்புகள் ஒத்திசைவு தோல்வி: {{error}}",
"Reading progress synced to Hardcover": "வாசிப்பு முன்னேற்றம் Hardcover உடன் ஒத்திசைக்கப்பட்டது",
"Hardcover progress sync failed: {{error}}": "Hardcover முன்னேற்ற ஒத்திசைவு தோல்வி: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "ஏற்கனவே உள்ள மூல அடையாளங்காட்டியை வைத்திருக்கவும்",
"Toggle Toolbar": "கருவிப்பட்டியை நிலைமாற்று",
"Split Hyphens": "ஹைபன்களைப் பிரி",
"Apply Theme Colors to PDF": "PDF-க்கு தீம் நிறங்களைப் பயன்படுத்து",
"Name": "பெயர்",
"Unavailable": "கிடைக்கவில்லை",
"Remove": "நீக்கு",
"Edit OPDS Catalog": "OPDS பட்டியலைத் திருத்து",
"Save Changes": "மாற்றங்களைச் சேமி",
"Use Book Layout": "புத்தகத்தின் அமைப்பைப் பயன்படுத்து"
}
@@ -164,7 +164,6 @@
"Code Language": "ภาษาโค้ด",
"Scroll": "เลื่อน",
"Scrolled Mode": "โหมดเลื่อน",
"Continuous Scroll": "เลื่อนต่อเนื่อง",
"Overlap Pixels": "พิกเซลซ้อน",
"Volume Keys for Page Flip": "ปุ่มเสียงเพื่อพลิกหน้า",
"Animation": "ภาพเคลื่อนไหว",
@@ -971,7 +970,6 @@
"words": "คำ",
"{{time}} left": "เหลือ {{time}}",
"Reading progress": "ความคืบหน้าการอ่าน",
"Click to seek": "คลิกเพื่อค้นหาตำแหน่ง",
"Skip back 15 words": "ย้อนกลับ 15 คำ",
"Back 15 words (Shift+Left)": "ย้อนกลับ 15 คำ (Shift+ซ้าย)",
"Pause (Space)": "หยุดชั่วคราว (Space)",
@@ -1089,5 +1087,96 @@
"Loading library...": "กำลังโหลดห้องสมุด...",
"{{count}} books refreshed_other": "รีเฟรชแล้ว {{count}} เล่ม",
"Failed to refresh metadata": "รีเฟรชข้อมูลเมตาล้มเหลว",
"Refresh Metadata": "รีเฟรชข้อมูลเมตา"
"Refresh Metadata": "รีเฟรชข้อมูลเมตา",
"Keyboard Shortcuts": "แป้นพิมพ์ลัด",
"View all keyboard shortcuts": "ดูแป้นพิมพ์ลัดทั้งหมด",
"Switch Sidebar Tab": "สลับแท็บแถบด้านข้าง",
"Toggle Notebook": "แสดง/ซ่อนสมุดบันทึก",
"Search in Book": "ค้นหาในหนังสือ",
"Toggle Scroll Mode": "สลับโหมดเลื่อน",
"Toggle Select Mode": "สลับโหมดเลือก",
"Toggle Bookmark": "สลับที่คั่นหนังสือ",
"Toggle Text to Speech": "สลับข้อความเป็นเสียง",
"Play / Pause TTS": "เล่น / หยุดชั่วคราว TTS",
"Toggle Paragraph Mode": "สลับโหมดย่อหน้า",
"Highlight Selection": "ไฮไลต์ข้อความที่เลือก",
"Underline Selection": "ขีดเส้นใต้ข้อความที่เลือก",
"Annotate Selection": "เพิ่มหมายเหตุให้ข้อความที่เลือก",
"Search Selection": "ค้นหาข้อความที่เลือก",
"Copy Selection": "คัดลอกข้อความที่เลือก",
"Translate Selection": "แปลข้อความที่เลือก",
"Dictionary Lookup": "ค้นหาในพจนานุกรม",
"Wikipedia Lookup": "ค้นหาใน Wikipedia",
"Read Aloud Selection": "อ่านออกเสียงข้อความที่เลือก",
"Proofread Selection": "ตรวจทานข้อความที่เลือก",
"Open Settings": "เปิดการตั้งค่า",
"Open Command Palette": "เปิดแถบคำสั่ง",
"Show Keyboard Shortcuts": "แสดงแป้นพิมพ์ลัด",
"Open Books": "เปิดหนังสือ",
"Toggle Fullscreen": "สลับเต็มหน้าจอ",
"Close Window": "ปิดหน้าต่าง",
"Quit App": "ออกจากแอป",
"Go Left / Previous Page": "ไปซ้าย / หน้าก่อนหน้า",
"Go Right / Next Page": "ไปขวา / หน้าถัดไป",
"Go Up": "ขึ้น",
"Go Down": "ลง",
"Previous Chapter": "บทก่อนหน้า",
"Next Chapter": "บทถัดไป",
"Scroll Half Page Down": "เลื่อนลงครึ่งหน้า",
"Scroll Half Page Up": "เลื่อนขึ้นครึ่งหน้า",
"Save Note": "บันทึกโน้ต",
"Single Section Scroll": "เลื่อนทีละส่วน",
"General": "ทั่วไป",
"Text to Speech": "อ่านออกเสียง",
"Zoom": "ซูม",
"Window": "หน้าต่าง",
"Your Bookshelf": "ชั้นหนังสือของคุณ",
"TTS": "อ่านออกเสียง",
"Media Info": "ข้อมูลสื่อ",
"Update Frequency": "ความถี่ในการอัปเดต",
"Every Sentence": "ทุกประโยค",
"Every Paragraph": "ทุกย่อหน้า",
"Every Chapter": "ทุกบท",
"TTS Media Info Update Frequency": "ความถี่ในการอัปเดตข้อมูลสื่อ TTS",
"Select reading speed": "เลือกความเร็วในการอ่าน",
"Drag to seek": "ลากเพื่อค้นหา",
"Punctuation Delay": "หน่วงเวลาเครื่องหมายวรรคตอน",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "กรุณายืนยันว่าการเชื่อมต่อ OPDS นี้จะถูกส่งผ่านเซิร์ฟเวอร์ Readest บนเว็บแอปก่อนดำเนินการต่อ",
"Custom Headers": "ส่วนหัวที่กำหนดเอง",
"Custom Headers (optional)": "ส่วนหัวที่กำหนดเอง (ไม่บังคับ)",
"Add one header per line using \"Header-Name: value\".": "เพิ่มส่วนหัวหนึ่งรายการต่อบรรทัดโดยใช้ \"Header-Name: value\"",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "ฉันเข้าใจว่าการเชื่อมต่อ OPDS นี้จะถูกส่งผ่านเซิร์ฟเวอร์ Readest บนเว็บแอป หากฉันไม่ไว้วางใจ Readest กับข้อมูลรับรองหรือส่วนหัวเหล่านี้ ฉันควรใช้แอปเนทีฟแทน",
"Unable to connect to Hardcover. Please check your network connection.": "ไม่สามารถเชื่อมต่อกับ Hardcover ได้ กรุณาตรวจสอบการเชื่อมต่อเครือข่ายของคุณ",
"Invalid Hardcover API token": "โทเค็น API ของ Hardcover ไม่ถูกต้อง",
"Disconnected from Hardcover": "ตัดการเชื่อมต่อจาก Hardcover แล้ว",
"Hardcover Settings": "การตั้งค่า Hardcover",
"Connected to Hardcover": "เชื่อมต่อกับ Hardcover แล้ว",
"Connect your Hardcover account to sync reading progress and notes.": "เชื่อมต่อบัญชี Hardcover ของคุณเพื่อซิงค์ความคืบหน้าการอ่านและบันทึก",
"Get your API token from hardcover.app → Settings → API.": "รับโทเค็น API ของคุณจาก hardcover.app → การตั้งค่า → API",
"API Token": "โทเค็น API",
"Paste your Hardcover API token": "วางโทเค็น API ของ Hardcover ของคุณ",
"Hardcover sync enabled for this book": "เปิดใช้งานการซิงค์ Hardcover สำหรับหนังสือนี้แล้ว",
"Hardcover sync disabled for this book": "ปิดใช้งานการซิงค์ Hardcover สำหรับหนังสือนี้แล้ว",
"Hardcover Sync": "การซิงค์ Hardcover",
"Enable for This Book": "เปิดใช้งานสำหรับหนังสือนี้",
"Push Notes": "ส่งบันทึก",
"Enable Hardcover sync for this book first.": "เปิดใช้งานการซิงค์ Hardcover สำหรับหนังสือนี้ก่อน",
"No annotations or excerpts to sync for this book.": "ไม่มีคำอธิบายประกอบหรือข้อความคัดลอกที่จะซิงค์สำหรับหนังสือนี้",
"Configure Hardcover in Settings first.": "กำหนดค่า Hardcover ในการตั้งค่าก่อน",
"No new Hardcover note changes to sync.": "ไม่มีการเปลี่ยนแปลงบันทึก Hardcover ใหม่ที่จะซิงค์",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "ซิงค์ Hardcover แล้ว: {{inserted}} รายการใหม่, {{updated}} อัปเดต, {{skipped}} ไม่เปลี่ยนแปลง",
"Hardcover notes sync failed: {{error}}": "การซิงค์บันทึก Hardcover ล้มเหลว: {{error}}",
"Reading progress synced to Hardcover": "ความคืบหน้าการอ่านซิงค์กับ Hardcover แล้ว",
"Hardcover progress sync failed: {{error}}": "การซิงค์ความคืบหน้า Hardcover ล้มเหลว: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "เก็บตัวระบุแหล่งที่มาที่มีอยู่",
"Toggle Toolbar": "สลับแถบเครื่องมือ",
"Split Hyphens": "แยกยัติภังค์",
"Apply Theme Colors to PDF": "ใช้สีธีมกับ PDF",
"Name": "ชื่อ",
"Unavailable": "ไม่พร้อมใช้งาน",
"Remove": "ลบ",
"Edit OPDS Catalog": "แก้ไขแคตตาล็อก OPDS",
"Save Changes": "บันทึกการเปลี่ยนแปลง",
"Use Book Layout": "ใช้เลย์เอาต์ของหนังสือ"
}
@@ -184,7 +184,6 @@
"RTL Direction": "RTL Yönü",
"Maximum Column Height": "Maksimum Sütun Yüksekliği",
"Maximum Column Width": "Maksimum Sütun Genişliği",
"Continuous Scroll": "Sürekli Kaydırma",
"Fullscreen": "Tam Ekran",
"No supported files found. Supported formats: {{formats}}": "Desteklenen dosya bulunamadı. Desteklenen formatlar: {{formats}}",
"Drop to Import Books": "Kitapları İçe Aktarmak İçin Bırak",
@@ -983,7 +982,6 @@
"words": "kelime",
"{{time}} left": "{{time}} kaldı",
"Reading progress": "Okuma ilerlemesi",
"Click to seek": "Konum aramak için tıkla",
"Skip back 15 words": "15 kelime geri atla",
"Back 15 words (Shift+Left)": "15 kelime geri (Shift+Sol)",
"Pause (Space)": "Duraklat (Boşluk)",
@@ -1102,5 +1100,96 @@
"{{count}} books refreshed_one": "{{count}} kitap yenilendi",
"{{count}} books refreshed_other": "{{count}} kitap yenilendi",
"Failed to refresh metadata": "Meta veri yenileme başarısız",
"Refresh Metadata": "Meta Verileri Yenile"
"Refresh Metadata": "Meta Verileri Yenile",
"Keyboard Shortcuts": "Klavye Kısayolları",
"View all keyboard shortcuts": "Tüm klavye kısayollarını görüntüle",
"Switch Sidebar Tab": "Kenar çubuğu sekmesini değiştir",
"Toggle Notebook": "Not defterini göster/gizle",
"Search in Book": "Kitapta ara",
"Toggle Scroll Mode": "Kaydırma modunu değiştir",
"Toggle Select Mode": "Seçim modunu değiştir",
"Toggle Bookmark": "Yer imini değiştir",
"Toggle Text to Speech": "Metinden konuşmayı değiştir",
"Play / Pause TTS": "TTS Oynat / Duraklat",
"Toggle Paragraph Mode": "Paragraf modunu değiştir",
"Highlight Selection": "Seçimi vurgula",
"Underline Selection": "Seçimin altını çiz",
"Annotate Selection": "Seçime not ekle",
"Search Selection": "Seçimi ara",
"Copy Selection": "Seçimi kopyala",
"Translate Selection": "Seçimi çevir",
"Dictionary Lookup": "Sözlükte ara",
"Wikipedia Lookup": "Wikipedia'da ara",
"Read Aloud Selection": "Seçimi sesli oku",
"Proofread Selection": "Seçimi düzelt",
"Open Settings": "Ayarları aç",
"Open Command Palette": "Komut paletini aç",
"Show Keyboard Shortcuts": "Klavye kısayollarını göster",
"Open Books": "Kitapları aç",
"Toggle Fullscreen": "Tam ekranı değiştir",
"Close Window": "Pencereyi kapat",
"Quit App": "Uygulamadan çık",
"Go Left / Previous Page": "Sola git / Önceki sayfa",
"Go Right / Next Page": "Sağa git / Sonraki sayfa",
"Go Up": "Yukarı",
"Go Down": "Aşağı",
"Previous Chapter": "Önceki bölüm",
"Next Chapter": "Sonraki bölüm",
"Scroll Half Page Down": "Yarım sayfa aşağı kaydır",
"Scroll Half Page Up": "Yarım sayfa yukarı kaydır",
"Save Note": "Notu kaydet",
"Single Section Scroll": "Tek Bölüm Kaydırma",
"General": "Genel",
"Text to Speech": "Metinden konuşmaya",
"Zoom": "Yakınlaştırma",
"Window": "Pencere",
"Your Bookshelf": "Kitaplığınız",
"TTS": "Sesli Okuma",
"Media Info": "Medya Bilgisi",
"Update Frequency": "Güncelleme Sıklığı",
"Every Sentence": "Her Cümle",
"Every Paragraph": "Her Paragraf",
"Every Chapter": "Her Bölüm",
"TTS Media Info Update Frequency": "TTS Medya Bilgisi Güncelleme Sıklığı",
"Select reading speed": "Okuma hızını seçin",
"Drag to seek": "Aramak için sürükleyin",
"Punctuation Delay": "Noktalama gecikmesi",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Devam etmeden önce bu OPDS bağlantısının web uygulamasında Readest sunucuları üzerinden yönlendirileceğini onaylayın.",
"Custom Headers": "Özel Başlıklar",
"Custom Headers (optional)": "Özel Başlıklar (isteğe bağlı)",
"Add one header per line using \"Header-Name: value\".": "\"Header-Name: value\" biçimini kullanarak her satıra bir başlık ekleyin.",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Bu OPDS bağlantısının web uygulamasında Readest sunucuları üzerinden yönlendirileceğini anlıyorum. Bu kimlik bilgileri veya başlıklar konusunda Readest'e güvenmiyorsam, bunun yerine yerel uygulamayı kullanmalıyım.",
"Unable to connect to Hardcover. Please check your network connection.": "Hardcover'a bağlanılamıyor. Lütfen ağ bağlantınızı kontrol edin.",
"Invalid Hardcover API token": "Geçersiz Hardcover API belirteci",
"Disconnected from Hardcover": "Hardcover bağlantısı kesildi",
"Hardcover Settings": "Hardcover Ayarları",
"Connected to Hardcover": "Hardcover'a bağlandı",
"Connect your Hardcover account to sync reading progress and notes.": "Okuma ilerlemenizi ve notlarınızı senkronize etmek için Hardcover hesabınızı bağlayın.",
"Get your API token from hardcover.app → Settings → API.": "API belirtecinizi hardcover.app → Ayarlar → API'den alın.",
"API Token": "API Belirteci",
"Paste your Hardcover API token": "Hardcover API belirtecinizi yapıştırın",
"Hardcover sync enabled for this book": "Bu kitap için Hardcover senkronizasyonu etkinleştirildi",
"Hardcover sync disabled for this book": "Bu kitap için Hardcover senkronizasyonu devre dışı bırakıldı",
"Hardcover Sync": "Hardcover Senkronizasyonu",
"Enable for This Book": "Bu Kitap İçin Etkinleştir",
"Push Notes": "Notları Gönder",
"Enable Hardcover sync for this book first.": "Önce bu kitap için Hardcover senkronizasyonunu etkinleştirin.",
"No annotations or excerpts to sync for this book.": "Bu kitap için senkronize edilecek açıklama veya alıntı yok.",
"Configure Hardcover in Settings first.": "Önce Ayarlar'da Hardcover'ı yapılandırın.",
"No new Hardcover note changes to sync.": "Senkronize edilecek yeni Hardcover not değişikliği yok.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover senkronize edildi: {{inserted}} yeni, {{updated}} güncellendi, {{skipped}} değişmedi",
"Hardcover notes sync failed: {{error}}": "Hardcover not senkronizasyonu başarısız: {{error}}",
"Reading progress synced to Hardcover": "Okuma ilerlemesi Hardcover ile senkronize edildi",
"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",
"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"
}
@@ -184,7 +184,6 @@
"RTL Direction": "Напрямок RTL",
"Maximum Column Height": "Максимальна висота колонки",
"Maximum Column Width": "Максимальна ширина колонки",
"Continuous Scroll": "Постійна прокрутка",
"Fullscreen": "Повноекранний",
"No supported files found. Supported formats: {{formats}}": "Не знайдено підтримуваних файлів. Підтримувані формати: {{formats}}",
"Drop to Import Books": "Перетягніть для імпорту книг",
@@ -1007,7 +1006,6 @@
"words": "слів",
"{{time}} left": "залишилося {{time}}",
"Reading progress": "Прогрес читання",
"Click to seek": "Натисніть для пошуку",
"Skip back 15 words": "Назад на 15 слів",
"Back 15 words (Shift+Left)": "Назад на 15 слів (Shift+Стрілка вліво)",
"Pause (Space)": "Пауза (Пробіл)",
@@ -1128,5 +1126,96 @@
"{{count}} books refreshed_many": "Оновлено {{count}} книг",
"{{count}} books refreshed_other": "Оновлено {{count}} книг",
"Failed to refresh metadata": "Не вдалося оновити метадані",
"Refresh Metadata": "Оновити метадані"
"Refresh Metadata": "Оновити метадані",
"Keyboard Shortcuts": "Комбінації клавіш",
"View all keyboard shortcuts": "Переглянути всі комбінації клавіш",
"Switch Sidebar Tab": "Перемкнути вкладку бічної панелі",
"Toggle Notebook": "Показати/сховати блокнот",
"Search in Book": "Пошук у книзі",
"Toggle Scroll Mode": "Перемкнути режим прокрутки",
"Toggle Select Mode": "Перемкнути режим виділення",
"Toggle Bookmark": "Перемкнути закладку",
"Toggle Text to Speech": "Перемкнути озвучення тексту",
"Play / Pause TTS": "Відтворити / Пауза TTS",
"Toggle Paragraph Mode": "Перемкнути режим абзацу",
"Highlight Selection": "Виділити вибране",
"Underline Selection": "Підкреслити вибране",
"Annotate Selection": "Додати примітку до вибраного",
"Search Selection": "Шукати вибране",
"Copy Selection": "Копіювати вибране",
"Translate Selection": "Перекласти вибране",
"Dictionary Lookup": "Пошук у словнику",
"Wikipedia Lookup": "Пошук у Вікіпедії",
"Read Aloud Selection": "Прочитати вибране вголос",
"Proofread Selection": "Перевірити вибране",
"Open Settings": "Відкрити налаштування",
"Open Command Palette": "Відкрити палітру команд",
"Show Keyboard Shortcuts": "Показати комбінації клавіш",
"Open Books": "Відкрити книги",
"Toggle Fullscreen": "Перемкнути повноекранний режим",
"Close Window": "Закрити вікно",
"Quit App": "Вийти з програми",
"Go Left / Previous Page": "Вліво / Попередня сторінка",
"Go Right / Next Page": "Вправо / Наступна сторінка",
"Go Up": "Вгору",
"Go Down": "Вниз",
"Previous Chapter": "Попередній розділ",
"Next Chapter": "Наступний розділ",
"Scroll Half Page Down": "Прокрутити на півсторінки вниз",
"Scroll Half Page Up": "Прокрутити на півсторінки вгору",
"Save Note": "Зберегти нотатку",
"Single Section Scroll": "Прокрутка одного розділу",
"General": "Загальне",
"Text to Speech": "Озвучення",
"Zoom": "Масштаб",
"Window": "Вікно",
"Your Bookshelf": "Ваша книжкова полиця",
"TTS": "Озвучування",
"Media Info": "Медіа-інформація",
"Update Frequency": "Частота оновлення",
"Every Sentence": "Кожне речення",
"Every Paragraph": "Кожен абзац",
"Every Chapter": "Кожен розділ",
"TTS Media Info Update Frequency": "Частота оновлення медіа-інформації TTS",
"Select reading speed": "Виберіть швидкість читання",
"Drag to seek": "Перетягніть для пошуку",
"Punctuation Delay": "Затримка на розділових знаках",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Підтвердіть, що це з'єднання OPDS буде проксовано через сервери Readest у веб-додатку, перш ніж продовжити.",
"Custom Headers": "Користувацькі заголовки",
"Custom Headers (optional)": "Користувацькі заголовки (необов'язково)",
"Add one header per line using \"Header-Name: value\".": "Додайте один заголовок на рядок у форматі \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Я розумію, що це з'єднання OPDS буде проксовано через сервери Readest у веб-додатку. Якщо я не довіряю Readest свої облікові дані або заголовки, мені слід використовувати рідний додаток.",
"Unable to connect to Hardcover. Please check your network connection.": "Не вдалося підключитися до Hardcover. Перевірте мережеве з'єднання.",
"Invalid Hardcover API token": "Недійсний API-токен Hardcover",
"Disconnected from Hardcover": "Від'єднано від Hardcover",
"Hardcover Settings": "Налаштування Hardcover",
"Connected to Hardcover": "Підключено до Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Підключіть свій обліковий запис Hardcover для синхронізації прогресу читання та нотаток.",
"Get your API token from hardcover.app → Settings → API.": "Отримайте API-токен на hardcover.app → Налаштування → API.",
"API Token": "API-токен",
"Paste your Hardcover API token": "Вставте ваш API-токен Hardcover",
"Hardcover sync enabled for this book": "Синхронізацію Hardcover увімкнено для цієї книги",
"Hardcover sync disabled for this book": "Синхронізацію Hardcover вимкнено для цієї книги",
"Hardcover Sync": "Синхронізація Hardcover",
"Enable for This Book": "Увімкнути для цієї книги",
"Push Notes": "Надіслати нотатки",
"Enable Hardcover sync for this book first.": "Спочатку увімкніть синхронізацію Hardcover для цієї книги.",
"No annotations or excerpts to sync for this book.": "Немає анотацій або витягів для синхронізації для цієї книги.",
"Configure Hardcover in Settings first.": "Спочатку налаштуйте Hardcover у параметрах.",
"No new Hardcover note changes to sync.": "Немає нових змін нотаток Hardcover для синхронізації.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover синхронізовано: {{inserted}} нових, {{updated}} оновлених, {{skipped}} без змін",
"Hardcover notes sync failed: {{error}}": "Помилка синхронізації нотаток Hardcover: {{error}}",
"Reading progress synced to Hardcover": "Прогрес читання синхронізовано з Hardcover",
"Hardcover progress sync failed: {{error}}": "Помилка синхронізації прогресу Hardcover: {{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "Зберегти наявний ідентифікатор джерела",
"Toggle Toolbar": "Перемкнути панель інструментів",
"Split Hyphens": "Розділити дефіси",
"Apply Theme Colors to PDF": "Застосувати кольори теми до PDF",
"Name": "Ім'я",
"Unavailable": "Недоступно",
"Remove": "Видалити",
"Edit OPDS Catalog": "Редагувати каталог OPDS",
"Save Changes": "Зберегти зміни",
"Use Book Layout": "Використовувати макет книги"
}
@@ -184,7 +184,6 @@
"RTL Direction": "Hướng RTL",
"Maximum Column Height": "Chiều cao cột tối đa",
"Maximum Column Width": "Chiều rộng cột tối đa",
"Continuous Scroll": "Cuộn liên tục",
"Fullscreen": "Toàn màn hình",
"No supported files found. Supported formats: {{formats}}": "Không tìm thấy tệp được hỗ trợ. Định dạng được hỗ trợ: {{formats}}",
"Drop to Import Books": "Kéo và thả để nhập sách",
@@ -971,7 +970,6 @@
"words": "từ",
"{{time}} left": "còn lại {{time}}",
"Reading progress": "Tiến độ đọc",
"Click to seek": "Nhấp để tua",
"Skip back 15 words": "Lùi lại 15 từ",
"Back 15 words (Shift+Left)": "Lùi lại 15 từ (Shift+Trái)",
"Pause (Space)": "Tạm dừng (Khoảng trắng)",
@@ -1089,5 +1087,96 @@
"Loading library...": "Đang tải thư viện...",
"{{count}} books refreshed_other": "Đã làm mới {{count}} sách",
"Failed to refresh metadata": "Không thể làm mới siêu dữ liệu",
"Refresh Metadata": "Làm mới siêu dữ liệu"
"Refresh Metadata": "Làm mới siêu dữ liệu",
"Keyboard Shortcuts": "Phím tắt",
"View all keyboard shortcuts": "Xem tất cả phím tắt",
"Switch Sidebar Tab": "Chuyển tab thanh bên",
"Toggle Notebook": "Hiện/ẩn sổ ghi chú",
"Search in Book": "Tìm kiếm trong sách",
"Toggle Scroll Mode": "Chuyển chế độ cuộn",
"Toggle Select Mode": "Chuyển chế độ chọn",
"Toggle Bookmark": "Bật/tắt đánh dấu",
"Toggle Text to Speech": "Bật/tắt đọc văn bản",
"Play / Pause TTS": "Phát / Tạm dừng TTS",
"Toggle Paragraph Mode": "Chuyển chế độ đoạn văn",
"Highlight Selection": "Tô sáng vùng chọn",
"Underline Selection": "Gạch chân vùng chọn",
"Annotate Selection": "Thêm ghi chú cho vùng chọn",
"Search Selection": "Tìm kiếm vùng chọn",
"Copy Selection": "Sao chép vùng chọn",
"Translate Selection": "Dịch vùng chọn",
"Dictionary Lookup": "Tra từ điển",
"Wikipedia Lookup": "Tra cứu Wikipedia",
"Read Aloud Selection": "Đọc to vùng chọn",
"Proofread Selection": "Kiểm tra vùng chọn",
"Open Settings": "Mở cài đặt",
"Open Command Palette": "Mở bảng lệnh",
"Show Keyboard Shortcuts": "Hiển thị phím tắt",
"Open Books": "Mở sách",
"Toggle Fullscreen": "Chuyển toàn màn hình",
"Close Window": "Đóng cửa sổ",
"Quit App": "Thoát ứng dụng",
"Go Left / Previous Page": "Sang trái / Trang trước",
"Go Right / Next Page": "Sang phải / Trang tiếp",
"Go Up": "Lên trên",
"Go Down": "Xuống dưới",
"Previous Chapter": "Chương trước",
"Next Chapter": "Chương tiếp",
"Scroll Half Page Down": "Cuộn xuống nửa trang",
"Scroll Half Page Up": "Cuộn lên nửa trang",
"Save Note": "Lưu ghi chú",
"Single Section Scroll": "Cuộn từng phần",
"General": "Chung",
"Text to Speech": "Chuyển văn bản thành giọng nói",
"Zoom": "Thu phóng",
"Window": "Cửa sổ",
"Your Bookshelf": "Tủ sách của bạn",
"TTS": "Đọc văn bản",
"Media Info": "Thông tin phương tiện",
"Update Frequency": "Tần suất cập nhật",
"Every Sentence": "Mỗi câu",
"Every Paragraph": "Mỗi đoạn văn",
"Every Chapter": "Mỗi chương",
"TTS Media Info Update Frequency": "Tần suất cập nhật thông tin phương tiện TTS",
"Select reading speed": "Chọn tốc độ đọc",
"Drag to seek": "Kéo để tìm kiếm",
"Punctuation Delay": "Độ trễ dấu câu",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Vui lòng xác nhận rằng kết nối OPDS này sẽ được chuyển tiếp qua máy chủ Readest trên ứng dụng web trước khi tiếp tục.",
"Custom Headers": "Tiêu đề tùy chỉnh",
"Custom Headers (optional)": "Tiêu đề tùy chỉnh (tùy chọn)",
"Add one header per line using \"Header-Name: value\".": "Thêm một tiêu đề mỗi dòng sử dụng \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Tôi hiểu rằng kết nối OPDS này sẽ được chuyển tiếp qua máy chủ Readest trên ứng dụng web. Nếu tôi không tin tưởng Readest với các thông tin đăng nhập hoặc tiêu đề này, tôi nên sử dụng ứng dụng gốc.",
"Unable to connect to Hardcover. Please check your network connection.": "Không thể kết nối với Hardcover. Vui lòng kiểm tra kết nối mạng của bạn.",
"Invalid Hardcover API token": "Token API Hardcover không hợp lệ",
"Disconnected from Hardcover": "Đã ngắt kết nối khỏi Hardcover",
"Hardcover Settings": "Cài đặt Hardcover",
"Connected to Hardcover": "Đã kết nối với Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Kết nối tài khoản Hardcover của bạn để đồng bộ tiến trình đọc và ghi chú.",
"Get your API token from hardcover.app → Settings → API.": "Lấy token API của bạn từ hardcover.app → Cài đặt → API.",
"API Token": "Token API",
"Paste your Hardcover API token": "Dán token API Hardcover của bạn",
"Hardcover sync enabled for this book": "Đồng bộ Hardcover đã được bật cho cuốn sách này",
"Hardcover sync disabled for this book": "Đồng bộ Hardcover đã được tắt cho cuốn sách này",
"Hardcover Sync": "Đồng bộ Hardcover",
"Enable for This Book": "Bật cho cuốn sách này",
"Push Notes": "Gửi ghi chú",
"Enable Hardcover sync for this book first.": "Bật đồng bộ Hardcover cho cuốn sách này trước.",
"No annotations or excerpts to sync for this book.": "Không có chú thích hoặc trích đoạn nào để đồng bộ cho cuốn sách này.",
"Configure Hardcover in Settings first.": "Cấu hình Hardcover trong Cài đặt trước.",
"No new Hardcover note changes to sync.": "Không có thay đổi ghi chú Hardcover mới để đồng bộ.",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Đồng bộ Hardcover: {{inserted}} mới, {{updated}} đã cập nhật, {{skipped}} không đổi",
"Hardcover notes sync failed: {{error}}": "Đồng bộ ghi chú Hardcover thất bại: {{error}}",
"Reading progress synced to Hardcover": "Tiến trình đọc đã đồng bộ với Hardcover",
"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ụ",
"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"
}
@@ -185,7 +185,6 @@
"RTL Direction": "从右向左",
"Maximum Column Height": "最大列高",
"Maximum Column Width": "最大列宽",
"Continuous Scroll": "连续滚动",
"Fullscreen": "全屏显示",
"No supported files found. Supported formats: {{formats}}": "未找到支持的文件。支持的格式:{{formats}}",
"Drop to Import Books": "拖放导入书籍",
@@ -972,7 +971,6 @@
"words": "词",
"{{time}} left": "剩余 {{time}}",
"Reading progress": "阅读进度",
"Click to seek": "点击跳转",
"Skip back 15 words": "回退 15 词",
"Back 15 words (Shift+Left)": "回退 15 词 (Shift+Left)",
"Pause (Space)": "暂停 (空格)",
@@ -1089,5 +1087,96 @@
"Loading library...": "正在加载书库...",
"{{count}} books refreshed_other": "已刷新 {{count}} 本书",
"Failed to refresh metadata": "刷新元数据失败",
"Refresh Metadata": "刷新元数据"
"Refresh Metadata": "刷新元数据",
"Keyboard Shortcuts": "键盘快捷键",
"View all keyboard shortcuts": "查看所有键盘快捷键",
"Switch Sidebar Tab": "切换侧边栏标签",
"Toggle Notebook": "显示/隐藏笔记本",
"Search in Book": "在书中搜索",
"Toggle Scroll Mode": "切换滚动模式",
"Toggle Select Mode": "切换选择模式",
"Toggle Bookmark": "切换书签",
"Toggle Text to Speech": "切换文字转语音",
"Play / Pause TTS": "播放 / 暂停朗读",
"Toggle Paragraph Mode": "切换段落模式",
"Highlight Selection": "高亮选中内容",
"Underline Selection": "为选中内容添加下划线",
"Annotate Selection": "为选中内容添加注释",
"Search Selection": "搜索选中内容",
"Copy Selection": "复制选中内容",
"Translate Selection": "翻译选中内容",
"Dictionary Lookup": "词典查询",
"Wikipedia Lookup": "维基百科查询",
"Read Aloud Selection": "朗读选中内容",
"Proofread Selection": "校对选中内容",
"Open Settings": "打开设置",
"Open Command Palette": "打开命令面板",
"Show Keyboard Shortcuts": "显示键盘快捷键",
"Open Books": "打开书库",
"Toggle Fullscreen": "切换全屏",
"Close Window": "关闭窗口",
"Quit App": "退出应用",
"Go Left / Previous Page": "向左 / 上一页",
"Go Right / Next Page": "向右 / 下一页",
"Go Up": "向上",
"Go Down": "向下",
"Previous Chapter": "上一章",
"Next Chapter": "下一章",
"Scroll Half Page Down": "向下滚动半页",
"Scroll Half Page Up": "向上滚动半页",
"Save Note": "保存笔记",
"Single Section Scroll": "单章节滚动",
"General": "常规",
"Text to Speech": "文字转语音",
"Zoom": "缩放",
"Window": "窗口",
"Your Bookshelf": "你的书架",
"TTS": "语音朗读",
"Media Info": "媒体信息",
"Update Frequency": "更新频率",
"Every Sentence": "每句",
"Every Paragraph": "每段",
"Every Chapter": "每章",
"TTS Media Info Update Frequency": "TTS 媒体信息更新频率",
"Select reading speed": "选择朗读速度",
"Drag to seek": "拖动以定位",
"Punctuation Delay": "标点符号停顿",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "继续前请确认此 OPDS 连接将通过网页应用中的 Readest 服务器进行代理。",
"Custom Headers": "自定义标头",
"Custom Headers (optional)": "自定义标头(可选)",
"Add one header per line using \"Header-Name: value\".": "每行添加一个标头,格式为 \"Header-Name: value\"。",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "我了解此 OPDS 连接将通过网页应用中的 Readest 服务器进行代理。如果我不信任 Readest 处理这些凭据或标头,我应该使用原生应用。",
"Unable to connect to Hardcover. Please check your network connection.": "无法连接到 Hardcover。请检查您的网络连接。",
"Invalid Hardcover API token": "无效的 Hardcover API 令牌",
"Disconnected from Hardcover": "已断开与 Hardcover 的连接",
"Hardcover Settings": "Hardcover 设置",
"Connected to Hardcover": "已连接到 Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "连接您的 Hardcover 账户以同步阅读进度和笔记。",
"Get your API token from hardcover.app → Settings → API.": "从 hardcover.app → 设置 → API 获取您的 API 令牌。",
"API Token": "API 令牌",
"Paste your Hardcover API token": "粘贴您的 Hardcover API 令牌",
"Hardcover sync enabled for this book": "已为此书启用 Hardcover 同步",
"Hardcover sync disabled for this book": "已为此书禁用 Hardcover 同步",
"Hardcover Sync": "Hardcover 同步",
"Enable for This Book": "为此书启用",
"Push Notes": "推送笔记",
"Enable Hardcover sync for this book first.": "请先为此书启用 Hardcover 同步。",
"No annotations or excerpts to sync for this book.": "此书没有可同步的注释或摘录。",
"Configure Hardcover in Settings first.": "请先在设置中配置 Hardcover。",
"No new Hardcover note changes to sync.": "没有新的 Hardcover 笔记更改需要同步。",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover 已同步:{{inserted}} 个新增、{{updated}} 个更新、{{skipped}} 个未变",
"Hardcover notes sync failed: {{error}}": "Hardcover 笔记同步失败:{{error}}",
"Reading progress synced to Hardcover": "阅读进度已同步到 Hardcover",
"Hardcover progress sync failed: {{error}}": "Hardcover 进度同步失败:{{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "保留现有来源标识符",
"Toggle Toolbar": "切换工具栏",
"Split Hyphens": "拆分连字符",
"Apply Theme Colors to PDF": "将主题颜色应用于 PDF",
"Name": "名称",
"Unavailable": "不可用",
"Remove": "移除",
"Edit OPDS Catalog": "编辑 OPDS 目录",
"Save Changes": "保存更改",
"Use Book Layout": "使用书籍排版"
}
@@ -184,7 +184,6 @@
"RTL Direction": "從右向左",
"Maximum Column Height": "最大列高",
"Maximum Column Width": "最大列寬",
"Continuous Scroll": "連續滾動",
"Fullscreen": "全螢幕",
"No supported files found. Supported formats: {{formats}}": "未找到支援的檔案。支援的格式:{{formats}}",
"Drop to Import Books": "拖放導入書籍",
@@ -971,7 +970,6 @@
"words": "詞",
"{{time}} left": "剩餘 {{time}}",
"Reading progress": "閱讀進度",
"Click to seek": "點擊跳轉",
"Skip back 15 words": "回退 15 詞",
"Back 15 words (Shift+Left)": "回退 15 詞 (Shift+Left)",
"Pause (Space)": "暫停 (空格)",
@@ -1089,5 +1087,96 @@
"Loading library...": "正在載入書庫...",
"{{count}} books refreshed_other": "已重新整理 {{count}} 本書",
"Failed to refresh metadata": "重新整理中繼資料失敗",
"Refresh Metadata": "重新整理中繼資料"
"Refresh Metadata": "重新整理中繼資料",
"Keyboard Shortcuts": "鍵盤快捷鍵",
"View all keyboard shortcuts": "查看所有鍵盤快捷鍵",
"Switch Sidebar Tab": "切換側邊欄標籤",
"Toggle Notebook": "顯示/隱藏筆記本",
"Search in Book": "在書中搜尋",
"Toggle Scroll Mode": "切換捲動模式",
"Toggle Select Mode": "切換選取模式",
"Toggle Bookmark": "切換書籤",
"Toggle Text to Speech": "切換文字轉語音",
"Play / Pause TTS": "播放 / 暫停朗讀",
"Toggle Paragraph Mode": "切換段落模式",
"Highlight Selection": "醒目標示選取內容",
"Underline Selection": "為選取內容加底線",
"Annotate Selection": "為選取內容加註釋",
"Search Selection": "搜尋選取內容",
"Copy Selection": "複製選取內容",
"Translate Selection": "翻譯選取內容",
"Dictionary Lookup": "字典查詢",
"Wikipedia Lookup": "維基百科查詢",
"Read Aloud Selection": "朗讀選取內容",
"Proofread Selection": "校對選取內容",
"Open Settings": "開啟設定",
"Open Command Palette": "開啟指令面板",
"Show Keyboard Shortcuts": "顯示鍵盤快捷鍵",
"Open Books": "開啟書庫",
"Toggle Fullscreen": "切換全螢幕",
"Close Window": "關閉視窗",
"Quit App": "結束應用程式",
"Go Left / Previous Page": "向左 / 上一頁",
"Go Right / Next Page": "向右 / 下一頁",
"Go Up": "向上",
"Go Down": "向下",
"Previous Chapter": "上一章",
"Next Chapter": "下一章",
"Scroll Half Page Down": "向下捲動半頁",
"Scroll Half Page Up": "向上捲動半頁",
"Save Note": "儲存筆記",
"Single Section Scroll": "單章節捲動",
"General": "一般",
"Text to Speech": "文字轉語音",
"Zoom": "縮放",
"Window": "視窗",
"Your Bookshelf": "你的書架",
"TTS": "語音朗讀",
"Media Info": "媒體資訊",
"Update Frequency": "更新頻率",
"Every Sentence": "每句",
"Every Paragraph": "每段",
"Every Chapter": "每章",
"TTS Media Info Update Frequency": "TTS 媒體資訊更新頻率",
"Select reading speed": "選擇朗讀速度",
"Drag to seek": "拖曳以定位",
"Punctuation Delay": "標點符號停頓",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "繼續前請確認此 OPDS 連線將透過網頁應用程式中的 Readest 伺服器進行代理。",
"Custom Headers": "自訂標頭",
"Custom Headers (optional)": "自訂標頭(選填)",
"Add one header per line using \"Header-Name: value\".": "每行新增一個標頭,格式為 \"Header-Name: value\"。",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "我了解此 OPDS 連線將透過網頁應用程式中的 Readest 伺服器進行代理。若我不信任 Readest 處理這些憑證或標頭,我應該使用原生應用程式。",
"Unable to connect to Hardcover. Please check your network connection.": "無法連線到 Hardcover。請檢查您的網路連線。",
"Invalid Hardcover API token": "無效的 Hardcover API 權杖",
"Disconnected from Hardcover": "已中斷與 Hardcover 的連線",
"Hardcover Settings": "Hardcover 設定",
"Connected to Hardcover": "已連線到 Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "連結您的 Hardcover 帳戶以同步閱讀進度和筆記。",
"Get your API token from hardcover.app → Settings → API.": "從 hardcover.app → 設定 → API 取得您的 API 權杖。",
"API Token": "API 權杖",
"Paste your Hardcover API token": "貼上您的 Hardcover API 權杖",
"Hardcover sync enabled for this book": "已為此書啟用 Hardcover 同步",
"Hardcover sync disabled for this book": "已為此書停用 Hardcover 同步",
"Hardcover Sync": "Hardcover 同步",
"Enable for This Book": "為此書啟用",
"Push Notes": "推送筆記",
"Enable Hardcover sync for this book first.": "請先為此書啟用 Hardcover 同步。",
"No annotations or excerpts to sync for this book.": "此書沒有可同步的註釋或摘錄。",
"Configure Hardcover in Settings first.": "請先在設定中配置 Hardcover。",
"No new Hardcover note changes to sync.": "沒有新的 Hardcover 筆記變更需要同步。",
"Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged": "Hardcover 已同步:{{inserted}} 個新增、{{updated}} 個更新、{{skipped}} 個未變",
"Hardcover notes sync failed: {{error}}": "Hardcover 筆記同步失敗:{{error}}",
"Reading progress synced to Hardcover": "閱讀進度已同步到 Hardcover",
"Hardcover progress sync failed: {{error}}": "Hardcover 進度同步失敗:{{error}}",
"ISBN": "ISBN",
"Keep existing source identifier": "保留現有來源識別碼",
"Toggle Toolbar": "切換工具列",
"Split Hyphens": "拆分連字號",
"Apply Theme Colors to PDF": "將主題顏色套用至 PDF",
"Name": "名稱",
"Unavailable": "無法使用",
"Remove": "移除",
"Edit OPDS Catalog": "編輯 OPDS 目錄",
"Save Changes": "儲存變更",
"Use Book Layout": "使用書籍排版"
}
+56
View File
@@ -1,5 +1,61 @@
{
"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": [
"Annotations: You can now name your highlight colors, and your custom names sync across devices",
"Annotations: Fixed highlights that stayed on the page after being deleted from the sidebar",
"Speed Reading: Added an option to split long words, a faster countdown",
"Text-to-Speech: Fixed double playback when tapping the play button in quick succession",
"Accessibility: Dropdown menus now open reliably when activated with the keyboard",
"Reading: Background textures now display correctly when you override the theme background color",
"Translation: Temporarily disabled the Yandex provider while its service is unavailable"
]
},
"0.10.2": {
"date": "2026-04-05",
"notes": [
"Reading: Added single section scroll option, keyboard shortcuts help dialog, and reading ruler improvements",
"Reading: Fixed Adobe font deobfuscation and applied theme colors to PDFs",
"Reading: Fixed paginator freeze, image sizing on resize, and vertical writing mode compatibility",
"Annotations: Fixed annotations grouped under wrong chapter and added highlight opacity setting",
"Text-to-Speech: Added dedicated TTS settings tab with highlight opacity and keyboard shortcuts for navigation",
"Sync: Added Hardcover.app sync integration and improved KOSync progress accuracy",
"KOReader Plugin: Added full annotation sync option and fixed sync-related crashes",
"OPDS: Added custom catalog headers with web proxy consent and fixed feed display issues",
"Speed Reading: Added per-chapter progress bar and speed selector dropdown",
"Android: Added D-pad navigation for Android TV remote controller",
"Theme: Added atmosphere Easter egg with video overlay and ambient audio"
]
},
"0.10.1": {
"date": "2026-03-22",
"notes": [
"Reading: Added continuous scroll and spread layout support for EPUBs",
"Reading: Added current time and battery display in footer",
"Reading: Added TTS, annotation support, pinch-to-zoom, and scroll mode for PDFs",
"Library: Added backup/restore to a zip file and batch metadata refresh",
"KOReader: Added bookmark and annotation sync between KOReader and Readest devices",
"Footnotes: Added back navigation with link history and more responsive popup sizing",
"Annotations: Added loupe display when selecting text in instant annotation mode",
"Text-to-Speech: Fixed TTS reading position tracking in scrolled mode"
]
},
"0.9.101": {
"date": "2026-02-28",
"notes": [
@@ -38,8 +38,9 @@ if grep -q 'MANAGE_EXTERNAL_STORAGE' "$MANIFEST"; then
fi
fi
echo "🚀 Running: pnpm tauri android build"
pnpm tauri android build --config src-tauri/tauri.playstore.conf.json
source .env.google-play.local
echo "🚀 Running: pnpm tauri android build (googleplay flavor)"
ORG_GRADLE_PROJECT_storeFlavor=googleplay pnpm tauri android build --config src-tauri/tauri.playstore.conf.json
# --- ADD PERMISSION BACK AFTER BUILD ---
if ! grep -q 'REQUEST_INSTALL_PACKAGES' "$MANIFEST"; then
@@ -56,11 +57,27 @@ if ! grep -q 'MANAGE_EXTERNAL_STORAGE' "$MANIFEST"; then
" "$MANIFEST"
fi
source .env.google-play.local
if [[ -z "$GOOGLE_PLAY_JSON_KEY_FILE" ]]; then
echo "❌ GOOGLE_PLAY_JSON_KEY_FILE is not set"
exit 1
fi
# --- GENERATE CHANGELOG FOR GOOGLE PLAY ---
CHANGELOG_DIR="../../fastlane/metadata/android/en-US/changelogs"
mkdir -p "$CHANGELOG_DIR"
NOTES=$(jq -r --arg ver "$VERSION" '.releases[$ver].notes // empty | map("• " + .) | join("\n")' release-notes.json)
if [[ -n "$NOTES" ]]; then
# Google Play has a 500-character limit for release notes per language
if [[ ${#NOTES} -gt 480 ]]; then
NOTES="${NOTES:0:477}..."
fi
echo "$NOTES" > "$CHANGELOG_DIR/default.txt"
echo "📝 Release notes for v$VERSION written to changelogs/default.txt"
else
echo "⚠️ No release notes found for v$VERSION in release-notes.json"
fi
cd ../../
fastlane android upload_production
+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"
@@ -29,6 +29,8 @@ android {
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
val storeFlavor = project.findProperty("storeFlavor")?.toString() ?: "foss"
missingDimensionStrategy("store", storeFlavor)
}
signingConfigs {
if (keystorePropertiesFile.exists()) {
@@ -85,9 +87,10 @@ dependencies {
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.lifecycle:lifecycle-process:2.10.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")
apply(from = "tauri.build.gradle.kts")
@@ -30,10 +30,21 @@ android {
kotlinOptions {
jvmTarget = "1.8"
}
flavorDimensions += "store"
productFlavors {
create("foss") {
dimension = "store"
}
create("googleplay") {
dimension = "store"
}
}
}
dependencies {
implementation("com.android.billingclient:billing-ktx:7.1.1")
"googleplayImplementation"("com.android.billingclient:billing-ktx:7.1.1")
"googleplayImplementation"("com.google.android.gms:play-services-base:18.5.0")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.browser:browser:1.8.0")
@@ -0,0 +1,31 @@
package com.readest.native_bridge
import android.app.Activity
import android.util.Log
class BillingManager(private val activity: Activity) {
companion object {
private const val TAG = "BillingManager"
}
fun isBillingAvailable(): Boolean {
return false
}
fun initialize(callback: (Boolean) -> Unit) {
Log.d(TAG, "Google Play billing not available in this build")
callback(false)
}
fun fetchProducts(productIds: List<String>, callback: (List<ProductData>) -> Unit) {
callback(emptyList())
}
fun purchaseProduct(productId: String, callback: (PurchaseData?) -> Unit) {
callback(null)
}
fun restorePurchases(callback: (List<PurchaseData>) -> Unit) {
callback(emptyList())
}
}
@@ -285,7 +285,7 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
if (visible) {
controller.show(WindowInsets.Type.statusBars())
controller.show(WindowInsets.Type.navigationBars())
controller.hide(WindowInsets.Type.navigationBars())
} else {
controller.hide(WindowInsets.Type.systemBars())
}
@@ -301,7 +301,7 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
if (visible) {
it.show(WindowInsetsCompat.Type.statusBars())
it.show(WindowInsetsCompat.Type.navigationBars())
it.hide(WindowInsetsCompat.Type.navigationBars())
} else {
it.hide(WindowInsetsCompat.Type.systemBars())
}
@@ -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
@@ -2,6 +2,8 @@ package com.readest.native_tts
import android.Manifest
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.app.Activity
import android.content.Context
import android.provider.Settings
@@ -110,25 +112,33 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
companion object {
private const val TAG = "NativeTTSPlugin"
private const val CHANNEL_NAME = "tts_events"
private const val IDLE_TIMEOUT_MS = 30L * 60 * 1000 // 30 minutes
var NOTIFICATION_TITLE = "Read Aloud"
var NOTIFICATION_TEXT = "Ready to read aloud"
var FOREGROUND_SERVICE_TITLE = "Read Aloud"
var FOREGROUND_SERVICE_TEXT = "Ready to read aloud"
}
private var textToSpeech: TextToSpeech? = null
private var isInitialized = AtomicBoolean(false)
private var isPaused = AtomicBoolean(false)
private var isSpeaking = AtomicBoolean(false)
private var currentRate = AtomicReference<Float>(1.0f)
private var currentPitch = AtomicReference<Float>(1.0f)
private val eventChannels = ConcurrentHashMap<String, Channel<TTSMessageEvent>>()
private val speakingJobs = ConcurrentHashMap<String, Job>()
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private val idleHandler = Handler(Looper.getMainLooper())
private val idleShutdownRunnable = Runnable {
Log.d(TAG, "Idle timeout reached, shutting down TTS engine to save battery")
shutdownTTSEngine()
}
@Command
fun init(invoke: Invoke) {
cancelIdleTimer()
coroutineScope.launch {
try {
val success = initializeTTS()
@@ -215,34 +225,46 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
@Command
fun speak(invoke: Invoke) {
cancelIdleTimer()
val args = invoke.parseArgs(SpeakArgs::class.java)
val text = args.text ?: ""
if (text.isEmpty()) {
invoke.reject("Text cannot be empty")
return
}
val utteranceId = UUID.randomUUID().toString()
coroutineScope.launch {
try {
// Re-initialize TTS engine if it was shut down by the idle timer
if (!isInitialized.get()) {
val success = initializeTTS()
if (!success) {
invoke.reject("Failed to re-initialize TTS engine")
return@launch
}
Log.d(TAG, "TTS engine re-initialized after idle shutdown")
}
val eventChannel = Channel<TTSMessageEvent>(Channel.UNLIMITED)
eventChannels[utteranceId] = eventChannel
val speakJob = launch {
speakText(text, utteranceId, args.preload ?: false)
}
speakingJobs[utteranceId] = speakJob
val result = JSObject().apply {
put("utteranceId", utteranceId)
}
invoke.resolve(result)
// Start sending events to the frontend
startEventStream(utteranceId)
} catch (e: Exception) {
Log.e(TAG, "Failed to start speaking", e)
invoke.reject("Failed to start speaking: ${e.message}")
@@ -317,6 +339,7 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
try {
if (textToSpeech?.stop() == TextToSpeech.SUCCESS) {
isPaused.set(true)
startIdleTimer()
invoke.resolve()
} else {
invoke.reject("Failed to pause TTS")
@@ -328,6 +351,7 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
@Command
fun resume(invoke: Invoke) {
cancelIdleTimer()
try {
isPaused.set(false)
invoke.resolve()
@@ -346,7 +370,8 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
eventChannels.values.forEach { it.close() }
speakingJobs.clear()
eventChannels.clear()
startIdleTimer()
invoke.resolve()
} else {
invoke.reject("Failed to stop TTS")
@@ -377,57 +402,67 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
invoke.reject("Exception setting pitch: ${e.message}")
}
}
@Command
fun set_voice(invoke: Invoke) {
val args = invoke.parseArgs(SetVoiceArgs::class.java)
try {
val voices = textToSpeech?.voices
val targetVoice = voices?.find { voice ->
val languageTag = voice.locale.toLanguageTag()
voice.name == args.voice || (languageTag.contains(voice.name) && languageTag == args.voice)
}
if (targetVoice != null) {
val result = textToSpeech?.setVoice(targetVoice)
if (result == TextToSpeech.SUCCESS) {
invoke.resolve()
} else {
invoke.reject("Failed to set voice: ${args.voice}")
coroutineScope.launch {
try {
if (!isInitialized.get()) {
initializeTTS()
}
} else {
invoke.reject("Voice not found: ${args.voice}")
val voices = textToSpeech?.voices
val targetVoice = voices?.find { voice ->
val languageTag = voice.locale.toLanguageTag()
voice.name == args.voice || (languageTag.contains(voice.name) && languageTag == args.voice)
}
if (targetVoice != null) {
val result = textToSpeech?.setVoice(targetVoice)
if (result == TextToSpeech.SUCCESS) {
invoke.resolve()
} else {
invoke.reject("Failed to set voice: ${args.voice}")
}
} else {
invoke.reject("Voice not found: ${args.voice}")
}
} catch (e: Exception) {
invoke.reject("Exception setting voice: ${e.message}")
}
} catch (e: Exception) {
invoke.reject("Exception setting voice: ${e.message}")
}
}
@Command
fun get_all_voices(invoke: Invoke) {
try {
val voices = textToSpeech?.voices?.map { voice ->
val voiceName = voice.name
val language = voice.locale.toLanguageTag()
val (id, name) = if (language.contains(voiceName)) {
language to language
} else {
voiceName to voiceName
coroutineScope.launch {
try {
if (!isInitialized.get()) {
initializeTTS()
}
JSObject().apply {
put("id", id)
put("name", name)
put("lang", language)
put("disabled", false)
val voices = textToSpeech?.voices?.map { voice ->
val voiceName = voice.name
val language = voice.locale.toLanguageTag()
val (id, name) = if (language.contains(voiceName)) {
language to language
} else {
voiceName to voiceName
}
JSObject().apply {
put("id", id)
put("name", name)
put("lang", language)
put("disabled", false)
}
} ?: emptyList()
val result = JSObject().apply {
put("voices", JSONArray(voices))
}
} ?: emptyList()
val result = JSObject().apply {
put("voices", JSONArray(voices))
invoke.resolve(result)
} catch (e: Exception) {
invoke.reject("Exception getting voices: ${e.message}")
}
invoke.resolve(result)
} catch (e: Exception) {
invoke.reject("Exception getting voices: ${e.message}")
}
}
@@ -516,6 +551,7 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
try {
val intent = Intent(activity, MediaPlaybackService::class.java)
if (active) {
cancelIdleTimer()
MediaPlaybackService.pluginEventTrigger = { event, data -> trigger(event, data) }
MediaPlaybackService.currentTitle = FOREGROUND_SERVICE_TITLE
MediaPlaybackService.currentArtist = FOREGROUND_SERVICE_TEXT
@@ -530,13 +566,49 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
}
}
private fun startIdleTimer() {
idleHandler.removeCallbacks(idleShutdownRunnable)
idleHandler.postDelayed(idleShutdownRunnable, IDLE_TIMEOUT_MS)
}
private fun cancelIdleTimer() {
idleHandler.removeCallbacks(idleShutdownRunnable)
}
private fun shutdownTTSEngine() {
try {
val intent = Intent(activity, MediaPlaybackService::class.java)
activity.stopService(intent)
MediaPlaybackService.pluginEventTrigger = null
textToSpeech?.shutdown()
textToSpeech = null
isInitialized.set(false)
isSpeaking.set(false)
isPaused.set(false)
speakingJobs.values.forEach { it.cancel() }
eventChannels.values.forEach { it.close() }
speakingJobs.clear()
eventChannels.clear()
Log.d(TAG, "TTS engine shut down due to idle timeout")
} catch (e: Exception) {
Log.e(TAG, "Error during idle TTS shutdown", e)
}
}
fun destroy() {
try {
cancelIdleTimer()
val intent = Intent(activity, MediaPlaybackService::class.java)
activity.stopService(intent)
coroutineScope.cancel()
textToSpeech?.shutdown()
textToSpeech = null
isInitialized.set(false)
eventChannels.values.forEach { it.close() }
eventChannels.clear()
speakingJobs.values.forEach { it.cancel() }
+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,313 @@
import { describe, it, expect } from 'vitest';
import {
createBookFilter,
getBreadcrumbs,
getBookSortValue,
compareSortValues,
createBookSorter,
} from '@/app/library/utils/libraryUtils';
import { Book } from '@/types/book';
import { LibrarySortByType } from '@/types/settings';
import { BookMetadata } from '@/libs/document';
/**
* Tests for functions NOT covered by the existing library-utils.test.ts:
* - createBookFilter
* - getBreadcrumbs
* - getBookSortValue
* - compareSortValues
* - createBookSorter (additional sort-by cases: Author, Format, Series, Published, default)
*/
const createMockBook = (
overrides: Partial<Omit<Book, 'metadata'> & { metadata?: Partial<BookMetadata> }> = {},
): Book => ({
hash: `hash-${Math.random().toString(36).substr(2, 9)}`,
format: 'EPUB',
title: 'Test Book',
author: 'Test Author',
createdAt: Date.now(),
updatedAt: Date.now(),
...overrides,
metadata: { ...overrides.metadata } as BookMetadata,
});
describe('createBookFilter', () => {
it('should return true for all books when queryTerm is null', () => {
const filter = createBookFilter(null);
const book = createMockBook({ title: 'Anything' });
expect(filter(book)).toBe(true);
});
it('should return true for all non-deleted books when queryTerm is empty string', () => {
const filter = createBookFilter('');
const book = createMockBook({ title: 'Anything' });
expect(filter(book)).toBe(true);
});
it('should match by title', () => {
const filter = createBookFilter('Moby');
const book = createMockBook({ title: 'Moby Dick' });
expect(filter(book)).toBe(true);
});
it('should match by author', () => {
const filter = createBookFilter('Twain');
const book = createMockBook({ title: 'Adventures', author: 'Mark Twain' });
expect(filter(book)).toBe(true);
});
it('should match by format', () => {
const filter = createBookFilter('PDF');
const book = createMockBook({ format: 'PDF', title: 'Some Book' });
expect(filter(book)).toBe(true);
});
it('should match by groupName', () => {
const filter = createBookFilter('fiction');
const book = createMockBook({ title: 'A Book', groupName: 'Science Fiction' });
expect(filter(book)).toBe(true);
});
it('should match by description in metadata', () => {
const filter = createBookFilter('adventure');
const book = createMockBook({
title: 'A Book',
metadata: { description: 'An adventure tale' },
});
expect(filter(book)).toBe(true);
});
it('should be case-insensitive', () => {
const filter = createBookFilter('moby');
const book = createMockBook({ title: 'MOBY DICK' });
expect(filter(book)).toBe(true);
});
it('should not match non-matching books', () => {
const filter = createBookFilter('nonexistent');
const book = createMockBook({ title: 'Real Book', author: 'Real Author' });
expect(filter(book)).toBeFalsy();
});
it('should return false for deleted books', () => {
const filter = createBookFilter('test');
const book = createMockBook({ title: 'test book', deletedAt: Date.now() });
expect(filter(book)).toBe(false);
});
it('should handle invalid regex gracefully by doing substring match', () => {
// The string "[invalid" is an invalid regex pattern
const filter = createBookFilter('[invalid');
const book = createMockBook({ title: 'A book with [invalid text' });
expect(filter(book)).toBe(true);
});
it('should not match when invalid regex query does not match', () => {
const filter = createBookFilter('[invalid');
const book = createMockBook({ title: 'Normal Title', author: 'Normal Author' });
expect(filter(book)).toBeFalsy();
});
it('should support regex patterns', () => {
const filter = createBookFilter('^Moby');
const bookMatch = createMockBook({ title: 'Moby Dick' });
const bookNoMatch = createMockBook({ title: 'The Moby Dick' });
expect(filter(bookMatch)).toBeTruthy();
expect(filter(bookNoMatch)).toBeFalsy();
});
});
describe('getBreadcrumbs', () => {
it('should return empty array for empty string', () => {
expect(getBreadcrumbs('')).toEqual([]);
});
it('should return single breadcrumb for single segment', () => {
const result = getBreadcrumbs('Library');
expect(result).toEqual([{ name: 'Library', path: 'Library' }]);
});
it('should return multiple breadcrumbs for path with separators', () => {
const result = getBreadcrumbs('Library/Fiction/Scifi');
expect(result).toEqual([
{ name: 'Library', path: 'Library' },
{ name: 'Fiction', path: 'Library/Fiction' },
{ name: 'Scifi', path: 'Library/Fiction/Scifi' },
]);
});
it('should handle two segments', () => {
const result = getBreadcrumbs('A/B');
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ name: 'A', path: 'A' });
expect(result[1]).toEqual({ name: 'B', path: 'A/B' });
});
});
describe('getBookSortValue', () => {
it('should return formatted title for Title sort', () => {
const book = createMockBook({ title: 'The Great Book' });
const value = getBookSortValue(book, LibrarySortByType.Title);
expect(typeof value).toBe('string');
expect(value).toBeTruthy();
});
it('should return formatted author for Author sort', () => {
const book = createMockBook({ author: 'Jane Austen' });
const value = getBookSortValue(book, LibrarySortByType.Author);
expect(typeof value).toBe('string');
expect(value).toBeTruthy();
});
it('should return updatedAt for Updated sort', () => {
const book = createMockBook({ updatedAt: 12345 });
expect(getBookSortValue(book, LibrarySortByType.Updated)).toBe(12345);
});
it('should return createdAt for Created sort', () => {
const book = createMockBook({ createdAt: 67890 });
expect(getBookSortValue(book, LibrarySortByType.Created)).toBe(67890);
});
it('should return format string for Format sort', () => {
const book = createMockBook({ format: 'PDF' });
expect(getBookSortValue(book, LibrarySortByType.Format)).toBe('PDF');
});
it('should return published date timestamp for Published sort', () => {
const book = createMockBook({ metadata: { published: '2023-01-15' } });
const value = getBookSortValue(book, LibrarySortByType.Published);
expect(value).toBe(new Date('2023-01-15').getTime());
});
it('should return 0 for Published sort when no published date', () => {
const book = createMockBook({ metadata: {} });
expect(getBookSortValue(book, LibrarySortByType.Published)).toBe(0);
});
it('should return 0 for Published sort when date is invalid', () => {
const book = createMockBook({ metadata: { published: 'not-a-date' } });
expect(getBookSortValue(book, LibrarySortByType.Published)).toBe(0);
});
it('should return updatedAt for unknown sort type', () => {
const book = createMockBook({ updatedAt: 99999 });
expect(getBookSortValue(book, 'unknown' as LibrarySortByType)).toBe(99999);
});
});
describe('compareSortValues', () => {
it('should compare two strings using locale comparison', () => {
const result = compareSortValues('Apple', 'Banana', 'en');
expect(result).toBeLessThan(0);
});
it('should compare two numbers', () => {
expect(compareSortValues(10, 20, 'en')).toBeLessThan(0);
expect(compareSortValues(20, 10, 'en')).toBeGreaterThan(0);
expect(compareSortValues(10, 10, 'en')).toBe(0);
});
it('should return 0 when types are mismatched', () => {
expect(compareSortValues('hello', 42, 'en')).toBe(0);
expect(compareSortValues(42, 'hello', 'en')).toBe(0);
});
it('should handle equal strings', () => {
expect(compareSortValues('same', 'same', 'en')).toBe(0);
});
});
describe('createBookSorter - additional cases', () => {
it('should sort by author with last-name-first formatting', () => {
const books = [
createMockBook({ author: 'Jane Austen' }),
createMockBook({ author: 'Charles Dickens' }),
];
const sorter = createBookSorter(LibrarySortByType.Author, 'en');
const sorted = [...books].sort(sorter);
// Austen < Dickens (by last name)
expect(sorted[0]!.author).toBe('Jane Austen');
expect(sorted[1]!.author).toBe('Charles Dickens');
});
it('should sort by createdAt', () => {
const books = [
createMockBook({ title: 'Newer', createdAt: 2000 }),
createMockBook({ title: 'Older', createdAt: 1000 }),
];
const sorter = createBookSorter(LibrarySortByType.Created, 'en');
const sorted = [...books].sort(sorter);
expect(sorted[0]!.title).toBe('Older');
expect(sorted[1]!.title).toBe('Newer');
});
it('should sort by format alphabetically', () => {
const books = [createMockBook({ format: 'PDF' }), createMockBook({ format: 'EPUB' })];
const sorter = createBookSorter(LibrarySortByType.Format, 'en');
const sorted = [...books].sort(sorter);
expect(sorted[0]!.format).toBe('EPUB');
expect(sorted[1]!.format).toBe('PDF');
});
it('should sort by seriesIndex', () => {
const books = [
createMockBook({ metadata: { seriesIndex: 3 } }),
createMockBook({ metadata: { seriesIndex: 1 } }),
createMockBook({ metadata: { seriesIndex: 2 } }),
];
const sorter = createBookSorter(LibrarySortByType.Series, 'en');
const sorted = [...books].sort(sorter);
expect(sorted[0]!.metadata?.seriesIndex).toBe(1);
expect(sorted[1]!.metadata?.seriesIndex).toBe(2);
expect(sorted[2]!.metadata?.seriesIndex).toBe(3);
});
it('should sort by published date', () => {
const books = [
createMockBook({ metadata: { published: '2023-06-15' } }),
createMockBook({ metadata: { published: '2020-01-01' } }),
createMockBook({ metadata: { published: '2021-12-31' } }),
];
const sorter = createBookSorter(LibrarySortByType.Published, 'en');
const sorted = [...books].sort(sorter);
expect(sorted[0]!.metadata?.published).toBe('2020-01-01');
expect(sorted[1]!.metadata?.published).toBe('2021-12-31');
expect(sorted[2]!.metadata?.published).toBe('2023-06-15');
});
it('should handle missing published dates in sort (books without date use fallback 0001-01-01)', () => {
const books = [
createMockBook({ metadata: {} }),
createMockBook({ metadata: { published: '2020-01-01' } }),
];
const sorter = createBookSorter(LibrarySortByType.Published, 'en');
const sorted = [...books].sort(sorter);
// Book without published date defaults to '0001-01-01', which is earlier
expect(sorted[0]!.metadata?.published).toBeUndefined();
expect(sorted[1]!.metadata?.published).toBe('2020-01-01');
});
it('should handle invalid published dates', () => {
const books = [
createMockBook({ metadata: { published: 'not-a-date' } }),
createMockBook({ metadata: { published: '2020-01-01' } }),
];
const sorter = createBookSorter(LibrarySortByType.Published, 'en');
const sorted = [...books].sort(sorter);
// Invalid date treated like missing, should sort after valid dates
expect(sorted[0]!.metadata?.published).toBe('2020-01-01');
});
it('should use updatedAt as default sort for unknown sort type', () => {
const books = [
createMockBook({ title: 'Newer', updatedAt: 2000 }),
createMockBook({ title: 'Older', updatedAt: 1000 }),
];
const sorter = createBookSorter('unknown', 'en');
const sorted = [...books].sort(sorter);
expect(sorted[0]!.title).toBe('Older');
expect(sorted[1]!.title).toBe('Newer');
});
});
@@ -0,0 +1,575 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock dependencies before importing the module under test
vi.mock('foliate-js/opds.js', () => ({
isOPDSCatalog: vi.fn((type: string) => {
return (
type.includes('application/atom+xml') &&
(type.includes('opds-catalog') || type.includes('opds'))
);
}),
}));
vi.mock('@/libs/document', () => ({
EXTS: {
EPUB: 'epub',
PDF: 'pdf',
MOBI: 'mobi',
AZW: 'azw',
AZW3: 'azw3',
CBZ: 'cbz',
FB2: 'fb2',
FBZ: 'fbz',
TXT: 'txt',
MD: 'md',
},
}));
vi.mock('@/services/environment', () => ({
isWebAppPlatform: vi.fn(() => true),
isTauriAppPlatform: vi.fn(() => false),
getAPIBaseUrl: () => '/api',
getNodeAPIBaseUrl: () => '/node-api',
getBaseUrl: () => 'https://web.readest.com',
getNodeBaseUrl: () => 'https://node.readest.com',
isWebDevMode: () => true,
}));
vi.mock('@tauri-apps/plugin-http', () => ({
fetch: vi.fn(),
}));
vi.mock('@/app/opds/utils/opdsReq', () => ({
fetchWithAuth: vi.fn(),
}));
import {
groupByArray,
parseMediaType,
isSearchLink,
resolveURL,
getFileExtFromPath,
MIME,
validateOPDSURL,
} from '@/app/opds/utils/opdsUtils';
import type { OPDSLink } from '@/types/opds';
import { fetchWithAuth } from '@/app/opds/utils/opdsReq';
const mockFetchWithAuth = vi.mocked(fetchWithAuth);
describe('opdsUtils', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('groupByArray', () => {
it('should group items by a single key', () => {
const items = [
{ name: 'Alice', role: 'admin' },
{ name: 'Bob', role: 'user' },
{ name: 'Charlie', role: 'admin' },
];
const grouped = groupByArray(items, (item) => item.role);
expect(grouped.get('admin')).toHaveLength(2);
expect(grouped.get('user')).toHaveLength(1);
expect(grouped.get('admin')![0]!.name).toBe('Alice');
expect(grouped.get('admin')![1]!.name).toBe('Charlie');
});
it('should group items by multiple keys (array return)', () => {
const items = [
{ name: 'Alice', tags: ['a', 'b'] },
{ name: 'Bob', tags: ['b', 'c'] },
];
const grouped = groupByArray(items, (item) => item.tags);
expect(grouped.get('a')).toHaveLength(1);
expect(grouped.get('b')).toHaveLength(2);
expect(grouped.get('c')).toHaveLength(1);
});
it('should return an empty map for undefined input', () => {
const grouped = groupByArray(undefined, (item: string) => item);
expect(grouped.size).toBe(0);
});
it('should return an empty map for empty array', () => {
const grouped = groupByArray([], (item: string) => item);
expect(grouped.size).toBe(0);
});
it('should handle single-element arrays', () => {
const items = [{ id: 1 }];
const grouped = groupByArray(items, (item) => item.id);
expect(grouped.get(1)).toHaveLength(1);
});
});
describe('MIME constants', () => {
it('should have correct XML MIME type', () => {
expect(MIME.XML).toBe('application/xml');
});
it('should have correct ATOM MIME type', () => {
expect(MIME.ATOM).toBe('application/atom+xml');
});
it('should have correct XHTML MIME type', () => {
expect(MIME.XHTML).toBe('application/xhtml+xml');
});
it('should have correct HTML MIME type', () => {
expect(MIME.HTML).toBe('text/html');
});
it('should have correct EPUB MIME type', () => {
expect(MIME.EPUB).toBe('application/epub+zip');
});
it('should have correct PDF MIME type', () => {
expect(MIME.PDF).toBe('application/pdf');
});
it('should have correct OPENSEARCH MIME type', () => {
expect(MIME.OPENSEARCH).toBe('application/opensearchdescription+xml');
});
});
describe('parseMediaType', () => {
it('should parse a simple media type', () => {
const result = parseMediaType('application/atom+xml');
expect(result).not.toBeNull();
expect(result!.mediaType).toBe('application/atom+xml');
expect(Object.keys(result!.parameters)).toHaveLength(0);
});
it('should parse media type with parameters', () => {
const result = parseMediaType('application/atom+xml; charset=utf-8');
expect(result).not.toBeNull();
expect(result!.mediaType).toBe('application/atom+xml');
expect(result!.parameters['charset']).toBe('utf-8');
});
it('should parse media type with multiple parameters', () => {
const result = parseMediaType('text/html; charset=utf-8; boundary=something');
expect(result).not.toBeNull();
expect(result!.mediaType).toBe('text/html');
expect(result!.parameters['charset']).toBe('utf-8');
expect(result!.parameters['boundary']).toBe('something');
});
it('should parse media type with quoted parameter values', () => {
const result = parseMediaType('application/atom+xml; profile="opds-catalog"');
expect(result).not.toBeNull();
expect(result!.parameters['profile']).toBe('opds-catalog');
});
it('should lowercase the media type', () => {
const result = parseMediaType('Application/Atom+XML');
expect(result).not.toBeNull();
expect(result!.mediaType).toBe('application/atom+xml');
});
it('should lowercase parameter names', () => {
const result = parseMediaType('text/html; Charset=utf-8');
expect(result).not.toBeNull();
expect(result!.parameters['charset']).toBe('utf-8');
});
it('should return null for empty string', () => {
expect(parseMediaType('')).toBeNull();
});
it('should return null for undefined input', () => {
expect(parseMediaType(undefined)).toBeNull();
});
it('should handle extra spaces around semicolons', () => {
const result = parseMediaType('text/html ; charset=utf-8');
expect(result).not.toBeNull();
expect(result!.mediaType).toBe('text/html');
expect(result!.parameters['charset']).toBe('utf-8');
});
});
describe('isSearchLink', () => {
it('should return true for a search link with OPENSEARCH type', () => {
const link: OPDSLink = {
rel: 'search',
href: '/search',
type: MIME.OPENSEARCH,
properties: {},
};
expect(isSearchLink(link)).toBe(true);
});
it('should return true for a search link with ATOM type', () => {
const link: OPDSLink = {
rel: 'search',
href: '/search',
type: MIME.ATOM,
properties: {},
};
expect(isSearchLink(link)).toBe(true);
});
it('should return true when rel is an array containing "search"', () => {
const link: OPDSLink = {
rel: ['self', 'search'],
href: '/search',
type: MIME.OPENSEARCH,
properties: {},
};
expect(isSearchLink(link)).toBe(true);
});
it('should return false when rel does not include "search"', () => {
const link: OPDSLink = {
rel: 'self',
href: '/search',
type: MIME.OPENSEARCH,
properties: {},
};
expect(isSearchLink(link)).toBe(false);
});
it('should return false when type is not OPENSEARCH or ATOM', () => {
const link: OPDSLink = {
rel: 'search',
href: '/search',
type: 'text/html',
properties: {},
};
expect(isSearchLink(link)).toBe(false);
});
it('should return false when rel is undefined', () => {
const link: OPDSLink = {
href: '/search',
type: MIME.OPENSEARCH,
properties: {},
};
expect(isSearchLink(link)).toBe(false);
});
it('should return false when rel is an empty array', () => {
const link: OPDSLink = {
rel: [],
href: '/search',
type: MIME.ATOM,
properties: {},
};
expect(isSearchLink(link)).toBe(false);
});
});
describe('resolveURL', () => {
it('should resolve an absolute URL relative to a base URL', () => {
const result = resolveURL('/feed/new', 'https://example.com/opds');
expect(result).toBe('https://example.com/feed/new');
});
it('should resolve a relative URL relative to a base URL', () => {
const result = resolveURL('new', 'https://example.com/opds/feed');
expect(result).toBe('https://example.com/opds/new');
});
it('should return empty string for empty url', () => {
expect(resolveURL('', 'https://example.com')).toBe('');
});
it('should resolve through proxy URL', () => {
const proxyBase = '/api/opds/proxy?url=https%3A%2F%2Fexample.com%2Fopds';
const result = resolveURL('/feed/new', proxyBase);
expect(result).toBe('https://example.com/feed/new');
});
it('should handle non-scheme relativeTo (path-only)', () => {
const result = resolveURL('subdir/file.xml', '/opds/catalog/');
// Should use the invalid.invalid root and strip it
expect(result).toContain('subdir/file.xml');
expect(result).not.toContain('invalid.invalid');
});
it('should return the URL itself when resolution fails', () => {
// Suppress console.warn for this test
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = resolveURL('://broken', '://also-broken');
// Should fall through to the catch and return url
expect(result).toBe('://broken');
warnSpy.mockRestore();
});
it('should handle full absolute URLs as the url parameter', () => {
const result = resolveURL('https://other.com/feed', 'https://example.com/opds');
expect(result).toBe('https://other.com/feed');
});
it('should strip query parameters for non-scheme relativeTo', () => {
const result = resolveURL('feed.xml?page=2', '/opds/catalog/');
// The function strips search params for non-scheme relativeTo
expect(result).not.toContain('page=2');
});
});
describe('getFileExtFromPath', () => {
it('should find epub extension in path', () => {
expect(getFileExtFromPath('/books/epub/my-book')).toBe('epub');
});
it('should find pdf extension in path', () => {
expect(getFileExtFromPath('/books/pdf/my-doc')).toBe('pdf');
});
it('should find mobi extension in path', () => {
expect(getFileExtFromPath('/books/mobi/my-book')).toBe('mobi');
});
it('should find cbz extension in path', () => {
expect(getFileExtFromPath('/comics/cbz/issue1')).toBe('cbz');
});
it('should find fb2 extension in path', () => {
expect(getFileExtFromPath('/books/fb2/russian-novel')).toBe('fb2');
});
it('should find txt extension in path', () => {
expect(getFileExtFromPath('/texts/txt/readme')).toBe('txt');
});
it('should find md extension in path', () => {
expect(getFileExtFromPath('/notes/md/guide')).toBe('md');
});
it('should return empty string when no extension found', () => {
expect(getFileExtFromPath('/books/unknown/my-book')).toBe('');
});
it('should return empty string for empty path', () => {
expect(getFileExtFromPath('')).toBe('');
});
it('should use custom delimiter', () => {
expect(getFileExtFromPath('books.epub.my-book', '.')).toBe('epub');
});
it('should return the first matching extension', () => {
// If both epub and pdf appear, returns whichever EXTS entry matches first
const result = getFileExtFromPath('/books/epub/pdf/file');
expect(['epub', 'pdf']).toContain(result);
});
});
describe('validateOPDSURL', () => {
beforeEach(() => {
mockFetchWithAuth.mockReset();
// Suppress expected error noise from validation failure tests.
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('should return valid feed for XML OPDS feed', async () => {
const xmlFeed = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Test Feed</title>
</feed>`;
mockFetchWithAuth.mockResolvedValue({
ok: true,
url: 'https://example.com/opds',
text: () => Promise.resolve(xmlFeed),
headers: new Headers({ 'Content-Type': 'application/atom+xml' }),
} as Response);
const result = await validateOPDSURL('https://example.com/opds');
expect(result.isValid).toBe(true);
expect(result.data?.type).toBe('feed');
expect(result.data?.responseURL).toBe('https://example.com/opds');
});
it('should return valid entry for XML entry document', async () => {
const xmlEntry = `<?xml version="1.0" encoding="UTF-8"?>
<entry xmlns="http://www.w3.org/2005/Atom">
<title>Single Book</title>
</entry>`;
mockFetchWithAuth.mockResolvedValue({
ok: true,
url: 'https://example.com/opds/entry',
text: () => Promise.resolve(xmlEntry),
headers: new Headers(),
} as Response);
const result = await validateOPDSURL('https://example.com/opds/entry');
expect(result.isValid).toBe(true);
expect(result.data?.type).toBe('entry');
});
it('should return valid opensearch for OpenSearchDescription', async () => {
const xmlOSD = `<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>Search</ShortName>
</OpenSearchDescription>`;
mockFetchWithAuth.mockResolvedValue({
ok: true,
url: 'https://example.com/search',
text: () => Promise.resolve(xmlOSD),
headers: new Headers(),
} as Response);
const result = await validateOPDSURL('https://example.com/search');
expect(result.isValid).toBe(true);
expect(result.data?.type).toBe('opensearch');
});
it('should return authentication error for 401 status', async () => {
mockFetchWithAuth.mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
url: 'https://example.com/opds',
text: () => Promise.resolve(''),
headers: new Headers(),
} as Response);
const result = await validateOPDSURL('https://example.com/opds');
expect(result.isValid).toBe(false);
expect(result.error).toContain('Authentication required');
});
it('should return error for non-OK HTTP response', async () => {
mockFetchWithAuth.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
url: 'https://example.com/opds',
text: () => Promise.resolve(''),
headers: new Headers(),
} as Response);
const result = await validateOPDSURL('https://example.com/opds');
expect(result.isValid).toBe(false);
expect(result.error).toContain('500');
});
it('should return valid feed for JSON-based OPDS', async () => {
const jsonFeed = JSON.stringify({
metadata: { title: 'Test Feed' },
links: [],
publications: [],
});
mockFetchWithAuth.mockResolvedValue({
ok: true,
url: 'https://example.com/opds.json',
text: () => Promise.resolve(jsonFeed),
headers: new Headers(),
} as Response);
const result = await validateOPDSURL('https://example.com/opds.json');
expect(result.isValid).toBe(true);
expect(result.data?.type).toBe('feed');
});
it('should return invalid for JSON without OPDS fields', async () => {
const jsonData = JSON.stringify({ foo: 'bar' });
mockFetchWithAuth.mockResolvedValue({
ok: true,
url: 'https://example.com/data.json',
text: () => Promise.resolve(jsonData),
headers: new Headers(),
} as Response);
const result = await validateOPDSURL('https://example.com/data.json');
expect(result.isValid).toBe(false);
});
it('should return invalid for non-XML, non-JSON content', async () => {
mockFetchWithAuth.mockResolvedValue({
ok: true,
url: 'https://example.com/page',
text: () => Promise.resolve('Just some plain text'),
headers: new Headers(),
} as Response);
const result = await validateOPDSURL('https://example.com/page');
expect(result.isValid).toBe(false);
});
it('should handle fetch errors gracefully', async () => {
mockFetchWithAuth.mockRejectedValue(new Error('Network error'));
const result = await validateOPDSURL('https://example.com/opds');
expect(result.isValid).toBe(false);
expect(result.error).toBe('Network error');
});
it('should handle non-Error exceptions', async () => {
mockFetchWithAuth.mockRejectedValue('string error');
const result = await validateOPDSURL('https://example.com/opds');
expect(result.isValid).toBe(false);
});
it('should return valid for JSON feed with only navigation', async () => {
const jsonFeed = JSON.stringify({
navigation: [{ title: 'New', href: '/new' }],
});
mockFetchWithAuth.mockResolvedValue({
ok: true,
url: 'https://example.com/opds.json',
text: () => Promise.resolve(jsonFeed),
headers: new Headers(),
} as Response);
const result = await validateOPDSURL('https://example.com/opds.json');
expect(result.isValid).toBe(true);
});
it('should return invalid for XML that is not a recognized OPDS document type', async () => {
const xmlDoc = `<?xml version="1.0" encoding="UTF-8"?>
<catalog>
<book>Test</book>
</catalog>`;
mockFetchWithAuth.mockResolvedValue({
ok: true,
url: 'https://example.com/catalog',
text: () => Promise.resolve(xmlDoc),
headers: new Headers({ 'Content-Type': 'application/xml' }),
} as Response);
const result = await validateOPDSURL('https://example.com/catalog');
expect(result.isValid).toBe(false);
});
it('should pass auth and custom headers to fetchWithAuth', async () => {
const xmlFeed = `<feed xmlns="http://www.w3.org/2005/Atom"><title>Auth Feed</title></feed>`;
const customHeaders = {
'CF-Access-Client-Id': 'client-id',
'CF-Access-Client-Secret': 'client-secret',
};
mockFetchWithAuth.mockResolvedValue({
ok: true,
url: 'https://example.com/opds',
text: () => Promise.resolve(xmlFeed),
headers: new Headers(),
} as Response);
await validateOPDSURL('https://example.com/opds', 'user', 'pass', true, customHeaders);
expect(mockFetchWithAuth).toHaveBeenCalledWith(
'https://example.com/opds',
'user',
'pass',
true,
expect.objectContaining({ signal: expect.anything() }),
customHeaders,
);
});
});
});
@@ -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,305 @@
import { describe, it, expect } from 'vitest';
import { getPlanDetails } from '@/app/user/utils/plan';
import { AvailablePlan, UserPlan, PlanInterval, QuotaFeature } from '@/types/quota';
import { StripeProductMetadata } from '@/types/payment';
// getPlanDetails expects (AvailablePlan & StripeAvailablePlan)[].
// StripeAvailablePlan = AvailablePlan & { metadata?: StripeProductMetadata; product?: Stripe.Product }
// We build the exact shape needed to satisfy the type.
type TestPlan = AvailablePlan & {
metadata?: StripeProductMetadata;
};
function makePlan(overrides: Partial<TestPlan> = {}): TestPlan {
return {
plan: 'free' as UserPlan,
productId: 'prod_test',
price: 0,
currency: 'USD',
interval: 'month' as PlanInterval,
productName: 'Test Plan',
...overrides,
};
}
describe('getPlanDetails', () => {
describe('free plan', () => {
it('should return free plan details with no available plans', () => {
const result = getPlanDetails('free', []);
expect(result.name).toBe('Free Plan');
expect(result.plan).toBe('free');
expect(result.type).toBe('subscription');
expect(result.price).toBe(0);
expect(result.currency).toBe('USD');
});
it('should use currency from available plans', () => {
const plans = [makePlan({ plan: 'free', currency: 'EUR' })];
const result = getPlanDetails('free', plans);
expect(result.currency).toBe('EUR');
});
it('should include features array', () => {
const result = getPlanDetails('free', []);
expect(result.features.length).toBeGreaterThan(0);
const labels = result.features.map((f) => f.label);
expect(labels).toContain('Cross-Platform Sync');
expect(labels).toContain('AI Read Aloud');
});
it('should include limits', () => {
const result = getPlanDetails('free', []);
expect(result.limits).toBeDefined();
expect(Object.keys(result.limits!).length).toBeGreaterThan(0);
});
it('should use month interval label by default', () => {
const result = getPlanDetails('free', []);
expect(result.interval).toBe('month');
});
it('should use year interval label when specified', () => {
const result = getPlanDetails('free', [], 'year');
expect(result.interval).toBe('year');
});
it('should set productId from matching available plan', () => {
const plans = [makePlan({ plan: 'free', productId: 'prod_free_123' })];
const result = getPlanDetails('free', plans);
expect(result.productId).toBe('prod_free_123');
});
});
describe('plus plan', () => {
it('should return plus plan details', () => {
const plans = [makePlan({ plan: 'plus', price: 499 })];
const result = getPlanDetails('plus', plans);
expect(result.name).toBe('Plus Plan');
expect(result.plan).toBe('plus');
expect(result.type).toBe('subscription');
expect(result.price).toBe(499);
});
it('should use default price when no matching plan found', () => {
const result = getPlanDetails('plus', []);
expect(result.price).toBe(499);
});
it('should include expected features', () => {
const result = getPlanDetails('plus', []);
const labels = result.features.map((f) => f.label);
expect(labels).toContain('Includes All Free Plan Benefits');
expect(labels).toContain('Unlimited AI Read Aloud Hours');
expect(labels).toContain('Priority Support');
});
it('should include limits with storage and translation', () => {
const result = getPlanDetails('plus', []);
expect(result.limits).toBeDefined();
const limitKeys = Object.keys(result.limits!);
expect(limitKeys.some((k) => k.includes('Storage'))).toBe(true);
expect(limitKeys.some((k) => k.includes('Translation'))).toBe(true);
});
it('should match correct plan by interval', () => {
const plans = [
makePlan({ plan: 'plus', price: 499, interval: 'month' }),
makePlan({ plan: 'plus', price: 3999, interval: 'year' }),
];
const monthResult = getPlanDetails('plus', plans, 'month');
expect(monthResult.price).toBe(499);
const yearResult = getPlanDetails('plus', plans, 'year');
expect(yearResult.price).toBe(3999);
});
});
describe('pro plan', () => {
it('should return pro plan details', () => {
const plans = [makePlan({ plan: 'pro', price: 999 })];
const result = getPlanDetails('pro', plans);
expect(result.name).toBe('Pro Plan');
expect(result.plan).toBe('pro');
expect(result.type).toBe('subscription');
expect(result.price).toBe(999);
});
it('should use default price when no matching plan found', () => {
const result = getPlanDetails('pro', []);
expect(result.price).toBe(999);
});
it('should include expected features', () => {
const result = getPlanDetails('pro', []);
const labels = result.features.map((f) => f.label);
expect(labels).toContain('Includes All Plus Plan Benefits');
expect(labels).toContain('Early Feature Access');
expect(labels).toContain('Advanced AI Tools');
});
it('should have higher storage limit than plus', () => {
const proResult = getPlanDetails('pro', []);
const plusResult = getPlanDetails('plus', []);
// Both should have limits
expect(proResult.limits).toBeDefined();
expect(plusResult.limits).toBeDefined();
// Find storage limit values
const proStorageKey = Object.keys(proResult.limits!).find((k) => k.includes('Storage'));
const plusStorageKey = Object.keys(plusResult.limits!).find((k) => k.includes('Storage'));
expect(proStorageKey).toBeDefined();
expect(plusStorageKey).toBeDefined();
// Pro should have 20 GB, Plus should have 5 GB
expect(proResult.limits![proStorageKey!]).toBe('20 GB');
expect(plusResult.limits![plusStorageKey!]).toBe('5 GB');
});
});
describe('purchase plan', () => {
it('should return purchase plan details', () => {
const plans = [makePlan({ plan: 'purchase', price: 1999 })];
const result = getPlanDetails('purchase', plans);
expect(result.name).toBe('Lifetime Plan');
expect(result.plan).toBe('purchase');
expect(result.type).toBe('purchase');
expect(result.interval).toBe('lifetime');
});
it('should use default price when no matching plan found', () => {
const result = getPlanDetails('purchase', []);
expect(result.price).toBe(1999);
});
it('should include products sorted by price', () => {
const plans = [
makePlan({
plan: 'purchase',
productId: 'prod_expensive',
price: 5000,
productName: 'Expensive',
}),
makePlan({ plan: 'purchase', productId: 'prod_cheap', price: 1000, productName: 'Cheap' }),
makePlan({ plan: 'purchase', productId: 'prod_mid', price: 3000, productName: 'Mid' }),
];
const result = getPlanDetails('purchase', plans);
expect(result.products).toBeDefined();
expect(result.products).toHaveLength(3);
expect(result.products![0]!.price).toBe(1000);
expect(result.products![1]!.price).toBe(3000);
expect(result.products![2]!.price).toBe(5000);
});
it('should derive feature from productId when metadata.feature is missing', () => {
const plans = [
makePlan({
plan: 'purchase',
productId: 'prod_storage_100gb',
price: 1000,
productName: 'Storage',
}),
makePlan({
plan: 'purchase',
productId: 'prod_translation_pack',
price: 500,
productName: 'Translation',
}),
makePlan({
plan: 'purchase',
productId: 'prod_tokens_bundle',
price: 2000,
productName: 'Tokens',
}),
makePlan({
plan: 'purchase',
productId: 'prod_customization_pro',
price: 1500,
productName: 'Custom',
}),
];
const result = getPlanDetails('purchase', plans);
expect(result.products![0]!.feature).toBe('translation');
expect(result.products![1]!.feature).toBe('storage');
expect(result.products![2]!.feature).toBe('customization');
expect(result.products![3]!.feature).toBe('tokens');
});
it('should use metadata.feature when present', () => {
const plans = [
makePlan({
plan: 'purchase',
productId: 'prod_xyz',
price: 1000,
productName: 'XYZ',
metadata: { plan: 'purchase', feature: 'storage' as QuotaFeature },
}),
];
const result = getPlanDetails('purchase', plans);
expect(result.products![0]!.feature).toBe('storage');
});
it('should fall back to generic when productId does not match any feature', () => {
const plans = [
makePlan({
plan: 'purchase',
productId: 'prod_unknown_thing',
price: 1000,
productName: 'Unknown',
}),
];
const result = getPlanDetails('purchase', plans);
expect(result.products![0]!.feature).toBe('generic');
});
it('should include expected features', () => {
const result = getPlanDetails('purchase', []);
const labels = result.features.map((f) => f.label);
expect(labels).toContain('One-Time Payment');
expect(labels).toContain('Expand Cloud Sync Storage');
});
});
describe('default / unknown plan', () => {
it('should fall back to free plan for unknown plan code', () => {
const result = getPlanDetails('unknown_plan' as UserPlan, []);
expect(result.plan).toBe('free');
expect(result.name).toBe('Free Plan');
});
});
describe('plan color and hintColor', () => {
it('should assign distinct colors per plan', () => {
const colors: Record<string, string> = {};
const planCodes: UserPlan[] = ['free', 'plus', 'pro', 'purchase'];
for (const code of planCodes) {
const details = getPlanDetails(code, []);
colors[code] = details.color;
}
// Each plan should have a unique color
const uniqueColors = new Set(Object.values(colors));
expect(uniqueColors.size).toBe(4);
});
it('should provide hintColor for all plans', () => {
const planCodes: UserPlan[] = ['free', 'plus', 'pro', 'purchase'];
for (const code of planCodes) {
const details = getPlanDetails(code, []);
expect(details.hintColor).toBeTruthy();
}
});
});
describe('plan without interval match', () => {
it('should handle plan with no interval specified', () => {
// A plan with no interval should match any interval request
const plans = [
makePlan({ plan: 'purchase', price: 1999, interval: undefined as unknown as PlanInterval }),
];
// When interval is not set on available plan, it should still be found
const result = getPlanDetails('purchase', plans);
expect(result.price).toBe(1999);
});
});
});
@@ -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,94 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import Dropdown from '@/components/Dropdown';
import { DropdownProvider } from '@/context/DropdownContext';
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
}));
afterEach(() => cleanup());
// Simulates the browser behavior where pressing Enter/Space on a focused
// <button> fires both a keydown event and a click event. JSDOM does not
// dispatch the click automatically, so we do it here.
const pressActivationKey = (el: HTMLElement, key: 'Enter' | ' ') => {
fireEvent.keyDown(el, { key });
fireEvent.click(el);
};
const renderDropdown = () =>
render(
<DropdownProvider>
<Dropdown label='Test Menu' toggleButton={<span>Toggle</span>} showTooltip={false}>
<div>
<button type='button'>Menu Item</button>
</div>
</Dropdown>
</DropdownProvider>,
);
describe('Dropdown keyboard activation', () => {
it('opens when Enter is pressed on the toggle button', () => {
renderDropdown();
const toggle = screen.getByRole('button', { name: 'Test Menu' });
toggle.focus();
pressActivationKey(toggle, 'Enter');
expect(toggle.getAttribute('aria-expanded')).toBe('true');
});
it('opens when Space is pressed on the toggle button', () => {
renderDropdown();
const toggle = screen.getByRole('button', { name: 'Test Menu' });
toggle.focus();
pressActivationKey(toggle, ' ');
expect(toggle.getAttribute('aria-expanded')).toBe('true');
});
it('closes when Enter is pressed again on an open dropdown', () => {
renderDropdown();
const toggle = screen.getByRole('button', { name: 'Test Menu' });
toggle.focus();
pressActivationKey(toggle, 'Enter');
expect(toggle.getAttribute('aria-expanded')).toBe('true');
pressActivationKey(toggle, 'Enter');
expect(toggle.getAttribute('aria-expanded')).toBe('false');
});
it('stops propagation on Enter to prevent global shortcuts from firing', () => {
renderDropdown();
const toggle = screen.getByRole('button', { name: 'Test Menu' });
const onWindowKeyDown = vi.fn();
window.addEventListener('keydown', onWindowKeyDown);
try {
toggle.focus();
fireEvent.keyDown(toggle, { key: 'Enter' });
expect(onWindowKeyDown).not.toHaveBeenCalled();
} finally {
window.removeEventListener('keydown', onWindowKeyDown);
}
});
it('stops propagation on Space to prevent global shortcuts from firing', () => {
renderDropdown();
const toggle = screen.getByRole('button', { name: 'Test Menu' });
const onWindowKeyDown = vi.fn();
window.addEventListener('keydown', onWindowKeyDown);
try {
toggle.focus();
fireEvent.keyDown(toggle, { key: ' ' });
expect(onWindowKeyDown).not.toHaveBeenCalled();
} finally {
window.removeEventListener('keydown', onWindowKeyDown);
}
});
});
@@ -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,53 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import NumberInput from '@/components/settings/NumberInput';
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
}));
afterEach(cleanup);
describe('NumberInput', () => {
const defaultProps = {
label: 'Font Size',
value: 16,
min: 8,
max: 72,
onChange: vi.fn(),
};
it('commits value on Enter key (form submit)', () => {
const onChange = vi.fn();
render(<NumberInput {...defaultProps} onChange={onChange} />);
const input = screen.getByDisplayValue('16');
fireEvent.change(input, { target: { value: '24' } });
expect(onChange).not.toHaveBeenCalled();
// Enter triggers form submit which commits the value
fireEvent.submit(input.closest('form')!);
expect(onChange).toHaveBeenCalledWith(24);
});
it('commits value on blur', () => {
const onChange = vi.fn();
render(<NumberInput {...defaultProps} onChange={onChange} />);
const input = screen.getByDisplayValue('16');
fireEvent.change(input, { target: { value: '20' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenCalledWith(20);
});
it('clamps value to min/max on commit', () => {
const onChange = vi.fn();
render(<NumberInput {...defaultProps} onChange={onChange} />);
const input = screen.getByDisplayValue('16');
fireEvent.change(input, { target: { value: '100' } });
fireEvent.submit(input.closest('form')!);
expect(onChange).toHaveBeenCalledWith(72);
});
});
@@ -0,0 +1,185 @@
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ReadingRuler from '@/app/reader/components/ReadingRuler';
import { ViewSettings } from '@/types/book';
import { eventDispatcher } from '@/utils/event';
const saveViewSettings = vi.fn();
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ envConfig: {} }),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({
getProgress: () => null,
}),
}));
vi.mock('@/helpers/settings', () => ({
saveViewSettings: (...args: unknown[]) => saveViewSettings(...args),
}));
global.ResizeObserver = class ResizeObserver {
observe() {}
disconnect() {}
unobserve() {}
};
HTMLElement.prototype.setPointerCapture = vi.fn();
HTMLElement.prototype.releasePointerCapture = vi.fn();
describe('ReadingRuler', () => {
const viewSettings = {
defaultFontSize: 16,
lineHeight: 1.5,
} as ViewSettings;
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
get: () => 1000,
});
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get: () => 800,
});
HTMLElement.prototype.getBoundingClientRect = vi.fn(() => ({
x: 0,
y: 0,
top: 0,
left: 0,
right: 800,
bottom: 1000,
width: 800,
height: 1000,
toJSON: () => ({}),
}));
});
afterEach(() => {
cleanup();
});
it('keeps the ruler body pass-through and exposes dedicated drag handles', () => {
const { container } = render(
<ReadingRuler
bookKey='book-1'
isVertical={false}
rtl={false}
lines={2}
position={33}
opacity={0.5}
color='transparent'
bookFormat='EPUB'
viewSettings={viewSettings}
gridInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
/>,
);
const ruler = container.querySelector('.ruler');
const dragHandles = container.querySelectorAll('.cursor-row-resize');
expect(ruler?.className).toContain('pointer-events-none');
expect(dragHandles).toHaveLength(2);
dragHandles.forEach((handle) => {
expect(handle.className).toContain('pointer-events-auto');
});
});
it('moves and persists the ruler position when a ruler-step event is dispatched', async () => {
const { container } = render(
<ReadingRuler
bookKey='book-1'
isVertical={false}
rtl={false}
lines={2}
position={33}
opacity={0.5}
color='transparent'
bookFormat='EPUB'
viewSettings={viewSettings}
gridInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
/>,
);
eventDispatcher.dispatchSync('reading-ruler-move', {
bookKey: 'book-1',
direction: 'forward',
});
await waitFor(() => {
const ruler = container.querySelector('.ruler') as HTMLDivElement;
const overlays = container.querySelectorAll('.bg-base-100');
expect(ruler.style.top).toBe('37.8%');
expect(ruler.style.transition).toContain('top 0.6s');
expect(overlays[0]?.getAttribute('style')).toContain('transition: height 0.6s');
expect(overlays[1]?.getAttribute('style')).toContain('transition: height 0.6s');
expect(saveViewSettings).toHaveBeenCalledWith(
{},
'book-1',
'readingRulerPosition',
37.8,
false,
false,
);
});
});
it('keeps the drag handle anchored instead of snapping the ruler center to the pointer', () => {
const { container } = render(
<ReadingRuler
bookKey='book-1'
isVertical={false}
rtl={false}
lines={2}
position={33}
opacity={0.5}
color='transparent'
bookFormat='EPUB'
viewSettings={viewSettings}
gridInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
/>,
);
const ruler = container.querySelector('.ruler') as HTMLDivElement;
const topHandle = container.querySelector('.cursor-row-resize') as HTMLDivElement;
expect(ruler.style.top).toBe('33%');
fireEvent.pointerDown(topHandle, { pointerId: 1, clientY: 306 });
fireEvent.pointerMove(topHandle, { pointerId: 1, clientY: 316 });
expect(ruler.style.top).toBe('34%');
});
it('does not consume ruler movement when already at the boundary', () => {
render(
<ReadingRuler
bookKey='book-1'
isVertical={false}
rtl={false}
lines={2}
position={97.6}
opacity={0.5}
color='transparent'
bookFormat='EPUB'
viewSettings={viewSettings}
gridInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
/>,
);
const consumed = eventDispatcher.dispatchSync('reading-ruler-move', {
bookKey: 'book-1',
direction: 'forward',
});
expect(consumed).toBe(false);
expect(saveViewSettings).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import React from 'react';
import { StaticListRow } from '@/app/reader/components/sidebar/TOCItem';
import { TOCItem } from '@/libs/document';
@@ -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,180 @@
import { cleanup, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import useBookShortcuts from '@/app/reader/hooks/useBookShortcuts';
import { eventDispatcher } from '@/utils/event';
const shortcutState = {
actions: null as Record<
string,
((event?: KeyboardEvent | MessageEvent) => void) | undefined
> | null,
};
const mockView = {
book: { dir: 'ltr' },
prev: vi.fn(),
next: vi.fn(),
pan: vi.fn(),
renderer: {
scrolled: false,
setAttribute: vi.fn(),
},
history: {
back: vi.fn(),
forward: vi.fn(),
},
};
const currentViewSettings = {
defaultFontSize: 16,
lineHeight: 1.5,
readingRulerEnabled: true,
writingMode: 'horizontal-tb',
vertical: false,
rtl: false,
paragraphMode: { enabled: false },
};
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({
getView: () => mockView,
getViewState: () => ({ ttsEnabled: false }),
getViewSettings: () => currentViewSettings,
setViewSettings: vi.fn(),
}),
}));
vi.mock('@/store/sidebarStore', () => ({
useSidebarStore: () => ({
toggleSideBar: vi.fn(),
setSideBarBookKey: vi.fn(),
}),
}));
vi.mock('@/store/settingsStore', () => ({
useSettingsStore: () => ({
setSettingsDialogOpen: vi.fn(),
}),
}));
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: () => ({
getBookData: vi.fn(),
}),
}));
vi.mock('@/store/notebookStore', () => ({
useNotebookStore: () => ({
toggleNotebook: vi.fn(),
}),
}));
vi.mock('@/store/themeStore', () => ({
useThemeStore: () => ({
safeAreaInsets: null,
}),
}));
vi.mock('@/components/command-palette', () => ({
useCommandPalette: () => ({
open: vi.fn(),
}),
}));
vi.mock('@/hooks/useShortcuts', () => ({
default: (actions: typeof shortcutState.actions) => {
shortcutState.actions = actions;
},
}));
vi.mock('@/services/environment', () => ({
isTauriAppPlatform: () => false,
}));
vi.mock('@/utils/window', () => ({
tauriHandleClose: vi.fn(),
tauriHandleToggleFullScreen: vi.fn(),
tauriQuitApp: vi.fn(),
}));
vi.mock('@/utils/style', () => ({
getStyles: vi.fn(),
}));
vi.mock('@/services/constants', () => ({
MAX_ZOOM_LEVEL: 200,
MIN_ZOOM_LEVEL: 50,
ZOOM_STEP: 10,
}));
vi.mock('@/app/reader/hooks/useBooksManager', () => ({
default: () => ({
getNextBookKey: () => 'book-1',
}),
}));
const Harness = () => {
useBookShortcuts({ sideBarBookKey: 'book-1', bookKeys: ['book-1'] });
return null;
};
describe('useBookShortcuts', () => {
beforeEach(() => {
vi.clearAllMocks();
shortcutState.actions = null;
currentViewSettings.readingRulerEnabled = true;
currentViewSettings.writingMode = 'horizontal-tb';
currentViewSettings.vertical = false;
currentViewSettings.rtl = false;
currentViewSettings.paragraphMode.enabled = false;
mockView.book.dir = 'ltr';
});
afterEach(() => {
cleanup();
});
it('routes page-turn shortcuts to reading ruler movement when enabled', () => {
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatchSync').mockReturnValue(true);
render(<Harness />);
shortcutState.actions?.['onGoNext']?.();
expect(dispatchSpy).toHaveBeenCalledWith('reading-ruler-move', {
bookKey: 'book-1',
direction: 'forward',
});
expect(mockView.next).not.toHaveBeenCalled();
});
it('uses reading order when directional shortcuts are handled in rtl books', () => {
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatchSync').mockReturnValue(true);
mockView.book.dir = 'rtl';
render(<Harness />);
shortcutState.actions?.['onGoRight']?.();
expect(dispatchSpy).toHaveBeenCalledWith('reading-ruler-move', {
bookKey: 'book-1',
direction: 'backward',
});
});
it('falls back to normal page navigation when the ruler is disabled', () => {
currentViewSettings.readingRulerEnabled = false;
render(<Harness />);
shortcutState.actions?.['onGoNext']?.();
expect(mockView.next).toHaveBeenCalledWith(72);
});
it('falls back to normal page navigation when the ruler cannot move further', () => {
vi.spyOn(eventDispatcher, 'dispatchSync').mockReturnValue(false);
render(<Harness />);
shortcutState.actions?.['onGoNext']?.();
expect(mockView.next).toHaveBeenCalledWith(72);
});
});
@@ -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);
});
});
@@ -0,0 +1,292 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
import type { Renderer } from '@/types/view';
// repro-3683: cover page with display:table + position:absolute + width:100% on body
const REPRO_3683_URL = new URL('../fixtures/data/repro-3683.epub', import.meta.url).href;
// repro-3583: vertical writing mode with height/width:100% divs and SVG image
const REPRO_3583_URL = new URL('../fixtures/data/repro-3583.epub', import.meta.url).href;
const ALICE_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href;
const loadEPUB = async (url: string) => {
const resp = await fetch(url);
const buffer = await resp.arrayBuffer();
const name = url.split('/').pop() ?? 'book.epub';
const file = new File([buffer], name, { type: 'application/epub+zip' });
const loader = new DocumentLoader(file);
const { book } = await loader.open();
return book;
};
const waitForStabilized = (el: HTMLElement, timeout = 5000) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout);
el.addEventListener(
'stabilized',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
const waitForFillComplete = async (el: Renderer, timeout = 5000) => {
const start = Date.now();
let lastCount = -1;
let stableFor = 0;
while (Date.now() - start < timeout) {
const count = el.getContents().length;
if (count === lastCount) {
stableFor += 50;
if (stableFor >= 300) return;
} else {
stableFor = 0;
lastCount = count;
}
await new Promise((r) => setTimeout(r, 50));
}
};
let repro3683: BookDoc;
let repro3583: BookDoc;
let aliceBook: BookDoc;
/** Check all iframes and SVGs in shadow DOM stay within a size bound. */
const assertBoundedWidths = (paginator: Renderer, maxWidth: number) => {
const shadow = paginator.shadowRoot;
expect(shadow).toBeDefined();
for (const iframe of shadow!.querySelectorAll('iframe')) {
const w = parseFloat(iframe.style.width) || iframe.getBoundingClientRect().width;
expect(w, `iframe width ${w}px exceeds ${maxWidth}px`).toBeLessThan(maxWidth);
}
for (const svg of shadow!.querySelectorAll('svg')) {
const w = parseFloat(svg.style.width) || svg.getBoundingClientRect().width;
expect(w, `svg width ${w}px exceeds ${maxWidth}px`).toBeLessThan(maxWidth);
}
};
const assertBoundedHeights = (paginator: Renderer, maxHeight: number) => {
const shadow = paginator.shadowRoot;
expect(shadow).toBeDefined();
for (const iframe of shadow!.querySelectorAll('iframe')) {
const h = parseFloat(iframe.style.height) || iframe.getBoundingClientRect().height;
expect(h, `iframe height ${h}px exceeds ${maxHeight}px`).toBeLessThan(maxHeight);
}
for (const svg of shadow!.querySelectorAll('svg')) {
const h = parseFloat(svg.style.height) || svg.getBoundingClientRect().height;
expect(h, `svg height ${h}px exceeds ${maxHeight}px`).toBeLessThan(maxHeight);
}
};
describe('Paginator expand loop regression', () => {
let paginator: Renderer;
const suppressHandler = (e: ErrorEvent) => {
if (e.message?.includes('getComputedStyle')) e.preventDefault();
};
beforeAll(async () => {
window.addEventListener('error', suppressHandler);
[repro3683, repro3583, aliceBook] = await Promise.all([
loadEPUB(REPRO_3683_URL),
loadEPUB(REPRO_3583_URL),
loadEPUB(ALICE_URL),
]);
await import('foliate-js/paginator.js');
}, 30000);
const createPaginator = () => {
const el = document.createElement('foliate-paginator') as Renderer;
Object.assign(el.style, {
width: '800px',
height: '600px',
position: 'absolute',
left: '0',
top: '0',
});
document.body.appendChild(el);
return el;
};
afterEach(async () => {
if (paginator) {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
try {
paginator.destroy();
} catch {
/* iframe body may already be torn down */
}
paginator.remove();
}
window.removeEventListener('error', suppressHandler);
});
describe('Table-layout cover page (repro EPUB)', () => {
it('should stabilize without freezing on the problematic cover section', async () => {
paginator = createPaginator();
paginator.open(repro3683);
// Section 0 is the cover with display:table + position:absolute + width:100%
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: 0 });
await stabilized;
// If we reach here, the paginator didn't loop infinitely
expect(paginator.primaryIndex).toBe(0);
expect(paginator.getContents().length).toBeGreaterThanOrEqual(1);
});
it('should render the cover content correctly', async () => {
paginator = createPaginator();
paginator.open(repro3683);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: 0 });
await stabilized;
const contents = paginator.getContents();
const cover = contents.find((c) => c.index === 0);
expect(cover).toBeDefined();
expect(cover!.doc.body.textContent).toContain('COVER PAGE');
});
it('should navigate from cover to chapter without freezing', async () => {
paginator = createPaginator();
paginator.open(repro3683);
// Go to cover first
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: 0 });
await stabilized;
await waitForFillComplete(paginator);
// Navigate to chapter 1 — it may already be loaded as an adjacent
// section during fill, so goTo reuses the view without emitting
// 'stabilized'. Just await goTo and verify the result.
await paginator.goTo({ index: 1 });
// Allow layout to settle
await new Promise((r) => setTimeout(r, 200));
expect(paginator.primaryIndex).toBe(1);
const contents = paginator.getContents();
const ch1 = contents.find((c) => c.index === 1);
expect(ch1).toBeDefined();
expect(ch1!.doc.body.textContent).toContain('Hello, world.');
});
it('should stabilize the cover in scrolled mode', async () => {
paginator = createPaginator();
paginator.open(repro3683);
paginator.setAttribute('flow', 'scrolled');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: 0 });
await stabilized;
expect(paginator.scrolled).toBe(true);
expect(paginator.primaryIndex).toBe(0);
});
it('should keep element sizes bounded in paginated mode', async () => {
// Without the fix, expand() diverges: body (position:absolute,
// width:100%) mirrors the iframe width, each expand computes an
// even larger expandedSize. Iframes/SVGs grow to millions of px.
paginator = createPaginator();
paginator.open(repro3683);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: 0 });
await stabilized;
await waitForFillComplete(paginator);
// Let ResizeObserver cycles run — divergence would explode here
await new Promise((r) => setTimeout(r, 500));
assertBoundedWidths(paginator, 800 * 20);
assertBoundedHeights(paginator, 600 * 20);
});
});
describe('Vertical writing mode with 100% divs in scrolled mode (repro-3583)', () => {
it('should stabilize without freezing', async () => {
paginator = createPaginator();
paginator.open(repro3583);
paginator.setAttribute('flow', 'scrolled');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: 0 });
await stabilized;
expect(paginator.scrolled).toBe(true);
expect(paginator.primaryIndex).toBe(0);
});
it('should keep element sizes bounded', async () => {
paginator = createPaginator();
paginator.open(repro3583);
paginator.setAttribute('flow', 'scrolled');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: 0 });
await stabilized;
await waitForFillComplete(paginator);
await new Promise((r) => setTimeout(r, 500));
assertBoundedWidths(paginator, 800 * 20);
assertBoundedHeights(paginator, 600 * 20);
});
});
describe('Normal EPUB regression check', () => {
it('should stabilize normally on a regular section', async () => {
paginator = createPaginator();
paginator.open(aliceBook);
const idx = aliceBook.sections!.findIndex((s) => s.linear !== 'no');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
expect(paginator.primaryIndex).toBe(idx);
expect(paginator.getContents().length).toBeGreaterThanOrEqual(1);
});
it('should allow legitimate re-expansion after render', async () => {
paginator = createPaginator();
paginator.open(aliceBook);
const idx = aliceBook.sections!.findIndex((s) => s.linear !== 'no');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
await waitForFillComplete(paginator);
// Re-render should work (expand history resets on layout changes)
const stabilized2 = waitForStabilized(paginator);
paginator.render?.();
await stabilized2;
expect(paginator.primaryIndex).toBe(idx);
});
it('should handle flow mode switch with convergence detection', async () => {
paginator = createPaginator();
paginator.open(aliceBook);
const idx = aliceBook.sections!.findIndex((s) => s.linear !== 'no');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
await waitForFillComplete(paginator);
// Switch to scrolled — triggers full re-layout
const stabilized2 = waitForStabilized(paginator);
paginator.setAttribute('flow', 'scrolled');
await stabilized2;
expect(paginator.scrolled).toBe(true);
expect(paginator.primaryIndex).toBe(idx);
});
});
});
@@ -1,34 +1,11 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
import type { FoliateView } from '@/types/view';
import type { FoliateView, Renderer } from '@/types/view';
// Vite serves fixture files; fetch the EPUB at runtime in the browser.
const EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href;
interface PaginatorElement extends HTMLElement {
open: (book: BookDoc) => void;
goTo: (target: {
index: number;
anchor?: number | (() => number);
select?: boolean;
}) => Promise<void>;
prev: () => Promise<void>;
next: () => Promise<void>;
destroy: () => void;
getContents: () => Array<{ index: number; doc: Document; overlayer: unknown }>;
setStyles: (styles: string | [string, string]) => void;
render: () => void;
primaryIndex: number;
pages: number;
page: number;
size: number;
viewSize: number;
scrolled: boolean;
columnCount: number;
sections: Array<{ linear?: string; load: () => Promise<string> }>;
}
let book: BookDoc;
const loadEPUB = async () => {
@@ -59,7 +36,7 @@ const waitForStabilized = (el: HTMLElement, timeout = 10000) =>
});
/** Wait until `getContents().length >= n` or timeout. */
const waitForViews = async (el: PaginatorElement, n: number, timeout = 10000) => {
const waitForViews = async (el: Renderer, n: number, timeout = 10000) => {
const start = Date.now();
while (Date.now() - start < timeout) {
if (el.getContents().length >= n) return;
@@ -68,7 +45,7 @@ const waitForViews = async (el: PaginatorElement, n: number, timeout = 10000) =>
};
/** Wait for fill to complete by polling until getContents count stabilizes. */
const waitForFillComplete = async (el: PaginatorElement, timeout = 10000) => {
const waitForFillComplete = async (el: Renderer, timeout = 10000) => {
const start = Date.now();
let lastCount = -1;
let stableFor = 0;
@@ -86,7 +63,7 @@ const waitForFillComplete = async (el: PaginatorElement, timeout = 10000) => {
};
describe('Paginator multi-view architecture (browser)', () => {
let paginator: PaginatorElement;
let paginator: Renderer;
beforeAll(async () => {
book = await loadEPUB();
@@ -94,7 +71,7 @@ describe('Paginator multi-view architecture (browser)', () => {
}, 30000);
const createPaginator = () => {
const el = document.createElement('foliate-paginator') as PaginatorElement;
const el = document.createElement('foliate-paginator') as Renderer;
// The paginator needs non-zero dimensions for layout calculations
Object.assign(el.style, {
width: '800px',
@@ -210,7 +187,7 @@ describe('Paginator multi-view architecture (browser)', () => {
await waitForViews(paginator, 2);
const contents = paginator.getContents();
const indices = contents.map((c) => c.index);
expect(indices).toEqual([...indices].sort((a, b) => a - b));
expect(indices).toEqual([...indices].sort((a, b) => (a ?? 0) - (b ?? 0)));
});
it('should include the primary section in getContents', async () => {
@@ -254,8 +231,14 @@ describe('Paginator multi-view architecture (browser)', () => {
await paginator.goTo({ index: idx, anchor: 0.5 });
await stabilized;
expect(paginator.primaryIndex).toBe(idx);
// When anchored at 0.5 in a multi-page section, page > 0
expect(paginator.page).toBeGreaterThan(0);
// When anchored at 0.5 in a multi-page section, page should be
// roughly in the middle — at least past page 0 when there are
// enough pages (sections with few columns may legitimately land
// on page 0 at 50%).
if (paginator.pages > 2) {
expect(paginator.page).toBeGreaterThan(0);
}
expect(paginator.page).toBeLessThan(paginator.pages);
});
it('should navigate to a later section and back', async () => {
@@ -235,10 +235,12 @@ describe('Paginator multi-view architecture', () => {
{ index: 5, offset: 150, size: 200 },
];
const detectPrimary = (visibleStart: number) => {
let offset = 0;
for (const view of views) {
if (visibleStart < view.offset + view.size) {
if (visibleStart < offset + view.size - 1) {
return view.index;
}
offset += view.size;
}
return views[views.length - 1]?.index;
};
@@ -249,6 +251,67 @@ describe('Paginator multi-view architecture', () => {
expect(detectPrimary(150)).toBe(5); // at boundary → third view
expect(detectPrimary(300)).toBe(5); // in third view
});
it('should handle floating-point precision at view boundaries (Android DPR)', () => {
// On Android with non-integer DPR, container scrollLeft and accumulated
// getBoundingClientRect() sizes diverge by tiny amounts (~0.0001px).
// Real trace: visibleStart=1570.9090576171875, accumulated offset+viewSize=1570.9091796875
// Fix: 1px tolerance so sub-pixel drift doesn't pick the wrong view.
const viewSize = 785.45458984375;
const detectPrimary = (visibleStart: number) => {
let offset = 0;
const views = [
{ index: 1, size: viewSize },
{ index: 2, size: viewSize },
{ index: 3, size: viewSize },
];
for (const view of views) {
if (visibleStart < offset + view.size - 1) {
return view.index;
}
offset += view.size;
}
return views[views.length - 1]?.index;
};
// visibleStart from scrollLeft: 2 * viewSize computed differently than sum of rects
const visibleStart = 1570.9090576171875; // container.scrollLeft
// 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', () => {
@@ -1,34 +1,11 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
import type { Renderer } from '@/types/view';
// Vite serves fixture files; fetch the EPUB at runtime in the browser.
const EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href;
interface PaginatorElement extends HTMLElement {
open: (book: BookDoc) => void;
goTo: (target: {
index: number;
anchor?: number | (() => number);
select?: boolean;
}) => Promise<void>;
prev: () => Promise<void>;
next: () => Promise<void>;
destroy: () => void;
getContents: () => Array<{ index: number; doc: Document; overlayer: unknown }>;
setStyles: (styles: string | [string, string]) => void;
render: () => void;
scrollToAnchor: (anchor: number | Range, select?: boolean, smooth?: boolean) => Promise<void>;
primaryIndex: number;
pages: number;
page: number;
size: number;
viewSize: number;
scrolled: boolean;
columnCount: number;
sections: Array<{ linear?: string; load: () => Promise<string> }>;
}
let book: BookDoc;
const loadEPUB = async () => {
@@ -59,7 +36,7 @@ const waitForStabilized = (el: HTMLElement, timeout = 10000) =>
});
/** Wait until `getContents().length >= n` or timeout. */
const waitForViews = async (el: PaginatorElement, n: number, timeout = 10000) => {
const waitForViews = async (el: Renderer, n: number, timeout = 10000) => {
const start = Date.now();
while (Date.now() - start < timeout) {
if (el.getContents().length >= n) return;
@@ -68,7 +45,7 @@ const waitForViews = async (el: PaginatorElement, n: number, timeout = 10000) =>
};
/** Wait for fill to complete by polling until getContents count stabilizes. */
const waitForFillComplete = async (el: PaginatorElement, timeout = 10000) => {
const waitForFillComplete = async (el: Renderer, timeout = 10000) => {
const start = Date.now();
let lastCount = -1;
let stableFor = 0;
@@ -86,7 +63,7 @@ const waitForFillComplete = async (el: PaginatorElement, timeout = 10000) => {
};
describe('Paginator stabilization (browser)', () => {
let paginator: PaginatorElement;
let paginator: Renderer;
// Suppress unhandled errors from paginator's #replaceBackground firing
// after views are destroyed (queued iframe loads from rapid navigation).
@@ -106,7 +83,7 @@ describe('Paginator stabilization (browser)', () => {
});
const createPaginator = () => {
const el = document.createElement('foliate-paginator') as PaginatorElement;
const el = document.createElement('foliate-paginator') as Renderer;
Object.assign(el.style, {
width: '800px',
height: '600px',
@@ -255,7 +232,7 @@ describe('Paginator stabilization (browser)', () => {
await waitForFillComplete(paginator);
const stabilizedFromRender = waitForStabilized(paginator);
paginator.render();
paginator.render?.();
// render() dispatches stabilized in a RAF
await stabilizedFromRender;
// If we get here, stabilized was emitted
@@ -269,7 +246,7 @@ describe('Paginator stabilization (browser)', () => {
const indexBefore = paginator.primaryIndex;
const stabilized = waitForStabilized(paginator);
paginator.render();
paginator.render?.();
await stabilized;
expect(paginator.primaryIndex).toBe(indexBefore);
});

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