Compare commits

...

138 Commits

Author SHA1 Message Date
Huang Xin 0177a03a90 fix(tts): properly follow tts reading position in scrolled mode (#3581) 2026-03-21 18:08:45 +01:00
Huang Xin f5df80f154 feat(koplugin): support sync bookmarks between Koreader and Readest devices (#3580) 2026-03-21 17:58:38 +01:00
Huang Xin 76b239f382 feat(koplugin): add support for annotation sync (#3579)
Add bidirectional annotation/highlight sync between KOReader and Readest:

- Add xpointer0/xpointer1 fields to BookNote and DBBookNote types for
  KOReader XPointer positions alongside Readest's CFI format
- Extend transform layer to pass through xpointer fields to/from DB
- Convert CFI→XPointer on push and XPointer→CFI on pull in useNotesSync,
  discarding notes that fail conversion
- Support KOReader's text()[K].N indexed text node format in xcfi.ts for
  paragraphs with inline elements (e.g. <a> page anchors)
- Generate KOReader-compatible XPointers: text().N for single text nodes,
  text()[K].N only when multiple direct text nodes exist
- Skip cfi-inert elements (injected by Readest at runtime) in XPointer
  path building and resolution
- Map highlight colors between KOReader and Readest color systems
- Implement KOReader plugin annotation push/pull with deterministic IDs,
  auto-sync on document open/close, and UIManager refresh on pull
- Refactor koplugin into focused modules: syncauth, syncconfig,
  syncannotations, selfupdate

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:43:51 +01:00
Huang Xin 2f430bf2ff feat(koplugin): add self-update check and install from menu (#3575)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 08:16:50 +01:00
Huang Xin 0e8605d298 feat(reader): import Foliate annotations on book open (Linux only), closes #2180 (#3574)
* feat(reader): import Foliate annotations on book open (Linux only), closes #2180

Automatically imports annotations, bookmarks, and reading progress from
Foliate's data files when opening a book on Linux. Uses foliateImportedAt
flag to prevent re-importing on subsequent opens.

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

* refactor(annotation): move Foliate import into annotation provider pattern

Restructure annotation import as a multi-provider service following the
translators pattern. Foliate becomes a provider under
services/annotation/providers/, making it easy to add more import sources.
Each provider controls its own availability check and skip-if-imported guard.

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-21 05:04:46 +01:00
Huang Xin 7445d5befe feat(library): add batch refresh metadata option, closes #3294 (#3573)
Add a "Refresh Metadata" option under Advanced Settings that re-parses
book files to update metadata (series info, language, etc.) for books
imported before these fields were supported.

- Add refreshBookMetadata() in bookService that opens a book file,
  re-parses with DocumentLoader, and updates metadata/metaHash/series
  without touching updatedAt or user-edited fields
- Add menu item in SettingsMenu with progress display
- Add unit tests for single-book metadata refresh
- Translate new i18n strings across all 29 locales

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 19:32:09 +01:00
Huang Xin e41bcfbac9 fix(txt): fix chapter detection for bare numbered headings, closes #3570 (#3572)
Three bugs in createChapterRegexps for English txt-to-epub conversion:

1. numberPattern had a capturing group (\d+|...) that leaked single
   digits into split() results instead of chapter titles. Changed to
   non-capturing (?:\d+|...).

2. combinedPattern wrapped the heading in (?:...) so split() consumed
   the title without returning it. Changed to (...) capturing group,
   matching the Chinese regex structure.

3. The prefix (?:^|\n|\s) allowed matching Chapter/Section after any
   whitespace, causing mid-sentence false positives. Changed to
   (?:^|\n) for line-start only.

Added a second-tier regex for bare numbered headings (e.g. "1.1The
Elements of Programming", "1Building Abstractions") commonly found
in academic texts. Dotted numbers allow optional space before title;
single bare digits require immediate title to avoid footnote matches.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 19:00:33 +01:00
Huang Xin 91bc4ddec7 feat(library): backup to and restore from a zip file (#3571) 2026-03-20 18:27:52 +01:00
Huang Xin e8f70b896e chore: bump next.js from 16.1.6 to 16.1.7 (#3569) 2026-03-19 18:11:26 +01:00
Huang Xin 1f4f5f8a6d feat(annotator): responsive layout for the loupe (#3568) 2026-03-19 17:54:24 +01:00
Huang Xin 764acf59de chore(agent): add project memory (#3567) 2026-03-19 17:46:45 +01:00
Huang Xin eab83c6d3f feat(annotator): show the loupe when selecting text in instant annotation, closes #3539 (#3565) 2026-03-19 15:10:33 +01:00
dependabot[bot] 7f62cdcab9 chore(deps): bump pnpm/action-setup in the github-actions group (#3564) 2026-03-19 13:06:00 +08:00
Huang Xin e4ab06abcd fix(footnote): don't preload other sections in footnotes (#3563) 2026-03-19 04:10:16 +01:00
Huang Xin fef0aa6947 fix(perf): revert back next/image with lazying loading for much better perf (#3562) 2026-03-18 14:49:06 +01:00
Huang Xin 2935eacb1c fix(layout): fixed header background color on mobile devices (#3561) 2026-03-18 13:08:43 +01:00
Huang Xin ad9bb58cd4 fix(ios): resolve stale closure crash preventing highlight popup on iOS (#3559) 2026-03-18 10:11:13 +01:00
Huang Xin 871d6467c3 fix(layout): more responsive layout for the transfer queue (#3558) 2026-03-18 06:32:55 +01:00
Huang Xin 3f19ce24de chore(agent): add gstack skills (#3557) 2026-03-18 06:08:48 +01:00
Huang Xin 8280585e70 compat(css): fix table layout regression issue, closes #3551 (#3556) 2026-03-17 17:52:07 +01:00
Huang Xin 1f7483b911 feat: scroll to the nearest item in the search results or annotation lists (#3555) 2026-03-17 16:01:56 +01:00
Huang Xin b2bb92036b fix(ios): decode percent-encoded file URIs in copy_uri_to_path (#3553) 2026-03-17 13:17:42 +01:00
Huang Xin 3831f24ff7 fix(android): fix page navigation layer z-index, closes #3511 (#3552) 2026-03-17 09:52:21 +01:00
Huang Xin 3bec89c85d fix(progress): show remaining pages for current section instead of all loaded sections (#3550) 2026-03-17 09:18:39 +01:00
Huang Xin 3dfe6bd65e fix(layout): fix parallel read layout on smaller screens (#3549) 2026-03-17 07:34:55 +01:00
Huang Xin b00d321817 refactor(reader): extract shared panel drag hooks and add vertical drag to Notebook (#3548)
Extract useSwipeToDismiss and usePanelResize hooks from duplicated code
in SideBar and Notebook. Add mobile swipe-to-dismiss drag handle to
Notebook matching SideBar's existing behavior. Fix drag cursor showing
col-resize instead of row-resize during vertical drags.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 04:27:27 +01:00
Huang Xin 1af55e24da feat(epub): support continuous scroll and spread layout for EPUBs, closes #3419 and closes #1745 (#3546) 2026-03-16 18:24:07 +01:00
Huang Xin f7b2a2432c fix(export): apply block quote syntax to every line in annotation export, closes #3534 (#3536)
Add formatBlockQuote utility and blockquote nunjucks filter so both
default and custom template exports prefix every line with `>`.
Document all available formatters in the custom template help panel.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 15:23:17 +01:00
Huang Xin db3dee025b feat(booshelf): dedupe books with the same meta hash (#3535) 2026-03-14 15:14:22 +01:00
Huang Xin 25352efece chore: bump dependencies for Dependabot alerts (#3533) 2026-03-14 06:57:43 +01:00
Huang Xin 1297e33c62 doc(agent): add rules files that are automatically loaded into context alongside CLAUDE.md (#3532) 2026-03-14 04:37:41 +01:00
Huang Xin 8274c6bc4f feat(node): refactor appService into focused service modules and add app service for Node runtime (#3530)
This also closes #3431.
2026-03-13 17:51:35 +01:00
Huang Xin ed5bbd25d6 fix(opds): more accurate error message when downloading books, closes #2656 (#3529) 2026-03-13 08:26:54 +01:00
Huang Xin d06e1849cc feat(rsvp): add persistent context, display settings, and layout improvements, closes #3333 (#3528)
* feat(rsvp): add persistent context, display settings, and layout improvements, closes #3333

Add always-visible collapsible context panel, font size adjustment,
ORP color selection, and stable context window that only rebuilds on
scroll. Refactor speed controls into playback row with collapsible
settings behind a gear icon. Translate new i18n keys across 29 locales.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(selfhost): update docker db schema to match FileRecords (#3527)

In #2636, FileRecord was defined to have updated_at  field
which is used when it is accessed from the database. But the
local dev setup is missing this field.

This diff adds an updated_at column to match the expectation

* chore(ci): cache system dependency and rustc outputs in ci

* chore(ci): lint script changed from tsc to tsgo

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Azeem Bande-Ali <me@azeemba.com>
2026-03-13 06:57:47 +01:00
Azeem Bande-Ali 34ad6e6749 fix(selfhost): update docker db schema to match FileRecords (#3527)
In #2636, FileRecord was defined to have updated_at  field
which is used when it is accessed from the database. But the
local dev setup is missing this field.

This diff adds an updated_at column to match the expectation
2026-03-13 05:15:55 +01:00
Huang Xin b163012488 fix(link): prevent opening duplicate browser tabs on Tauri, closes #3514 (#3525) 2026-03-12 17:29:04 +01:00
Huang Xin f9a3559086 fix(library): improve sorting and grouping interaction, closes #3517 (#3524)
- Sort author groups by last name instead of first name when sorting by author
- Sort series groups by book author instead of series name when sorting by author
- Place ungrouped books before custom groups instead of mixing them together

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:59:50 +01:00
Huang Xin 674a0bf16d compat(footnote): support more footnote selectors, closes #3515 (#3523) 2026-03-12 16:46:57 +01:00
Huang Xin 9b53ccc9de fix(i18n): handle POSIX locale values in getLocale(), closes #3518 (#3522)
When LANG=C.UTF-8, navigator.language can return 'C' which is not a
valid BCP 47 tag, causing Intl.NumberFormat and toLocaleString to throw.
Normalize POSIX values (C, C.UTF-8, POSIX) to 'en-US' at the source.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:36:48 +01:00
Huang Xin 7a605a357a feat(pdf): support pitch to zoom and scroll mode for PDFs, closes #3461 (#3520) 2026-03-12 16:07:55 +01:00
Huang Xin af649edd0d fix(pdf): show search results when navigating to new page, closes #3148 (#3513) 2026-03-11 17:55:58 +01:00
Huang Xin 64a71e25e4 fix(layout): make horizontal scroll bar visible in fixed-layout EPUB, closes #3506 (#3512)
And fix some text not shown in fixed-layout EPUB.
2026-03-11 15:03:05 +01:00
Huang Xin 51a3767940 feat(a11y): show prev/next section buttons for screen reader, closes #3290 (#3511) 2026-03-11 13:33:36 +01:00
Huang Xin a8572ab7c6 feat(reader): tap notch area to scroll to top in scrolled mode, closes #3474 (#3510) 2026-03-11 18:20:38 +08:00
Huang Xin 907b672313 fix(a11y): improve TOC screen reader accessibility for NVDA, closes #3477 (#3509) 2026-03-11 14:30:01 +08:00
Huang Xin 8df9f909b4 fix(reader): add Always on Top toggle to reader view, closes #3482 (#3505)
- Apply alwaysOnTop setting on reader window init so books opened
  in a new window correctly inherit the setting
- Update tauriHandleSetAlwaysOnTop to apply to all open windows
  so toggling from any window keeps all windows in sync

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 13:15:08 +01:00
Huang Xin bcd7a90f27 fix(layout): add top/bottom margin to container in scrolled mode, closes #3463 (#3504)
* fix(layout): add top/bottom margin to container in scrolled mode, closes #3463

When showMarginsOnScroll is enabled, the paginator margins are set to 0
but the FoliateViewer container had no compensating padding, causing
header/footer bars to overlap content. Now the container padding aligns
content top to the bottom of the header bar (gridInsets.top + 44px) and
content bottom to the top of the footer bar (52px + safe area padding).
Also fixes the footer bar height constant from 44 to 52 to match
ProgressBar's actual h-[52px].

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(ci): cache all crates including local path deps in Tauri build

Add cache-all-crates: 'true' to Swatinem/rust-cache so that vendored
local path dependencies (packages/tauri/crates/, packages/tauri-plugins/)
are cached between CI runs instead of being recompiled from scratch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:40:03 +01:00
Huang Xin 5646e0cfb5 fix(txt): add stream() to RemoteFile to fix large TXT import on desktop, closes #3495 (#3502)
RemoteFile (used on all desktop platforms) extended File([]) with empty
data and overrode slice(), text(), arrayBuffer() but not stream(). The
large file path (>8MB) introduced in #3320 uses file.stream() to read
content incrementally, which returned empty data from the base File
class, causing "No chapters detected" for large TXT files.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:00:44 +01:00
Huang Xin fad27d74a0 compat(android): show button navigation bar on Android, closes #3466 (#3501) 2026-03-09 17:36:13 +01:00
Huang Xin 5fbbfa523b chore(ci): fix rust cache (#3500) 2026-03-09 15:32:01 +01:00
Huang Xin 569c6707a4 fix(macOS): fix traffic light visibility in sidebar, closes #3488 (#3499) 2026-03-09 15:10:13 +01:00
Huang Xin 99f8a29326 fix(css): apply Line Spacing to list elements, closes #3494 (#3498) 2026-03-09 14:01:44 +01:00
Huang Xin 04737d6f35 fix(layout): update safe insets in reader page, closes #3469 (#3497) 2026-03-09 13:54:03 +01:00
Huang Xin 8850e6c00f feat(pdf): support TTS and annotation on PDFs, closes #2149 & #3462 (#3493)
* chore: bump jsdom to the latest version

* feat(pdf): support TTS and annotation on PDFs, closes #2149 & closes #3462
2026-03-09 10:28:19 +01:00
Huang Xin 93b96d64eb fix(sidebar): use position fixed and transform for mobile sidebar (#3490)
* chore: bump nodejs version to 24

* fix(sidebar): use position fixed and transform for mobile sidebar

Use position: fixed to prevent horizontal scrolling on the mobile
bottom sheet, and replace style.top with transform: translateY() for
smooth drag performance. Cache element refs to avoid
document.querySelector on every drag frame. Apply the same position:
fixed fix to the notebook panel. Closes #3492

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:04:37 +01:00
srsng 9f8894c1e0 feat(setting): add an option to hide Scrollbar in scroll mode, closes #3480
* feat(setting) impl settings option: hide Scrollbar of scroll mode

* refactor(setting): disable hide scrollbar in paginated mode, add i18n translations

- Add disabled prop to Hide Scrollbar toggle when not in scroll mode
- Remove unnecessary `as const` assertion on string literal
- Add "Hide Scrollbar" translations for all 28 locales

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 05:21:58 +01:00
Huang Xin dac4f2e8ec chore: experimental vinext build (#3486) 2026-03-06 18:04:28 +01:00
Huang Xin 54bc1514df feat(database): add platform-agnostic schema migration system (#3485) 2026-03-06 20:07:26 +08:00
Huang Xin 97cab2d70b feat(database): support turso fts in tauri apps (#3484) 2026-03-05 21:00:15 +01:00
Huang Xin 13588b4a65 chore(testing): add Tauri integration tests and E2E test infrastructure (#3483)
Set up WebDriver-based testing for the Tauri app with two tiers:
- Vitest browser-mode tests (*.tauri.test.ts) running inside the Tauri WebView
  for plugin IPC testing (libsql, smoke tests)
- WDIO E2E tests (*.e2e.ts) for UI-level interaction testing

Key changes:
- Add webdriver Cargo feature gating tauri-plugin-webdriver
- Add runtime capability for remote URLs (webdriver builds only)
- Add vitest.tauri.config.mts and wdio.conf.ts connecting to embedded
  WebDriver server on port 4445
- Add shared tauri-invoke helper for IPC from Vitest iframe context
- Add testing documentation in docs/testing.md
2026-03-05 19:14:01 +01:00
Huang Xin 0f5bd5ca1c fix(footnote): add overflow-wrap to footnote popup to prevent long words from overflowing (#3479)
The change adds overflow-wrap: break-word to the footnote popup body styles, which ensures long unbreakable strings (like URLs or long words) wrap properly instead of overflowing
the popup container.
2026-03-05 10:49:28 +01:00
Huang Xin a74c1a56a9 fix(metadata): parse series info from epub only when book series info not available (#3478) 2026-03-05 09:53:28 +01:00
Huang Xin 1e9bd1d821 chore(database): unit testing and feature detect for fts and vector search (#3476) 2026-03-05 07:41:16 +01:00
Huang Xin 02cc0a9ebb chore: bump turso to 0.5.0 (#3475) 2026-03-04 19:54:58 +01:00
Huang Xin 5273ef75dc feat(database): add database service abstraction with libsql/turso backend (#3472) 2026-03-05 02:38:23 +08:00
Blyrium bd957a4eb8 i18n: added <0> and <1> tags for SL (#3460) 2026-03-04 03:06:00 +01:00
scinac 38552a0c2e feat(reader): adding current Time and Battery to Footer (#3306) (#3402)
* added current time to desktop bar

* added time prototype to footer, needs code cleanup and settings toggle

* fixed settings toggle, added translations and code cleanup

* added battery support and moved Statusbar to own Component

* #3306 added 24 hour clock support

* refactored code styling and getting rid of any type in battery hook

* Add battery info for Tauri Apps

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-03-03 17:10:13 +01:00
Huang Xin 0609e828b1 fix(css): unset hardcoded calibre color and background color, closes #3448 (#3457) 2026-03-03 11:06:03 +01:00
Huang Xin 35ade2c855 fix(tts): handle documents without lang attribute or XHTML namespace, closes #3291 (#3456) 2026-03-03 10:35:33 +01:00
Huang Xin b68c14da1f i18n: add translations for Slovenian(sl), closes #3453 (#3455) 2026-03-03 07:20:59 +01:00
Huang Xin 5c41394961 feat(footnote): make popup window size more responsive for longer footnotes, closes #3425 (#3454) 2026-03-03 07:09:34 +01:00
Huang Xin 5685944599 fix(css): unset padding and margin in body, closes #3441 (#3452) 2026-03-03 04:52:21 +01:00
Huang Xin 94e761f681 fix(txt): more robust chapter extractor for TXT (#3446) 2026-03-02 18:20:50 +01:00
Huang Xin 7f636a2072 fix(toc): correct TOC grouping to prevent unnecessary nested layers (#3445) 2026-03-02 16:44:02 +01:00
Huang Xin 480b8b98a3 fix(css): properly constrain the max width and height of images, closes #3432 (#3444) 2026-03-02 16:20:01 +01:00
Huang Xin d1fb67316f compat(css): support duokan-page-fullscreen in spine to display cover image in fullscreen, closes #3424 (#3443) 2026-03-02 15:16:04 +01:00
Huang Xin f0e9ddd2ae feat(font): support loading custom font if embedded fonts in EPUBs are missing (#3439) 2026-03-02 09:18:18 +01:00
IGCFck b16a4445ae fix(opds): handle non-ASCII login details (#3436) 2026-03-02 07:01:47 +08:00
StepanSad 515d47e64d Update translation.json (#3423)
* Update translation.json

* Update translation for pages left in chapter

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-03-01 17:10:07 +01:00
Huang Xin 3fd78c4ed8 fix(gallery): support displaying svg image in image gallery mode, closes #3427 (#3435) 2026-03-01 17:08:51 +01:00
bfcs 152a95941a fix(translation): handle missing Cloudflare context on non-Cloudflare deployments (e.g., Vercel) (#3433)
* fix(translation): handle missing Cloudflare context on non-Cloudflare deployments (e.g., Vercel)

* Simplify error handling for Cloudflare context

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-03-01 16:40:45 +01:00
Huang Xin bf5805910d feat(footnote): add back navigation for footnote popup with link history, closes #3420 (#3434) 2026-03-01 16:30:59 +01:00
Huang Xin 431d14f4a4 fix(layout): respect grid insets for the zoom controller, closes #3426 (#3430) 2026-03-01 09:58:54 +01:00
Huang Xin 0d2e5b7c76 fix(translation): reduce initial layout shift in the translation view, closes #3078 (#3428)
* fix(css): allow overriding the padding of the root html

* fix(translation): reduce initial layout shift in the translation view, closes #3078
2026-03-01 09:12:02 +01:00
bfcs e949476d27 fix(translation): resolve DeepL translation failure with auto-detection (#3412)
* fix(translation): correctly handle DeepL source_lang 'AUTO' by omitting it

* refactor: backward compatibility

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-03-01 06:03:52 +01:00
Huang Xin 0afff573a1 chore: bump dependencies to resolve Dependabot alerts (#3421) 2026-02-28 18:31:33 +01:00
Huang Xin d4bb61f12b fix(layout): responsive layout for OPDS catalogs and download button (#3418) 2026-02-28 17:07:20 +01:00
Huang Xin 09c45b4615 fix(linux): avoid transitions API on WebKitGTK on Linux (#3417) 2026-02-28 16:35:39 +01:00
Huang Xin d8eef87bf0 chore(agents): add AGENTS.md for readest-app (#3415) 2026-02-28 15:30:31 +01:00
Huang Xin 66d2fdf999 release: version 0.9.101 (#3410) 2026-02-28 09:50:23 +01:00
Huang Xin d8e0ceeff1 fix(annotator): add page number in highlight export to Readwise (#3409) 2026-02-28 09:45:09 +01:00
Huang Xin f3ad97b989 fix(opds): add missing book description in OPDS feed (#3408) 2026-02-28 09:30:56 +01:00
Huang Xin 1bb49ab023 fix(tts): dispose of the TTS view when shutting down the TTS controller, closes #3400 (#3406) 2026-02-28 08:58:02 +01:00
bfcs f9a0b39586 fix: respect fixed translation quota in UI stats and DeepL provider (#3404) 2026-02-28 08:10:31 +01:00
Huang Xin c533da498d feat(annotator): add page number for annotations, closes #3082 (#3405)
* feat(annotator): add page number in annotation

* feat: add page number for annotations, closes #3082
2026-02-28 08:08:56 +01:00
Huang Xin b50bc0b854 fix: make touchpad scrolling respect the system’s natural scrolling settings, closes #3127 (#3398) 2026-02-27 07:24:49 +01:00
Huang Xin 96c465931c fix(toc): fix phantom subchapter TOC item (#3397) 2026-02-27 07:23:12 +01:00
Huang Xin 2bd54ac236 fix(tts): also show highlight when navigating in paused mode and improve abbreviations processing (#3396)
Closes #3317.
2026-02-27 06:46:27 +01:00
Huang Xin 6ad549d13c fix(iOS): correct sidebar insets on iPad and resolve occasional stale safe area inset on iOS (#3395) 2026-02-27 05:43:45 +01:00
Blyrium 67249370c9 fix(font): fix generic font family keywords bypassing user font settings (#3394)
* Fix generic font families resolving incorrectly

Replace `serif`, `sans-serif`, and `monospace` keywords in epub stylesheets with their corresponding CSS variables (`var(--serif)`, `var(--sans-serif)`, `var(--monospace)`), ensuring the user's configured fonts are always used.
Fixes https://github.com/readest/readest/issues/3334

* Got rid of the lookbehind

But now we're using a placeholder.
2026-02-27 08:55:24 +08:00
Huang Xin fe3ab011ca fix(tts): set document lang attribute when missing or invalid for TTS, closes #3291 (#3393) 2026-02-26 17:17:51 +01:00
Huang Xin 6cb4278b98 fix: fixed all progress at the last page of the book, closes #3383 (#3391) 2026-02-26 14:01:42 +01:00
Huang Xin 51468862a2 fix(ui): show progress 100% at the last page, closes #3383 (#3390) 2026-02-26 11:08:21 +01:00
Huang Xin b3a44d066f fix(layout): enlarge hit area for slider input on iOS, closes #3382 (#3389) 2026-02-26 09:26:28 +01:00
Huang Xin 68d4538d40 fix(layout): float the annotation navigation bar, closes #3386 (#3387) 2026-02-26 06:56:29 +01:00
Roy Zhu 80105af839 fix(txt): stabilize iOS large TXT import and decode picker paths (#3320)
* fix: add missing TXT worker files for large import path

* fix(ios): decode import paths and stabilize large TXT conversion

* style(txt): run prettier on TXT conversion files

* fix(txt): restore chunk iterator API and stream cancel behavior

* fix(txt): avoid unused private segment iterator

* chore: clean up log for unit tests

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-02-26 05:30:13 +01:00
Huang Xin 3904c1e8e7 perf: use GPU-accelerated scroll for smoother paging animation (#3385) 2026-02-26 03:39:37 +01:00
Alan 99be6c58e2 fix: close searchbar when adding a new annotation (#3384) 2026-02-26 09:20:27 +08:00
Huang Xin 664cc772d0 fix: more sensitive paging without snapping animation, closes #3310 (#3381) 2026-02-25 17:47:55 +01:00
Huang Xin 21208adbcf compat: add target class to hide import icon in the bookshelf, closes #3376 (#3380) 2026-02-25 14:13:10 +01:00
Huang Xin 70eb59e2d6 compat(css): only override img background when overriding book color, closes #3377 (#3379) 2026-02-25 11:43:02 +01:00
Huang Xin 3e7b57282e fix: delay context menu to prevent broken input loop on macOS, closes #3324 (#3378)
When a native context menu is triggered via right-click, the menu.popup()
call immediately takes control and disrupts the browser's pointer event
flow. This prevents the pointerUp event from being delivered, leaving the
input event loop in an inconsistent state where the first tap after
dismissing the menu is not captured.

The fix adds a 100ms delay before showing the context menu, allowing the
browser's input loop to complete the pointerUp event for the right-click
before the native menu interferes.
2026-02-25 11:22:25 +01:00
Huang Xin 534582b125 compat(css): unset none user-select in some EPUBs, closes #3370 (#3374) 2026-02-25 07:10:06 +01:00
Huang Xin 4465e6986e chore: add husky pre-commit and pre-push hooks (#3372) 2026-02-25 06:39:11 +01:00
Blyrium a535f6419e fix: allow CSS targeting of the "NUMBER pages left in chapter" label via <Trans> component (#3368)
* Allow users to change the "remaining pages" label

Makes the remaining page number and the label text that comes with it targetable with CSS.
Solves https://github.com/readest/readest/issues/3343.

* Fixed formatting
2026-02-25 06:11:45 +01:00
Huang Xin 65da9c1d47 compat(webview): compat with older webview for iterating gamepads (#3366) 2026-02-24 16:59:52 +01:00
Huang Xin 5f71fd9e47 fix(css): override inline image background color only when overriding book color, closes #3316 (#3365) 2026-02-24 16:04:28 +01:00
Huang Xin dcf75e07d1 feat(translator): add Khmer in translator target languages, closes #3323 (#3364) 2026-02-24 15:51:50 +01:00
Huang Xin 79ba9b3818 compat(css): unset font-family for body when set to serif or sans-serif, closes #3334 (#3363) 2026-02-24 15:18:12 +01:00
Huang Xin 128b238bcb feat: add directional view transitions with scroll preservation in library view, closes #3357 (#3362) 2026-02-24 14:56:45 +01:00
Mohammed Efaz e26f7e6a2c fix: empty paragraphs not skipped in paragraph mode (#3361)
* fix(paragraph): skip empty paragraph ranges

* test(paragraph): cover empty paragraph filtering
2026-02-24 09:05:35 +01:00
Huang Xin 4c5ff59bcf fix(layout): also scale table with parent width, closes #3284 (#3359) 2026-02-24 08:11:45 +01:00
Huang Xin 99b2a34bd2 compat(layout): fix insane block display for tables, closes #3351 (#3358) 2026-02-24 07:26:03 +01:00
Huang Xin 40f3268ef3 fix(layout): consistent padding and radius for the dialog header, closes #3352 (#3356) 2026-02-24 06:55:47 +01:00
Huang Xin 4dac0850c5 fix: add classes for progress info labels, closes #3343 (#3353) 2026-02-24 05:24:01 +01:00
Huang Xin 118538ba35 fix(epub): replace background also for scrolled mode, closes #3344 (#3350) 2026-02-24 04:37:44 +01:00
JustADeer 4a92cacd84 fix: changed tagName comparision to localName for case-insensitive in iframeEventHandlers (XHTML xmlns bug fix) (#3349) 2026-02-24 09:21:03 +08:00
Matt Vogel ce53cd2b47 feat: Readwise highlights sync (#3311) 2026-02-24 00:08:59 +08:00
JustADeer b99c1bc19a feat(ui): image viewing mode support (#3328)
* feat(ui): image viewing mode support

* Revert foliate-js submodule pointer to 72fda6a (CI-compatible)

* Fix long-press image viewing in foliateViewer instead of foliate-js and other refactors

* feat: add images navigation buttons and table viewer

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-02-22 17:28:48 +01:00
Aniket Kotal eec2c39f19 feat(docker/podman): self-hosting with docker/podman compose (#3312)
* initial files

* added testing files

* removed unused files

* cleaned additional mounts

* fixed sql init

* removed more unused files

* moved to docker folder

* revert package.json

* gitignore update

* env example comments and compose necessary healthcheck

* ghcr package impl

* updated dockerfile steps  for layer caching

* added development-stage to dockerfile to dev environment

* added documentation on how to use dockerfile and compose.yml

* fixed prettier issues

* fixed image tag

* removed workflow for later
2026-02-18 14:28:10 +01:00
Mohammed Efaz c6ae85484e fix(reader): clamp reading ruler within viewport (#3314) 2026-02-18 14:13:06 +08:00
Huang Xin 7a9f46e93c fix(layout): container layout for dimmed area of reading ruler, closes #3304 (#3313) 2026-02-17 18:34:48 +01:00
Mohammed Efaz b9f6578127 feat: remaining time in TTS mode (#3300)
* feat: add test

* chore: update test

* feat: add translations

* feat: add tts time

* feat: add tts time components

* feat: update test

* feat: fixes and update ui components

* refactor: revert translations and ui
2026-02-14 17:42:37 +01:00
Huang Xin e75a3d254e fix(tts): fixed an issue where starting TTS from the annotation tool did not work, closes #3292 (#3303) 2026-02-14 17:22:46 +01:00
Huang Xin ea15906acf fix(txt): fixed TXT import on platforms that CompressionStream is not working, closes #3255 (#3302) 2026-02-14 23:30:38 +08:00
Mohammed Efaz 15d2784725 fix: highlight in dark mode eink (#3299)
* fix: highlight in dark mode eink

* refactor: deduplicate

* fix: bw eink highlights to use the fg colour so saved highlights are visible

* fix: avoid grayscale color in E-ink mode for better legibility

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-02-14 13:32:52 +01:00
Huang Xin ae3bb9da9a fix(annotator): update highlight style, color and range handlers when showing different annotations, closes #3286 (#3289) 2026-02-13 16:46:44 +01:00
Mohammed Efaz 239b32fcc2 fix: reader ruler disabled in scroll mode (#3288) 2026-02-13 16:35:25 +01:00
316 changed files with 33837 additions and 6775 deletions
+81 -19
View File
@@ -1,5 +1,7 @@
name: PR checks
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
@@ -10,21 +12,30 @@ jobs:
runs-on: ubuntu-latest
env:
RUSTFLAGS: '-C target-cpu=skylake'
SCCACHE_GHA_ENABLED: 'true'
RUSTC_WRAPPER: sccache
steps:
- uses: actions/checkout@v6
with:
submodules: 'true'
- name: setup sccache
uses: mozilla-actions/sccache-action@v0.0.9
- name: Install minimal stable with clippy and rustfmt
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
override: true
components: rustfmt, clippy
- name: Cache apt packages
uses: actions/cache@v5
with:
path: /var/cache/apt/archives
key: apt-rust-lint-${{ runner.os }}
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libfontconfig-dev libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev
- name: Format
- name: Format check
working-directory: apps/readest-app/src-tauri
run: cargo fmt --check
- name: Clippy Check
@@ -33,33 +44,28 @@ jobs:
build_web_app:
runs-on: ubuntu-latest
strategy:
matrix:
config:
- platform: 'web'
- platform: 'tauri'
steps:
- uses: actions/checkout@v6
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
- name: setup node
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
- name: cache Next.js build
uses: actions/cache@v5
with:
path: apps/readest-app/.next/cache
key: nextjs-${{ matrix.config.platform }}-${{ github.sha }}-${{ hashFiles('pnpm-lock.yaml') }}
key: nextjs-web-${{ github.sha }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
nextjs-${{ matrix.config.platform }}-${{ github.sha }}-
nextjs-${{ matrix.config.platform }}-
nextjs-web-${{ github.sha }}-
nextjs-web-
- name: install Dependencies
working-directory: apps/readest-app
@@ -70,24 +76,80 @@ jobs:
run: |
pnpm format:check || (pnpm format && git diff && exit 1)
- name: run tests
- name: install playwright browsers
working-directory: apps/readest-app
run: |
pnpm test -- --watch=false
run: npx playwright install --with-deps chromium
- name: run web tests
working-directory: apps/readest-app
run: pnpm test:pr:web
- name: run lint
working-directory: apps/readest-app
run: |
pnpm lint
- name: build the web App
if: matrix.config.platform == 'web'
- name: build the web app
working-directory: apps/readest-app
run: |
pnpm build-web && pnpm check:all
- name: build the Tauri App
if: matrix.config.platform == 'tauri'
build_tauri_app:
runs-on: ubuntu-latest
env:
SCCACHE_GHA_ENABLED: 'true'
RUSTC_WRAPPER: sccache
steps:
- uses: actions/checkout@v6
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@v5
- name: setup node
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: cache Next.js build
uses: actions/cache@v5
with:
path: apps/readest-app/.next/cache
key: nextjs-tauri-${{ github.sha }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
nextjs-tauri-${{ github.sha }}-
nextjs-tauri-
- name: install Dependencies
working-directory: apps/readest-app
run: |
pnpm build && pnpm check:all
pnpm install && pnpm setup-vendors
- name: setup sccache
uses: mozilla-actions/sccache-action@v0.0.9
- name: install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@v2
with:
key: tauri-cargo
cache-all-crates: 'true'
- name: Cache apt packages
uses: actions/cache@v5
with:
path: /var/cache/apt/archives
key: apt-tauri-${{ runner.os }}
- name: install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libfontconfig-dev libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev xvfb
- name: run tauri tests
working-directory: apps/readest-app
run: xvfb-run pnpm test:pr:tauri
+2 -2
View File
@@ -154,12 +154,12 @@ jobs:
run: git submodule update --init --recursive
- name: setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
- name: setup node
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
- name: setup Java (for Android build only)
+6
View File
@@ -1,4 +1,5 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
docker/.env
# dependencies
/node_modules
@@ -45,4 +46,9 @@ fastlane/report.xml
# nix
result*
.playwright-mcp/
.claude/worktrees
.claude/settings.local.json
+6
View File
@@ -10,3 +10,9 @@
[submodule "packages/simplecc-wasm"]
path = packages/simplecc-wasm
url = https://github.com/readest/simplecc-wasm.git
[submodule "apps/readest-app/src-tauri/plugins/tauri-plugin-turso"]
path = apps/readest-app/src-tauri/plugins/tauri-plugin-turso
url = https://github.com/readest/tauri-plugin-turso.git
[submodule "apps/readest-app/.claude/skills/gstack"]
path = apps/readest-app/.claude/skills/gstack
url = https://github.com/garrytan/gstack.git
+1
View File
@@ -0,0 +1 @@
pnpm exec lint-staged
+2
View File
@@ -0,0 +1,2 @@
pnpm -C apps/readest-app lint
pnpm -C apps/readest-app test
+4
View File
@@ -22,9 +22,13 @@ gen
# Submodules (External Repos)
packages
# Claude Code Skills & Config
apps/readest-app/.claude
# Vendored Assets (Generated/External Code)
apps/readest-app/public/*.js
apps/readest-app/public/vendor
apps/readest-app/src-tauri/plugins/tauri-plugin-turso
# Environment & Editor
.env
Generated
+3019 -645
View File
File diff suppressed because it is too large Load Diff
+2 -8
View File
@@ -2,10 +2,7 @@
members = [
"apps/readest-app/src-tauri",
"packages/tauri/crates/tauri",
"packages/tauri/crates/tauri-utils",
"packages/tauri/crates/tauri-build",
"packages/tauri-plugins/plugins/deep-link",
"packages/tauri-plugins/plugins/single-instance"
"packages/tauri-plugins/plugins/fs"
]
resolver = "2"
@@ -36,7 +33,4 @@ rust-version = "1.77.2"
[patch.crates-io]
tauri = { path = "packages/tauri/crates/tauri" }
tauri-utils = { path = "packages/tauri/crates/tauri-utils" }
tauri-build = { path = "packages/tauri/crates/tauri-build" }
tauri-plugin-deep-link = { path = "packages/tauri-plugins/plugins/deep-link" }
tauri-plugin-single-instance = { path = "packages/tauri-plugins/plugins/single-instance" }
tauri-plugin-fs = { path = "packages/tauri-plugins/plugins/fs" }
+32 -12
View File
@@ -1,20 +1,40 @@
FROM node:22-slim
ENV PNPM_HOME="/root/.local/share/pnpm"
ENV PATH="${PATH}:${PNPM_HOME}"
RUN npm install --global pnpm
COPY . /app
FROM docker.io/node:22-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@10.29.3 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/readest-app/package.json ./apps/readest-app/
COPY patches/ ./patches/
COPY packages/ ./packages/
RUN pnpm install
FROM base AS dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm --filter @readest/readest-app setup-vendors
FROM dependencies AS development-stage
COPY . .
WORKDIR /app/apps/readest-app
EXPOSE 3000
ENTRYPOINT ["pnpm", "dev-web", "-H", "0.0.0.0"]
FROM base AS build
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG NEXT_PUBLIC_APP_PLATFORM
ARG NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_OBJECT_STORAGE_TYPE
ARG NEXT_PUBLIC_STORAGE_FIXED_QUOTA
ARG NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA
COPY --from=dependencies /app/node_modules /app/node_modules
COPY --from=dependencies /app/apps/readest-app/node_modules /app/apps/readest-app/node_modules
COPY --from=dependencies /app/apps/readest-app/public/vendor /app/apps/readest-app/public/vendor
COPY --from=dependencies /app/packages/foliate-js/node_modules /app/packages/foliate-js/node_modules
COPY . .
WORKDIR /app/apps/readest-app
RUN pnpm build-web
ENTRYPOINT ["pnpm", "start-web"]
FROM build as production-stage
ENTRYPOINT ["pnpm", "start-web", "-H", "0.0.0.0"]
EXPOSE 3000
+28 -28
View File
@@ -45,38 +45,38 @@
<div align="left">✅ Implemented</div>
| **Feature** | **Description** | **Status** |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------- |
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, TXT, PDF (experimental) | ✅ |
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience and use instant mode for quicker interactions. | ✅ |
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. | ✅ |
| **Translate with DeepL and Yandex** | From a single sentence to the entire book—translate instantly. | ✅ |
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
| **Accessibility** | Provides full keyboard navigation and supports for screen readers such as VoiceOver, TalkBack, NVDA, and Orca. | ✅ |
| **Visual & Focus Aids** | Reading ruler, paragraph-by-paragraph reading mode, and speed reading features. | ✅ |
| **Feature** | **Description** | **Status** |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | ---------- |
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, TXT, PDF | ✅ |
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience and use instant mode for quicker interactions. | ✅ |
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. | ✅ |
| **Translate with DeepL and Yandex** | From a single sentence to the entire book—translate instantly. | ✅ |
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
| [**Sync with Koreader**][link-kosync-wiki] | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | ✅ |
| **Accessibility** | Provides full keyboard navigation and supports for screen readers such as VoiceOver, TalkBack, NVDA, and Orca. | ✅ |
| **Visual & Focus Aids** | Reading ruler, paragraph-by-paragraph reading mode, and speed reading features. | ✅ |
## Planned Features
<div align="left">🛠 Building</div>
<div align="left">🔄 Planned</div>
| **Feature** | **Description** | **Priority** |
| ------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------ |
| [**Sync with Koreader**][link-kosync-wiki] | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🛠 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🛠 |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
| **Feature** | **Description** | **Priority** |
| ------------------------------- | -------------------------------------------------------------------------- | ------------ |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🛠 |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
@@ -122,8 +122,8 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
```bash
nvm install v22
nvm use v22
nvm install v24
nvm use v24
npm install -g pnpm
rustup update
```
+26
View File
@@ -0,0 +1,26 @@
# Readest Project Memory
## Key Reference Documents
- [Bug Fixing Patterns](bug-patterns.md) - Common bug categories, root causes, and fix strategies
- [CSS & Style Fixes](css-style-fixes.md) - EPUB CSS override patterns and the style.ts pipeline
- [TTS Fixes](tts-fixes.md) - Text-to-Speech architecture and bug patterns
- [Layout & UI Fixes](layout-ui-fixes.md) - Safe insets, z-index, platform-specific UI issues
- [Platform Compat Fixes](platform-compat-fixes.md) - Android, iOS, Linux, macOS platform-specific bugs
- [Annotator & Reader Fixes](annotator-reader-fixes.md) - Highlight, selection, accessibility bugs
## Critical Files (Most Bug-Prone)
- `src/utils/style.ts` - Central EPUB CSS transformation hub (14+ bug fixes)
- `packages/foliate-js/paginator.js` - Page layout, image sizing, backgrounds
- `src/services/tts/TTSController.ts` - TTS state machine, section tracking
- `src/hooks/useSafeAreaInsets.ts` - Safe area inset management
- `src/app/reader/components/FoliateViewer.tsx` - Reader view orchestration
- `src/app/reader/components/annotator/Annotator.tsx` - Annotation lifecycle
## 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
- Style overrides: `getLayoutStyles()` (always), `getColorStyles()` (when overriding color)
- `transformStylesheet()` does regex-based EPUB CSS rewriting at load time
- 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
@@ -0,0 +1,90 @@
# Annotator & Reader Fixes Reference
## Annotation System Architecture
### Key Components
- `Annotator.tsx` - Annotation lifecycle, popup display, style/color management
- `AnnotationRangeEditor.tsx` - Drag handles for adjusting selection range
- `MagnifierLoupe.tsx` - Magnifying glass during handle drag (mobile only)
- `useTextSelector.ts` - Text selection detection and processing
- `useAnnotationEditor.ts` - Editing existing annotations
- `useInstantAnnotation.ts` - Creating new annotations on selection
### Highlight Rendering
- Highlights rendered by foliate-js `Overlayer` (SVG overlayer in paginator shadow DOM, not iframe)
- Each view in multiview paginator has its own `Overlayer` instance with unique clipPath ID
- `Overlayer.add()` stores range + draw function; `redraw()` recalculates positions from stored ranges
- Colors stored as color names mapped to custom hex via `globalReadSettings.customHighlightColors`
- Sidebar uses `color-mix()` CSS function with custom colors, not Tailwind utility classes (#3273)
- Rounded highlight style supported via `vertical` option passed to overlayer (#3208)
### Multiview Overlayer Pitfalls
- **Duplicate SVG IDs**: Each overlayer creates `<clipPath>` for loupe hole — IDs MUST be unique per instance or `url(#id)` resolves to wrong element, clipping everything
- **docLoadHandler scope**: `FoliateViewer.tsx` re-adds annotations on `load` event — MUST filter by `detail.index` (loaded section), not re-add ALL annotations (overwrites drag edits)
- **MagnifierLoupe lifecycle**: Don't destroy/recreate loupe on every drag tick — `hideLoupe()` should only run on unmount, `showLoupe()` fast path updates position only
- **Stale closures in useTextSelector**: `getProgress()` must be called inside callbacks, not captured at hook top-level (useFoliateEvents deps are `[view]` only)
## Fix History
| Issue | Problem | Root Cause | Fix |
|-------|---------|------------|-----|
| #3286 | Selection stuck on first annotation | `initializedRef` guard blocked re-computation | Remove guard, consolidate style/color effects |
| #3273 | Custom colors not in sidebar | Hardcoded Tailwind classes | Use inline `style` with `color-mix()` |
| #3234 | Letter-by-letter selection on mobile | No word boundary snapping | Add `snapRangeToWords()` using `Intl.Segmenter` |
| #3208 | Hard rectangular highlights | No border radius support | Pass `vertical` option, update foliate-js |
| #3002 | Can't see text under finger | No magnification UI | New `MagnifierLoupe` component using `view.renderer.showLoupe()` |
| #3082 | No page numbers on annotations | `pageNumber` field missing | Add `pageNumber` to BookNote type, compute on create |
| #3225 | Android tools unresponsive | Premature `makeSelection()` call | Remove premature re-selection in Android path |
## Common Annotation Bugs
### Selection Issues
- **Word snapping**: Uses `Intl.Segmenter` with `granularity: 'word'` to snap selection to word boundaries
- **Android re-selection**: Don't call `makeSelection(sel, index, true)` immediately on pointer-up; let the popup flow complete
- **Range editor handles**: Remove `initializedRef` guards that prevent re-computation when switching annotations
### Color/Style Issues
- **Custom colors in sidebar**: Use inline `style={{ backgroundColor: 'color-mix(...)' }}` not Tailwind classes
- **Style synchronization**: Consolidate `selectedStyle` and `selectedColor` into one `useEffect`
- **Switching annotations**: Must call `setShowAnnotPopup(false)` and `setEditingAnnotation(null)` before setting up new annotation
## Reader/Content Fixes
### Progress Display
- Use physical `view.renderer.page` and `view.renderer.pages` for page counts (#3213, #3200)
- Last page shows 100% by fixing boundary condition (#3383)
- FB2 subsections need special handling for progress (#3136)
### Translation View (#3078)
- Problem: Page jumps back during full-text translation
- Root cause: DOM mutations from sequential translation insertions cause paginator relayout
- Fix: Batch DOM updates with 50ms timer, use bounded concurrent queue (max 5), show loading overlay
### TOC Navigation (#3124)
- Problem: Expanding TOC chapter scrolls back to current chapter
- Fix: Only scroll-into-view on navigation, not on expand/collapse
## Accessibility (a11y) Fixes
### Screen Reader (TalkBack) Support
- **Page indicator updates** (#2276): Add focus handlers on `<p>` elements that call `view.goTo(cfi)` to update position
- **Navigation buttons** (#3036): Always show prev/next buttons when screen reader active; `PageNavigationButtons.tsx`
- **Dropdown menus** (#3035): Use `DropdownContext` with overlay dismiss instead of blur-based closing
### Dropdown Architecture for a11y
- `DropdownContext` (`src/context/DropdownContext.tsx`) manages which dropdown is open globally
- Uses `useId()` for unique identification
- One dropdown open at a time
- `<Overlay>` for dismissal (tap/click outside) instead of `onBlur`
- `<details>` element with `open={isOpen}` for semantic structure
- No auto-focus-first-item (conflicts with TalkBack)
## E-ink Readability
- Use `not-eink:` Tailwind variant for colors and opacity (#3258)
- Don't use `text-primary` (blue) or low opacity on e-ink
- Highlights use foreground color in dark mode e-ink (#3299)
## Key Utility Functions
- `snapRangeToWords()` in `src/utils/sel.ts` - Word boundary snapping
- `handleAccessibilityEvents()` in `src/utils/a11y.ts` - Screen reader focus handling
- `color-mix()` CSS function for custom highlight colors with opacity
@@ -0,0 +1,119 @@
# Bug Fixing Patterns & Strategies
## Common Root Cause Categories
### 1. Overly Broad CSS Selectors
**Pattern:** A CSS rule targets too many elements, causing unintended visual side effects.
**Examples:**
- `hr { mix-blend-mode: multiply }` applied to ALL hr elements instead of only decorative ones (#3086)
- `p img { mix-blend-mode }` applied to block images, not just inline (#3112)
- `svg, img { height: auto; width: auto }` overrode explicit HTML width/height attributes (#3274)
- Background-color override applied unconditionally instead of only when user enabled color override (#3316)
**Fix Strategy:** Narrow selectors with class qualifiers (`.background-img`, `.has-text-siblings`) or attribute pseudo-selectors (`:where(:not([width]))`). Check if the rule should be conditional on a user setting.
### 2. Conditional vs Unconditional Style Overrides
**Pattern:** CSS rules meant for "Override Book Color/Layout" mode are placed in the always-active stylesheet.
**Examples:**
- Calibre `.calibre { color: unset }` was in `getLayoutStyles()` instead of `getColorStyles()` (#3448)
- Image background-color override applied without checking `overrideColor` flag (#3316, #3377)
**Fix Strategy:** Move rules to the correct conditional block: `getColorStyles()` for color overrides, `getLayoutStyles()` for layout overrides. Check the `overrideColor`/`overrideLayout` flags.
### 3. Missing EPUB Stylesheet Transformations
**Pattern:** EPUB stylesheets contain CSS that conflicts with app functionality.
**Examples:**
- `user-select: none` prevents text selection (#3370) -> regex replace in `transformStylesheet()`
- `font-family: serif/sans-serif` on body bypasses user font (#3334) -> detect and unset
- Hardcoded Calibre backgrounds persist in dark mode (#3448) -> unset in color override
**Fix Strategy:** Add regex-based transformation passes in `transformStylesheet()` in `style.ts`.
### 4. Stale State / Refs Not Reset
**Pattern:** A `useRef` or state variable is set once and never properly reset, blocking re-entry.
**Examples:**
- TTS `ttsOnRef` prevented restarting TTS from a new location (#3292)
- `initializedRef` in AnnotationRangeEditor prevented handle position updates (#3286)
- `view.tts` not nulled on shutdown prevented clean TTS restart (#3400)
- TTS safety timeout fired after pause, advancing to next sentence (#3244)
**Fix Strategy:** Check all refs/guards in the affected flow. Ensure cleanup in shutdown/unmount. Remove overly aggressive guards that prevent re-entry.
### 5. Platform API Differences
**Pattern:** A Web API behaves differently or is unavailable on certain platforms.
**Examples:**
- `navigator.getGamepads()` returns null on older Android WebView (#3245)
- `CompressionStream` unavailable on some Android versions (#3255)
- `btoa()` throws on non-ASCII characters (#3436)
- View Transitions API unsupported in WebKitGTK/Linux (#3417)
- `document.startViewTransition()` crashes on Linux
**Fix Strategy:** Always check API availability before use. Add fallback paths. Use feature detection, not platform detection when possible.
### 6. Safe Area Inset Issues
**Pattern:** UI elements overlap system bars (status bar, navigation bar, notch) on mobile.
**Examples:**
- Zoom controls behind status bar (#3426)
- Android navigation bar overlap (#3466)
- iPad sidebar insets incorrect (#3395)
- Reader page layout jump after system UI change (#3469)
**Fix Strategy:** Use `gridInsets` and `statusBarHeight` from `useSafeAreaInsets`. Use `env(safe-area-inset-*)` CSS functions. Call `onUpdateInsets()` after system UI visibility changes. See `docs/safe-area-insets.md`.
### 7. Z-Index Layering Issues
**Pattern:** Interactive elements rendered behind other layers, becoming unclickable.
**Examples:**
- Navigation buttons invisible on mobile (#3201) -> added `z-10`
- Annotation nav bar too prominent (#3386) -> reduced from `z-30` to `z-10`
- Page nav buttons behind TTS control (#3184)
**Fix Strategy:** Check z-index ordering. Use minimum necessary z-index. Reference the z-index hierarchy in the codebase.
### 8. Event Handling Race Conditions
**Pattern:** Timing issues between pointer events, native menus, and React state updates.
**Examples:**
- macOS context menu steals pointer event loop (#3324) -> 100ms setTimeout delay
- Traffic light buttons flicker due to timeout race (#3488, #3129)
- Android tool buttons unresponsive due to premature re-selection (#3225)
**Fix Strategy:** Add small delays before native menu calls. Check event state machine consistency. Remove premature re-triggers on Android.
### 9. foliate-js Rendering Issues
**Pattern:** Bugs in the lower-level EPUB renderer (paginator.js, epub.js).
**Examples:**
- Image size not constrained in double-page mode (#3432)
- Background not shown in scrolled mode (#3344)
- Section content cached incorrectly after mode switch (#3242, #3206)
- Swipe sensitivity too low for non-animated paging (#3310)
**Fix Strategy:** Check both `columnize()` and `scrolled()` code paths in paginator.js. Verify CSS variables (`--available-width`, `--available-height`) are computed correctly. Test in both paginated and scrolled modes.
### 10. Progress/Navigation Calculation Errors
**Pattern:** Page counts, progress percentages, or position tracking are wrong.
**Examples:**
- Progress shows 99.9% at last page (#3383) -> boundary condition
- Pages left shows estimated instead of physical count (#3213, #3200)
- FB2 subsection progress wrong (#3136) -> nested structure not handled
- TOC auto-scrolls on expand (#3124) -> scroll-into-view triggered too broadly
**Fix Strategy:** Use physical `view.renderer.page`/`view.renderer.pages` instead of estimated section metadata. Check boundary conditions (0-indexed vs 1-indexed, inclusive vs exclusive).
### 11. Multiview Paginator Side Effects
**Pattern:** The multiview paginator (e925e9d+) loads adjacent sections in background. Events from these loads can interfere with user interactions on the primary section.
**Examples:**
- `load` event from adjacent section triggers `docLoadHandler` which re-adds ALL annotations, overwriting drag edits
- Multiple overlayers with duplicate SVG `<clipPath>` IDs cause `url(#id)` to resolve to wrong element
- `MagnifierLoupe` destroying/recreating body clone on every drag tick triggers ResizeObserver → expand → redraw
**Fix Strategy:** Scope event handlers to the loaded section's index. Use unique IDs for SVG elements across overlayer instances. Minimize iframe DOM mutations during drag operations.
## Debugging Workflow
1. **Identify the category** from the issue description
2. **Check `style.ts`** first for any CSS-related visual bugs
3. **Check foliate-js** for rendering/layout bugs
4. **Check platform-specific code** for mobile/desktop differences
5. **Write a failing test** before implementing the fix
6. **Test in both paginated and scrolled modes** for layout changes
7. **Test on multiple platforms** for any UI change
8. **Run `pnpm build-check`** before submitting
@@ -0,0 +1,74 @@
# CSS & Style Fixes Reference
## The `style.ts` Pipeline (`src/utils/style.ts`)
This is the most bug-prone file in the codebase (14+ fixes). It handles all EPUB CSS transformations.
### Key Functions
#### `getLayoutStyles()`
- Always-active styles applied to every EPUB section
- Controls: line-height, hyphens, image sizing, table display
- Rules here should NOT be conditional on user settings
- Common mistake: putting color-related rules here instead of `getColorStyles()`
#### `getColorStyles()`
- Conditionally applied when user enables "Override Book Color"
- Controls: foreground/background colors, mix-blend modes, image backgrounds
- Gate rules on `overrideColor` flag
#### `transformStylesheet()`
- Regex-based rewriting of EPUB CSS at load time
- Runs on every stylesheet loaded from the EPUB
- Used to neutralize problematic EPUB CSS declarations
#### `applyTableStyle()`
- Post-render function that scales tables to fit available width
- Uses `getComputedStyle()` (not inline `style.width`) to read actual width
- Has two scaling paths: column-width-based and parent-container-based
### Fix History by Issue
| Issue | Problem | Fix in style.ts |
|-------|---------|-----------------|
| #3494 | Line spacing not on `<li>` | Added `li` CSS rule for `line-height` and `hyphens` |
| #3448 | Calibre colors persist | Moved `.calibre` unset to `getColorStyles()`, added `background-color: unset` |
| #3441 | Body padding/margin | Added `padding: unset; margin: unset` to body in `getLayoutStyles()` |
| #3316 | Image bg unconditional | Made `background-color` rule conditional on `overrideColor` |
| #3377 | Image bg override | Same pattern as #3316, only override when `overrideColor` is true |
| #3334 | Generic font-family | `transformStylesheet()` replaces `font-family: serif/sans-serif` with `unset` on body |
| #3370 | user-select: none | `transformStylesheet()` replaces all `user-select: none` with `unset` |
| #3284 | Table scaling | Added fallback when `totalTableWidth` is 0 but parent has width |
| #3351 | Table display broken | Added `display: table !important` to table rule |
| #3274 | Image dimensions | Changed selectors to `:where(:not([width]))` and `:where(:not([height]))` |
| #3205 | Table width reading | Changed from `style.width` to `getComputedStyle().width`, fixed CSS var unit |
| #3112 | Mix-blend on all images | Narrowed selector to `.has-text-siblings` class |
| #3086 | Mix-blend on hr | Narrowed selector to `hr.background-img` |
| #3012 | Vertical alignment | Fixed available dimensions (subtract insets), replaced `100vw/vh` with CSS vars |
### Common Patterns
1. **Adding new element rules:** Copy the pattern from `p` rules (e.g., adding `li` for #3494)
2. **EPUB CSS neutralization:** Add regex in `transformStylesheet()` to replace problematic declarations
3. **Conditional overrides:** Use `overrideColor`/`overrideLayout` flags to gate rules
4. **Selector narrowing:** Use class qualifiers or attribute pseudo-selectors to avoid over-matching
5. **Table fixes:** Always use `getComputedStyle()`, not inline style. Check both width paths.
### CSS Variables from foliate-js
- `--available-width` - Usable content width (set by paginator.js)
- `--available-height` - Usable content height
- `--full-width` - Full viewport width (numeric, multiply by 1px)
- `--full-height` - Full viewport height (numeric, multiply by 1px)
- `--overlayer-highlight-opacity` - Highlight transparency (default 0.3)
### foliate-js Rendering (`packages/foliate-js/paginator.js`)
Key functions:
- `columnize()` - Paginated layout path
- `scrolled()` - Scrolled layout path
- `setImageSize()` - Constrains image dimensions to available space
- `#replaceBackground()` - Transfers EPUB backgrounds to paginator layer
- `snap()` - Swipe gesture detection for page turning
Common issue: A fix applied to `columnize()` but not `scrolled()` (or vice versa). Always check both paths.
@@ -0,0 +1,70 @@
# Layout & UI Fixes Reference
## Safe Area Insets
### Architecture
- Native plugins push inset values: iOS (`NativeBridgePlugin.swift`), Android (`NativeBridgePlugin.kt`)
- `useSafeAreaInsets` hook (`src/hooks/useSafeAreaInsets.ts`) reads and caches values
- Components use `gridInsets` for positioning relative to safe areas
- CSS: `env(safe-area-inset-top/bottom/left/right)` for CSS-level insets
### Common Issues
- **Stale insets after system UI change** (#3469): Call `onUpdateInsets()` after `setSystemUIVisibility()`
- **iPad sidebar insets wrong** (#3395): Different inset handling needed for sidebar vs main view
- **Android nav bar overlap** (#3466): Use `calc(env(safe-area-inset-bottom) + 16px)` for Android bottom padding
- **Zoom controls behind status bar** (#3426): Pass `gridInsets` through component chain, use `Math.max(gridInsets.top, statusBarHeight)`
### Rules (see also `docs/safe-area-insets.md`)
- Always pass `gridInsets` to overlay/floating components near screen edges
- On Android, account for system navigation bar with `env(safe-area-inset-bottom)`
- After toggling system UI visibility, force-refresh insets via `onUpdateInsets()`
## Z-Index Hierarchy
- Navigation buttons: `z-10` (when visible)
- Annotation nav bar: `z-10` (reduced from z-30 in #3386)
- TTS control button: ensure above page nav buttons
- Floating overlays: check against `gridInsets` positioning
## macOS Traffic Lights
- Managed by `trafficLightStore.ts` and `HeaderBar.tsx`
- **Fullscreen check required** (#3129): `await currentWindow.isFullscreen()` before hiding
- **Timeout for visibility toggle** (#3488): Use 100ms delay to prevent flickering
- **Sidebar interaction** (#3488): Check `getIsSideBarVisible()` before hiding traffic lights
- Never hide traffic lights when sidebar is open
## Touch/Input Issues
- **Slider hit area on iOS** (#3382): Use min-h-12, strip browser appearance with CSS
- **Context menu on macOS** (#3324): 100ms delay before `onContextMenu()` to let pointer events complete
- **Swipe sensitivity** (#3310): Use average velocity (distance/time) instead of instantaneous velocity for non-animated paging
- **Touchpad natural scrolling** (#3127): Respect system natural scrolling setting in `usePagination.ts`
## Dialog/Menu Layout
- **Dialog header** (#3352): Use `px-2 sm:pe-3 sm:ps-2` padding to align with border radius
- **Settings alignment** (#3151): Use `Menu` component instead of raw `div` for consistent styling
- **Dropdown for screen readers** (#3035): Use `DropdownContext` with overlay dismiss, not blur-based closing
## Component-Specific Fixes
### HeaderBar.tsx
- Traffic light visibility management
- Sidebar toggle persistent position (#3193)
- Library button placement
### PageNavigationButtons.tsx
- z-10 when visible (#3201)
- Always shown for screen readers (#3036)
- Toggle via `showPageNavButtons` setting
### ProgressInfo.tsx
- Use physical `page`/`pages` from renderer, not estimated values (#3213, #3200)
- CSS classes: `time-left-label`, `pages-left-label`, `progress-info-label` (#3343)
### ReadingRuler.tsx
- Remove `containerStyle` from overlay so dimmed area covers full screen (#3304)
### NavigationBar.tsx
- Handle `gridInsets` internally, not via pre-computed `navPadding` (#3466)
### ContentNavBar.tsx (annotation search results)
- Floating buttons with drop shadow, not full-width bar (#3386)
- z-10 z-index
@@ -0,0 +1,85 @@
# Platform Compatibility Fixes Reference
## Android
### WebView API Issues
- **`navigator.getGamepads()`** returns null on older WebView (#3245): Always null-check before `.some()`
- **`CompressionStream`** unavailable on some Android WebView versions (#3255): Add fallback zip compression path
- **Annotation tools unresponsive** (#3225): Don't call `makeSelection()` immediately on pointer-up; let popup flow complete naturally
- **Safe inset updates** (#3469): Call `onUpdateInsets()` after `setSystemUIVisibility()`
- **Navigation bar overlap** (#3466): Use `calc(env(safe-area-inset-bottom) + 16px)` for bottom padding
### General Android Rules
- Test with older WebView versions (97+)
- Always check API availability before calling Web APIs
- Touch event handling differs from iOS - avoid premature re-selection
## iOS
### Common Issues
- **Slider touch dead zones** (#3382): Strip native appearance, use larger hit areas (min-h-12)
- **Safe area insets stale** (#3395): Native Swift plugin must push updated insets
- **Section content caching** (#3242, #3206): Don't cache section content in foliate-js when updating subitems; cached content retains stale styles after mode switch
- **CompressionStream** (#3255): Also broken on iOS 15.x; zip.js has its own native API disable
- **zip.js native API** (#3170): Disable native `CompressionStream`/`DecompressionStream` on iOS 15.x
### iOS-Specific Code
- `src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift`
- Slider CSS: `-webkit-appearance: none; appearance: none` in globals.css
- `useSafeAreaInsets.ts` hook
## macOS
### Traffic Lights (Window Controls)
- Check `isFullscreen()` before hiding (#3129)
- Use timeouts (100ms) for visibility transitions (#3488)
- Don't hide when sidebar is open (#3488)
### Input Issues
- **Context menu steals event loop** (#3324): 100ms setTimeout before calling menu.popup()
- **Touchpad natural scrolling** (#3127): Respect system setting in `usePagination.ts`
- **Two-finger swipe** (#3127): Support trackpad two-finger swipe for pagination
### macOS-Specific Code
- `src-tauri/src/macos/` - Platform-specific Rust code
- `src/store/trafficLightStore.ts`
- `src/hooks/useTrafficLight.ts`
## Linux
### WebKitGTK Issues
- **View Transitions API unsupported** (#3417): Feature-detect `document.startViewTransition` before calling
- Use `useAppRouter.ts` to avoid transitions on Linux
## E-ink Devices
### Legibility Issues
- **Low contrast colors** (#3258): Use `not-eink:` Tailwind variant prefix for colors/opacity
- **Links invisible** (#3258): Don't apply `text-primary` (blue) on e-ink; use default text color
- **Opacity too low** (#3258): Don't apply `opacity-60`/`opacity-75` on e-ink devices
- **Highlight visibility** (#3299): Use foreground color for highlights in dark mode e-ink
### E-ink CSS Pattern
```
// Instead of:
className="text-primary opacity-60"
// Use:
className="not-eink:text-primary not-eink:opacity-60"
```
## Docker/Self-Hosting
- **Missing submodules** (#3233): Run `git submodule update --init --recursive` before build
- Simplecc WASM module must be initialized
## OPDS
- **Non-ASCII credentials** (#3436): Use `TextEncoder` + manual Base64 instead of `btoa()`
- **Author parsing** (#3120): Handle varied metadata structures in OPDS 2.0 feeds
- **Responsive layout** (#3418): Ensure catalog and download button layout works on small screens
## Cross-Platform Testing Checklist
1. Android (old WebView + current)
2. iOS (15.x + current)
3. macOS (traffic lights, trackpad)
4. Linux (WebKitGTK)
5. E-ink devices (contrast, colors)
6. Web (CloudFlare Workers deployment)
@@ -0,0 +1,50 @@
# TTS (Text-to-Speech) Fixes Reference
## Architecture
### Key Components
- `TTSController` (`src/services/tts/TTSController.ts`) - Core state machine
- `EdgeTTSClient` (`src/services/tts/EdgeTTSClient.ts`) - Edge TTS provider
- `useTTSControl` hook (`src/app/reader/hooks/useTTSControl.ts`) - React integration
- `useTTSMediaSession` hook (`src/app/reader/hooks/useTTSMediaSession.ts`) - Media controls
### Section-Aware TTS Model
TTS tracks its own section independently from the view via `#ttsSectionIndex`:
- `#initTTSForSection()` - Creates TTS document for a section without changing the view
- `#initTTSForNextSection()` / `#initTTSForPrevSection()` - Navigate TTS across sections
- `#getHighlighter()` - Only returns highlighter if view section matches TTS section
- `onSectionChange` callback - Notifies UI when TTS crosses section boundary
- Highlights use CFI strings (not raw Range objects) for cross-section compatibility
### State Management Pitfalls
1. **`#ttsSectionIndex` must match view section for highlights to work**
- If `-1`, all highlight calls are suppressed
- `shutdown()` sets it to `-1` but must also null out `this.view.tts`
2. **Guards/Refs that block re-entry:**
- The old `ttsOnRef` guard blocked TTS restart from annotations (removed in #3292)
- `view.tts` reference surviving shutdown blocked re-initialization (#3400)
3. **Timeouts that fire after pause:**
- Edge TTS had a safety timeout that advanced sentences even when paused (#3244)
- Solution: removed the entire `ontimeupdate` safety timeout mechanism
## Fix History
| Issue | Problem | Root Cause | Fix |
|-------|---------|------------|-----|
| #3100 | TTS scrolls too far | TTS coupled to view section | Added `#ttsSectionIndex`, "Back to TTS Location" button |
| #3198 | TTS doesn't follow to next section | No `onSectionChange` callback | Added section change notification, extracted hooks |
| #3244 | Paused TTS advances | Safety timeout fires after pause | Removed `ontimeupdate` timeout mechanism |
| #3291 | TTS fails without lang attribute | Invalid SSML from missing lang | Set lang/xml:lang on html element from `ttsLang` |
| #3292 | Can't restart TTS from annotation | `ttsOnRef` blocks re-entry | Removed the guard ref entirely |
| #3400 | TTS highlight stops after restart | `view.tts` not nulled on shutdown | Added `this.view.tts = null` in `shutdown()` |
## Debugging TTS Issues
1. **TTS doesn't start:** Check `#initTTSForSection()` - does `view.tts.doc === doc` shortcut early?
2. **No highlights:** Check `#ttsSectionIndex` matches view's section index
3. **Advances when paused:** Look for setTimeout/timer callbacks that bypass pause state
4. **Can't restart:** Check for refs/guards that prevent re-entry into speak handlers
5. **Fails on some chapters:** Check if chapter has lang attribute and XHTML namespace
6. **SSML errors:** Check `src/utils/ssml.ts` for proper namespace/lang handling
@@ -0,0 +1,5 @@
## Test-First Development
- Always write a failing unit test **before** implementing a fix.
- Run the test to confirm it reproduces the bug or fails as expected, then apply the fix and verify the test passes.
- Run the full test suite (`pnpm test`) after changes to ensure no regressions.
@@ -0,0 +1,5 @@
## TypeScript
- Never use the `any` type. Use `unknown`, proper types, or generics instead.
- Strict mode is enabled. Target is ES2022.
- Unused vars prefixed with `_` are allowed (ESLint configured).
@@ -0,0 +1,8 @@
## Verification (done-conditions)
Before marking work complete, all applicable checks must pass:
1. `pnpm test` — unit tests
2. `pnpm lint` — ESLint
3. `pnpm fmt:check` — Rust format check (only when `src-tauri/` files changed)
4. `pnpm clippy:check` — Rust lint (only when `src-tauri/` files changed)
+8
View File
@@ -48,6 +48,9 @@ tauri.*.conf.json
*.tsbuildinfo
next-env.d.ts
# eslint
.eslintcache
#generated
src-tauri/gen
@@ -60,3 +63,8 @@ src-tauri/gen
/public/fallback-*.js
/public/swe-worker-*.js
/dist/
.claude/settings.local.json
.claude/skills
+91
View File
@@ -0,0 +1,91 @@
## Project Overview
Readest is a cross-platform ebook reader built as a **Next.js 16 + Tauri v2** hybrid app. It's part of a pnpm monorepo at `/apps/readest-app/`. The app runs on web (CloudFlare Workers), desktop (macOS/Windows/Linux via Tauri), and mobile (iOS/Android via Tauri).
## Common Commands
```bash
# Development
pnpm dev-web # Web-only dev server (no Rust compilation needed)
pnpm tauri dev # Desktop dev with Tauri (compiles Rust backend)
# Building
pnpm build # Build Next.js for Tauri
pnpm build-web # Build Next.js for web deployment
# Testing (see [docs/testing.md](docs/testing.md) for full details)
pnpm test # Unit tests (vitest + jsdom)
pnpm test -- src/__tests__/utils/misc.test.ts # Run a single test file
pnpm test -- --watch # Watch mode
pnpm test:browser # Browser tests (Chromium via Playwright)
pnpm tauri:dev:test # Start Tauri app with webdriver
pnpm test:tauri # Run Tauri integration tests
# Linting & Formatting
pnpm lint # ESLint
pnpm format # Prettier (runs from monorepo root)
pnpm format:check # Check formatting without writing
# Rust
pnpm fmt:check # Check formatting Rust code (src-tauri)
pnpm clippy:check # Lint Rust code (src-tauri)
```
### Source Layout
| Directory | Purpose |
| ----------------- | ------------------------------------------------------------- |
| `src/app/` | Next.js App Router pages and API routes |
| `src/components/` | React components (reader, settings, library, assistant, etc.) |
| `src/services/` | Business logic: TTS, translators, OPDS, sync, AI, metadata |
| `src/store/` | Zustand state stores |
| `src/hooks/` | Custom React hooks |
| `src/libs/` | Document loaders, payment, storage, sync |
| `src/utils/` | Pure utility functions |
| `src/types/` | TypeScript type definitions |
| `src/context/` | React Context providers (Auth, Env, Sync, etc.) |
| `src/workers/` | Web Workers for background tasks |
| `src-tauri/` | Rust backend: Tauri plugins, platform-specific code |
### Path Aliases (tsconfig)
- `@/*``./src/*`
- `@/components/ui/*``./src/components/primitives/*`
### Rust Backend (`src-tauri/`)
Platform-specific code lives in `src-tauri/src/{macos,windows,android,ios}/`. Custom Tauri plugins are in `src-tauri/plugins/`.
## Project Rules
Rules are in `.claude/rules/`: test-first, typescript, verification.
### i18n
See [docs/i18n.md](docs/i18n.md) for the key-as-content translation approach, `stubTranslation` usage in non-React modules, and extraction workflow.
### Safe Area Insets
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
- `/plan-eng-review` — Eng manager-mode plan review
- `/plan-design-review` — Designer's eye review of a live site
- `/design-consultation` — Design system consultation
- `/review` — Pre-landing PR review
- `/ship` — Ship workflow (merge, test, review, bump, PR)
- `/browse` — Fast headless browser for QA and site interaction
- `/qa` — QA test and fix bugs
- `/qa-only` — QA report only (no fixes)
- `/qa-design-review` — Designer's eye QA with fixes
- `/setup-browser-cookies` — Import cookies for authenticated testing
- `/retro` — Weekly engineering retrospective
- `/document-release` — Post-ship documentation update
If gstack skills aren't working, run `cd .claude/skills/gstack && ./setup` to build the binary and register skills.
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+48
View File
@@ -0,0 +1,48 @@
## i18n Guide
Readest uses a **key-as-content** approach — English strings are the translation keys. The English locale (`en/translation.json`) is empty because keys serve as content. Other locales contain actual translations.
### In React Components
```tsx
import { useTranslation } from '@/hooks/useTranslation';
const _ = useTranslation();
_('Progress synced');
```
### In Non-React Modules
Two-step process:
**1. Declaration** — Use `stubTranslation` to mark strings for scanner extraction (returns key as-is, does NOT translate):
```ts
import { stubTranslation as _ } from '@/utils/misc';
// These calls only register keys for extraction
_('Reveal in Finder');
_('Reveal in Explorer');
```
**2. Usage** — In the React component that consumes the value, apply the real `_()` from `useTranslation`:
```tsx
const _ = useTranslation();
const label = _(getRevealLabel()); // translates at runtime
```
### Extraction & Translation
```bash
pnpm i18n:extract # Scans codebase, adds new keys with __STRING_NOT_TRANSLATED__
```
- Translation files: `public/locales/<locale>/translation.json`
- Only `_('KEY')` and `_('KEY', options)` patterns are recognized by i18next-scanner
### Rules
- `stubTranslation` is for extraction only — always apply `_()` from `useTranslation` in the component for runtime translation.
- Fallback: when no translation exists, the English key itself is displayed.
- Error messages: register keys with `stubTranslation` in utility modules (e.g. `src/services/errors.ts`), return the English key from helpers, wrap with `_()` in the component.
+52
View File
@@ -0,0 +1,52 @@
## Safe Area Insets
The app runs on devices with notches, status bars, and rounded corners (iOS, Android). UI elements near screen edges must account for safe area insets to avoid being obscured.
### Key Concepts
- **`gridInsets: Insets`** — Per-view insets derived from view settings (header/footer visibility, margins). Calculated by `getViewInsets()` in `src/utils/insets.ts`. Passed as a prop from `BooksGrid` → child components.
- **`statusBarHeight: number`** — OS status bar height (default 24px). Stored in `themeStore`.
- **`systemUIVisible: boolean`** — Whether the system UI (status bar, navigation bar) is currently shown. Stored in `themeStore`.
- **`appService?.hasSafeAreaInset`** — Whether the platform requires safe area handling (mobile devices).
### Top Inset Rules
For UI elements anchored to the **top** of the screen (headers, close buttons, overlays):
```tsx
// When system UI is visible, use the larger of gridInsets.top and statusBarHeight
// When system UI is hidden, use gridInsets.top alone
style={{
marginTop: systemUIVisible
? `${Math.max(gridInsets.top, statusBarHeight)}px`
: `${gridInsets.top}px`,
}}
```
For containers that need safe area padding at the top:
```tsx
style={{
paddingTop: appService?.hasSafeAreaInset ? `${gridInsets.top}px` : '0px',
}}
```
### Bottom Inset Rules
For UI elements anchored to the **bottom** of the screen (footer bars, controls, progress indicators), use `gridInsets.bottom * 0.33` as padding — a fraction of the full inset since bottom bars don't need as much clearance as the home indicator area:
```tsx
style={{
paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0,
}}
```
### Passing `gridInsets`
When creating overlay components (image viewers, table viewers, zoom controls, etc.), always pass `gridInsets` as a prop so they can position their controls correctly:
```tsx
<ImageViewer gridInsets={gridInsets} ... />
<TableViewer gridInsets={gridInsets} ... />
<ZoomControls gridInsets={gridInsets} ... />
```
+110
View File
@@ -0,0 +1,110 @@
# Testing
Readest uses three test tiers, all powered by [Vitest](https://vitest.dev/).
## Unit Tests (`pnpm test`)
Runs tests in a **jsdom** environment. No browser or Tauri runtime required.
```bash
pnpm test # Run all unit tests
pnpm test -- src/__tests__/utils/misc.test.ts # Run a single file
pnpm test -- --watch # Watch mode
```
- **Config:** `vitest.config.mts`
- **Pattern:** `src/**/*.test.ts` (excludes `*.browser.test.ts` and `*.tauri.test.ts`)
- **Environment:** jsdom
- **Use for:** Pure logic, utilities, services that don't need real browser APIs or Tauri IPC.
## Browser Tests (`pnpm test:browser`)
Runs tests in a **real Chromium** browser via Playwright. Required for code that depends on Web Workers, SharedArrayBuffer, OPFS, or other browser-only APIs.
```bash
pnpm test:browser
```
- **Config:** `vitest.browser.config.mts`
- **Pattern:** `src/**/*.browser.test.ts`
- **Browser:** Chromium (headless, via `@vitest/browser-playwright`)
- **Use for:** WASM modules (e.g. `@tursodatabase/database-wasm`), Web Worker integration, browser-specific storage APIs.
## Tauri Integration Tests (`pnpm test:tauri`)
Runs Vitest tests **inside the Tauri WebView**, with access to Tauri IPC and native plugin commands. Tests execute in the actual app environment.
### Step 1: Start the Tauri App
In one terminal, start the app with the `webdriver` feature enabled:
```bash
pnpm tauri:dev:test # Dev mode (uses tauri dev server, faster iteration)
pnpm tauri:build:test # Debug release build (closer to production)
```
These commands compile the Rust backend with `--features webdriver`, which:
- Includes `tauri-plugin-webdriver` (embeds a W3C WebDriver server on port 4445)
- Adds a runtime capability granting plugin permissions to remote URLs (`http://127.0.0.1:*`), so Vitest's browser-mode iframe can call Tauri IPC
Keep this running while you run tests.
### Step 2: Run Tests
In another terminal:
```bash
pnpm test:tauri
```
Vitest connects directly to the embedded WebDriver server (port 4445) in the running Tauri app and executes tests inside its WebView.
- **Config:** `vitest.tauri.config.mts`
- **Pattern:** `src/**/*.tauri.test.ts`
- **Browser provider:** `@vitest/browser-webdriverio` (connects to port 4445)
- **Use for:** Tauri plugin commands (turso, native-tts, etc.), native filesystem, Tauri IPC.
### Writing Tauri Tests
Tests access Tauri IPC via a shared helper:
```typescript
import { invoke } from '../tauri/tauri-invoke';
it('calls a plugin command', async () => {
const result = await invoke('plugin:turso|load', { options: { path: 'sqlite::memory:' } });
expect(result).toBeDefined();
});
```
The `invoke()` helper accesses `window.top.__TAURI_INTERNALS__` (Vitest runs in an iframe, Tauri injects IPC into the main frame).
**Limitations:** Only custom invoke commands and plugin commands listed in the webdriver capability work. Standard Tauri JS APIs (e.g. `@tauri-apps/api`) that rely on `URL: local` may not work from the Vitest iframe.
## E2E Tests (WDIO)
Full end-to-end tests using WebDriverIO, for UI-level testing against the running Tauri app. Same two-step workflow as Tauri integration tests.
```bash
# Terminal 1: start the app (same as for Tauri integration tests)
pnpm tauri:dev:test
# Terminal 2: run E2E tests
pnpm test:e2e
```
- **Config:** `wdio.conf.ts`
- **Pattern:** `e2e/**/*.e2e.ts`
- **Framework:** Mocha (via `@wdio/mocha-framework`)
- **Connects to:** port 4445 (embedded WebDriver server)
- **Use for:** UI interaction tests, window management, navigation flows.
## Test File Naming
| Suffix | Runner | Environment |
| ------------------- | ------------------- | --------------------- |
| `*.test.ts` | `pnpm test` | jsdom |
| `*.browser.test.ts` | `pnpm test:browser` | Chromium (Playwright) |
| `*.tauri.test.ts` | `pnpm test:tauri` | Tauri WebView |
| `*.e2e.ts` | `pnpm test:e2e` | Tauri app (WDIO) |
+123
View File
@@ -0,0 +1,123 @@
describe('Readest App Launch', () => {
it('should have a visible body element', async () => {
const body = await $('body');
await body.waitForDisplayed({ timeout: 10000 });
expect(await body.isDisplayed()).toBe(true);
});
it('should have the correct window handle', async () => {
const handle = await browser.getWindowHandle();
expect(handle).toBeTruthy();
});
it('should return the page source', async () => {
const source = await browser.getPageSource();
expect(source).toContain('html');
});
});
describe('Library Page', () => {
it('should navigate to the library page', async () => {
const url = await browser.getUrl();
expect(url).toMatch(/library|localhost/);
});
it('should display the library container', async () => {
const library = await $('[aria-label="Your Library"]');
await library.waitForExist({ timeout: 15000 });
expect(await library.isExisting()).toBe(true);
});
it('should display the library header', async () => {
const header = await $('[aria-label="Library Header"]');
await header.waitForExist({ timeout: 10000 });
expect(await header.isExisting()).toBe(true);
});
it('should display the bookshelf area', async () => {
const bookshelf = await $('[aria-label="Bookshelf"]');
await bookshelf.waitForExist({ timeout: 10000 });
expect(await bookshelf.isExisting()).toBe(true);
});
it('should have a search input', async () => {
const searchInput = await $('.search-input');
await searchInput.waitForExist({ timeout: 10000 });
expect(await searchInput.isExisting()).toBe(true);
});
it('should allow typing in the search input', async () => {
const searchInput = await $('.search-input');
await searchInput.waitForDisplayed({ timeout: 10000 });
await searchInput.setValue('test search');
const value = await searchInput.getValue();
expect(value).toBe('test search');
});
it('should show the clear search button after typing', async () => {
const clearBtn = await $('[aria-label="Clear Search"]');
await clearBtn.waitForExist({ timeout: 5000 });
expect(await clearBtn.isExisting()).toBe(true);
});
it('should clear the search input when clear button is clicked', async () => {
const clearBtn = await $('[aria-label="Clear Search"]');
await clearBtn.click();
const searchInput = await $('.search-input');
const value = await searchInput.getValue();
expect(value).toBe('');
});
it('should have a select books button', async () => {
const selectBtn = await $('[aria-label="Select Books"]');
await selectBtn.waitForExist({ timeout: 10000 });
expect(await selectBtn.isExisting()).toBe(true);
});
it('should have an import books button', async () => {
const importBtn = await $('[aria-label="Import Books"]');
await importBtn.waitForExist({ timeout: 10000 });
expect(await importBtn.isExisting()).toBe(true);
});
});
describe('Window Management', () => {
it('should return the window size', async () => {
const size = await browser.getWindowSize();
expect(size.width).toBeGreaterThan(0);
expect(size.height).toBeGreaterThan(0);
});
});
describe('JavaScript Execution', () => {
it('should execute JavaScript in the app context', async () => {
const result = await browser.execute(() => {
return document.readyState;
});
expect(result).toBe('complete');
});
it('should access the document title via JS', async () => {
const title = await browser.execute(() => {
return document.title;
});
expect(title).toContain('Readest');
});
it('should detect the app platform globals', async () => {
const hasCLIAccess = await browser.execute(() => {
return (window as unknown as Record<string, unknown>).__READEST_CLI_ACCESS === true;
});
expect(hasCLIAccess).toBe(true);
});
});
describe('Navigation', () => {
it('should navigate back to library after visiting another route', async () => {
const currentUrl = await browser.getUrl();
await browser.url(currentUrl.replace(/\/[^/]*$/, '/library'));
const library = await $('[aria-label="Your Library"]');
await library.waitForExist({ timeout: 15000 });
expect(await library.isExisting()).toBe(true);
});
});
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"types": ["mocha", "@wdio/globals/types"]
},
"include": ["./**/*.ts"]
}
+6
View File
@@ -11,6 +11,7 @@ const eslintConfig = defineConfig([
{
rules: {
...jsxA11y.configs.recommended.rules,
'@next/next/no-img-element': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
@@ -25,10 +26,15 @@ const eslintConfig = defineConfig([
'node_modules/**',
'.next/**',
'.open-next/**',
'.wrangler/**',
'.claude/**',
'dist/**',
'out/**',
'build/**',
'public/**',
'src-tauri/**',
'next-env.d.ts',
'i18next-scanner.config.cjs',
]),
]);
@@ -0,0 +1,63 @@
const options = {
debug: false,
sort: false,
func: {
list: ['_'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
lngs: [
'de',
'ja',
'es',
'fa',
'fr',
'it',
'el',
'ko',
'uk',
'nl',
'sl',
'sv',
'pl',
'pt',
'ru',
'tr',
'hi',
'id',
'vi',
'ms',
'he',
'ar',
'th',
'bo',
'bn',
'ta',
'si',
'zh-CN',
'zh-TW',
],
ns: ['translation'],
defaultNs: 'translation',
defaultValue: '__STRING_NOT_TRANSLATED__',
resource: {
loadPath: './public/locales/{{lng}}/{{ns}}.json',
savePath: './public/locales/{{lng}}/{{ns}}.json',
jsonIndent: 2,
lineEnding: '\n',
},
keySeparator: false,
nsSeparator: false,
interpolation: {
prefix: '{{',
suffix: '}}',
},
metadata: {},
allowDynamicKeys: true,
removeUnusedKeys: true,
};
module.exports = {
input: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.test.{js,jsx,ts,tsx}'],
output: '.',
options,
};
@@ -1,60 +0,0 @@
module.exports = {
input: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.test.{js,jsx,ts,tsx}'],
output: '.',
options: {
debug: false,
sort: false,
func: {
list: ['_'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
lngs: [
'de',
'ja',
'es',
'fa',
'fr',
'it',
'el',
'ko',
'uk',
'nl',
'sv',
'pl',
'pt',
'ru',
'tr',
'hi',
'id',
'vi',
'ms',
'he',
'ar',
'th',
'bo',
'bn',
'ta',
'si',
'zh-CN',
'zh-TW',
],
ns: ['translation'],
defaultNs: 'translation',
defaultValue: '__STRING_NOT_TRANSLATED__',
resource: {
loadPath: './public/locales/{{lng}}/{{ns}}.json',
savePath: './public/locales/{{lng}}/{{ns}}.json',
jsonIndent: 2,
lineEnding: '\n',
},
keySeparator: false,
nsSeparator: false,
interpolation: {
prefix: '{{',
suffix: '}}',
},
metadata: {},
allowDynamicKeys: true,
removeUnusedKeys: true,
},
};
+8
View File
@@ -59,6 +59,14 @@ const nextConfig = {
'marked',
]),
],
async rewrites() {
return [
{
source: '/reader/:ids',
destination: '/reader?ids=:ids',
},
];
},
async headers() {
return [
{
+60 -22
View File
@@ -1,7 +1,8 @@
{
"name": "@readest/readest-app",
"version": "0.9.100",
"version": "0.9.101",
"private": true,
"type": "module",
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
"build": "dotenv -e .env.tauri -- next build",
@@ -9,12 +10,24 @@
"dev-web": "dotenv -e .env.web -- next dev",
"build-web": "dotenv -e .env.web -- next build",
"start-web": "dotenv -e .env.web -- next start",
"dev-web:vinext": "dotenv -e .env.web -- vinext dev",
"build-web:vinext": "dotenv -e .env.web -- vinext build",
"start-web:vinext": "dotenv -e .env.web -- vinext start",
"build-tauri": "dotenv -e .env.tauri -- next build",
"i18n:extract": "i18next-scanner",
"lint": "eslint .",
"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",
"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",
"test:all": "pnpm test -- --watch=false && pnpm test:browser && bash scripts/test-tauri.sh",
"test:e2e": "wdio run wdio.conf.ts",
"tauri:dev:test": "dotenv -e .env.tauri -- tauri dev --features webdriver",
"tauri:build:test": "dotenv -e .env.tauri -- tauri build --debug --features webdriver",
"tauri": "tauri",
"clippy": "cargo clippy -p Readest --no-deps -- -D warnings",
"fmt:check": "cargo fmt -p Readest --check",
"clippy:check": "cargo clippy -p Readest --no-deps -- -D warnings",
"format": "pnpm -w format",
"format:check": "pnpm -w format:check",
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs ./public/vendor/simplecc",
@@ -59,11 +72,14 @@
"@assistant-ui/react": "0.11.56",
"@assistant-ui/react-ai-sdk": "1.1.21",
"@assistant-ui/react-markdown": "0.11.9",
"@aws-sdk/client-s3": "^3.735.0",
"@aws-sdk/s3-request-presigner": "^3.735.0",
"@aws-sdk/client-s3": "^3.1000.0",
"@aws-sdk/s3-request-presigner": "^3.1000.0",
"@choochmeque/tauri-plugin-sharekit-api": "^0.3.0",
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@fabianlars/tauri-plugin-oauth": "2",
"@opennextjs/cloudflare": "^1.15.1",
"@napi-rs/wasm-runtime": "^1.1.1",
"@opennextjs/cloudflare": "^1.17.1",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -74,26 +90,30 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@serwist/next": "^9.4.2",
"@serwist/next": "^9.5.6",
"@serwist/webpack-plugin": "^9.5.6",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.4.0",
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.76.1",
"@tauri-apps/api": "2.9.1",
"@tauri-apps/api": "2.10.1",
"@tauri-apps/plugin-cli": "^2.4.1",
"@tauri-apps/plugin-deep-link": "^2.4.6",
"@tauri-apps/plugin-deep-link": "^2.4.7",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-haptics": "^2.3.2",
"@tauri-apps/plugin-http": "^2.5.6",
"@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "~2.3.4",
"@tauri-apps/plugin-updater": "^2.9.0",
"@tauri-apps/plugin-shell": "~2.3.5",
"@tauri-apps/plugin-updater": "^2.10.0",
"@tauri-apps/plugin-websocket": "~2.4.2",
"@tursodatabase/database-common": "^0.5.0",
"@tursodatabase/database-wasm": "^0.5.0",
"@tybys/wasm-util": "^0.10.1",
"@zip.js/zip.js": "^2.8.16",
"abortcontroller-polyfill": "^1.7.8",
"ai": "^6.0.47",
@@ -125,14 +145,15 @@
"lunr": "^2.3.9",
"marked": "^15.0.12",
"nanoid": "^5.1.6",
"next": "16.1.6",
"next": "16.1.7",
"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.0",
"react": "19.2.4",
"react-color": "^2.19.3",
"react-dom": "19.2.0",
"react-dom": "19.2.4",
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
"react-responsive": "^10.0.0",
@@ -144,6 +165,7 @@
"stripe": "^18.2.1",
"styled-jsx": "^5.1.7",
"tailwind-merge": "^3.4.0",
"tauri-plugin-device-info-api": "^1.0.1",
"tinycolor2": "^1.6.0",
"uuid": "^11.1.0",
"ws": "^8.18.3",
@@ -153,12 +175,14 @@
"devDependencies": {
"@next/bundle-analyzer": "^15.4.2",
"@tailwindcss/typography": "^0.5.16",
"@tauri-apps/cli": "2.9.6",
"@tauri-apps/cli": "2.10.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.0",
"@tursodatabase/database": "^0.5.0",
"@types/cors": "^2.8.17",
"@types/cssbeautify": "^0.3.5",
"@types/lunr": "^2.3.7",
"@types/mocha": "^10.0.10",
"@types/node": "^22.15.31",
"@types/nunjucks": "^3.2.6",
"@types/react": "^19.0.0",
@@ -171,28 +195,42 @@
"@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",
"@vitest/browser-playwright": "^4.0.18",
"@vitest/browser-webdriverio": "^4.0.18",
"@wdio/cli": "^9.25.0",
"@wdio/globals": "^9.23.0",
"@wdio/local-runner": "^9.24.0",
"@wdio/mocha-framework": "^9.24.0",
"@wdio/spec-reporter": "^9.24.0",
"@wdio/types": "^9.24.0",
"autoprefixer": "^10.4.20",
"caniuse-lite": "^1.0.30001746",
"cpx2": "^8.0.0",
"daisyui": "^4.12.24",
"dotenv-cli": "^7.4.4",
"eslint": "^9.16.0",
"eslint-config-next": "16.0.0",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.6",
"eslint-plugin-jsx-a11y": "^6.10.2",
"i18next-scanner": "^4.6.0",
"jsdom": "^26.1.0",
"jsdom": "^28.1.0",
"mkdirp": "^3.0.1",
"node-env-run": "^4.0.2",
"playwright": "^1.58.2",
"postcss": "^8.4.49",
"postcss-cli": "^11.0.0",
"postcss-nested": "^7.0.2",
"raw-loader": "^4.0.2",
"react-server-dom-webpack": "^19.2.4",
"serwist": "^9.3.0",
"tailwindcss": "^3.4.18",
"typescript": "^5.7.2",
"vinext": "^0.0.21",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.0.15",
"wrangler": "^4.60.0"
"vitest": "^4.0.18",
"wrangler": "^4.73.0"
}
}
@@ -105,7 +105,6 @@
"Wikipedia": "ويكيبيديا",
"Writing Mode": "وضع الكتابة",
"Your Library": "المكتبة الخاصة بك",
"TTS not supported for PDF": "القراءة الصوتية غير مدعومة لملفات PDF",
"Override Book Font": "تجاوز خط الكتاب",
"Apply to All Books": "تطبيق على جميع الكتب",
"Apply to This Book": "تطبيق على هذا الكتاب",
@@ -346,12 +345,12 @@
"Fit": "ملاءمة",
"Reset {{settings}}": "إعادة تعيين {{settings}}",
"Reset Settings": "إعادة تعيين الإعدادات",
"{{count}} pages left in chapter_zero": "لم يتبق أي صفحات في هذا الفصل",
"{{count}} pages left in chapter_one": "تبقّت صفحة واحدة في هذا الفصل",
"{{count}} pages left in chapter_two": "تبقّت صفحتان في هذا الفصل",
"{{count}} pages left in chapter_few": "تبقّت {{count}} صفحات في هذا الفصل",
"{{count}} pages left in chapter_many": "تبقّت {{count}} صفحة في هذا الفصل",
"{{count}} pages left in chapter_other": "تبقّى {{count}} صفحة في هذا الفصل",
"{{count}} pages left in chapter_zero": "<1>لم يتبق أي صفحات في هذا الفصل</1>",
"{{count}} pages left in chapter_one": "<1>تبقّت صفحة واحدة في هذا الفصل</1>",
"{{count}} pages left in chapter_two": "<1>تبقّت صفحتان في هذا الفصل</1>",
"{{count}} pages left in chapter_few": "<1>تبقّت </1><0>{{count}}</0><1> صفحات في هذا الفصل</1>",
"{{count}} pages left in chapter_many": "<1>تبقّت </1><0>{{count}}</0><1> صفحة في هذا الفصل</1>",
"{{count}} pages left in chapter_other": "<1>تبقّى </1><0>{{count}}</0><1> صفحة في هذا الفصل</1>",
"Show Remaining Pages": "عرض الصفحات المتبقية",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -615,11 +614,10 @@
"Size": "حجم",
"Cover": "غطاء",
"Contain": "احتواء",
"{{number}} pages left in chapter": "تبقّت {{number}} صفحات في هذا الفصل",
"{{number}} pages left in chapter": "<1>تبقّت </1><0>{{number}}</0><1> صفحات في هذا الفصل</1>",
"Device": "الجهاز",
"E-Ink Mode": "وضع الحبر الإلكتروني",
"Highlight Colors": "ألوان التمييز",
"Auto Screen Brightness": "سطوع الشاشة التلقائي",
"Pagination": "التقسيم إلى صفحات",
"Disable Double Tap": "تعطيل النقر المزدوج",
"Tap to Paginate": "اضغط للتقسيم إلى صفحات",
@@ -873,6 +871,9 @@
"Clear search": "مسح البحث",
"Clear search history": "مسح سجل البحث",
"Tap to Toggle Footer": "انقر لتبديل التذييل",
"Show Current Time": "عرض الوقت الحالي",
"Use 24 Hour Clock": "استخدام نظام 24 ساعة",
"Show Current Battery Status": "عرض حالة البطارية الحالية",
"Exported successfully": "تم التصدير بنجاح",
"Book exported successfully.": "تم تصدير الكتاب بنجاح.",
"Failed to export the book.": "فشل تصدير الكتاب.",
@@ -1037,10 +1038,8 @@
"Play (Space)": "تشغيل (المسافة)",
"Skip forward 15 words": "تخطي للأمام 15 كلمة",
"Forward 15 words (Shift+Right)": "للأمام 15 كلمة (Shift+اليمين)",
"Pause:": "إيقاف مؤقت:",
"Decrease speed": "تقليل السرعة",
"Slower (Left/Down)": "أبطأ (اليسار/الأسفل)",
"Current speed": "السرعة الحالية",
"Increase speed": "زيادة السرعة",
"Faster (Right/Up)": "أسرع (اليمين/الأعلى)",
"Start RSVP Reading": "بدء قراءة RSVP",
@@ -1079,5 +1078,81 @@
"Books": "الكتب",
"Groups": "المجموعات",
"Back to TTS Location": "العودة إلى موقع القراءة الآلية",
"Metadata": "بيانات وصفية"
"Metadata": "بيانات وصفية",
"Image viewer": "عارض الصور",
"Previous Image": "الصورة السابقة",
"Next Image": "الصورة التالية",
"Zoomed": "مكبّر",
"Zoom level": "مستوى التكبير",
"Table viewer": "عارض الجداول",
"Unable to connect to Readwise. Please check your network connection.": "تعذر الاتصال بـ Readwise. يرجى التحقق من اتصال الشبكة لديك.",
"Invalid Readwise access token": "رمز وصول Readwise غير صالح",
"Disconnected from Readwise": "تم قطع الاتصال بـ Readwise",
"Never": "أبداً",
"Readwise Settings": "إعدادات Readwise",
"Connected to Readwise": "متصل بـ Readwise",
"Last synced: {{time}}": "آخر مزامنة: {{time}}",
"Sync Enabled": "تم تمكين المزامنة",
"Disconnect": "قطع الاتصال",
"Connect your Readwise account to sync highlights.": "قم بتوصيل حساب Readwise الخاص بك لمزامنة التمييزات.",
"Get your access token at": "احصل على رمز الوصول الخاص بك من",
"Access Token": "رمز الوصول",
"Paste your Readwise access token": "الصق رمز وصول Readwise الخاص بك",
"Config": "تكوين",
"Readwise Sync": "مزامنة Readwise",
"Push Highlights": "إرسال التمييزات",
"Highlights synced to Readwise": "تمت مزامنة التمييزات مع Readwise",
"Readwise sync failed: no internet connection": "فشلت مزامنة Readwise: لا يوجد اتصال بالإنترنت",
"Readwise sync failed: {{error}}": "فشلت مزامنة Readwise: {{error}}",
"System Screen Brightness": "سطوع شاشة النظام",
"Page:": "صفحة:",
"Page: {{number}}": "صفحة: {{number}}",
"Annotation page number": "رقم صفحة التعליق",
"Translating...": "جارٍ الترجمة...",
"Show Battery Percentage": "عرض نسبة البطارية",
"Hide Scrollbar": "إخفاء شريط التمرير",
"Skip to last reading position": "انتقل إلى آخر موضع قراءة",
"Show context": "إظهار السياق",
"Hide context": "إخفاء السياق",
"Decrease font size": "تصغير حجم الخط",
"Increase font size": "تكبير حجم الخط",
"Focus": "تركيز",
"Theme color": "لون السمة",
"Import failed": "فشل الاستيراد",
"Available Formatters:": "المُنسّقات المتاحة:",
"Format date": "تنسيق التاريخ",
"Markdown block quote (> per line)": "اقتباس Markdown (> لكل سطر)",
"Newlines to <br>": "أسطر جديدة إلى <br>",
"Change case": "تغيير حالة الأحرف",
"Trim whitespace": "إزالة المسافات",
"Truncate to n characters": "اقتطاع إلى n حرف",
"Replace text": "استبدال النص",
"Fallback value": "القيمة الاحتياطية",
"Get length": "الحصول على الطول",
"First/last element": "العنصر الأول/الأخير",
"Join array": "دمج المصفوفة",
"Backup failed: {{error}}": "فشل النسخ الاحتياطي: {{error}}",
"Select Backup": "اختر نسخة احتياطية",
"Restore failed: {{error}}": "فشل الاستعادة: {{error}}",
"Backup & Restore": "النسخ الاحتياطي والاستعادة",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "أنشئ نسخة احتياطية من مكتبتك أو استعد من نسخة احتياطية سابقة. ستدمج الاستعادة مع مكتبتك الحالية.",
"Backup Library": "نسخ المكتبة احتياطيًا",
"Restore Library": "استعادة المكتبة",
"Creating backup...": "جارٍ إنشاء النسخة الاحتياطية...",
"Restoring library...": "جارٍ استعادة المكتبة...",
"{{current}} of {{total}} items": "{{current}} من {{total}} عنصر",
"Backup completed successfully!": "تم النسخ الاحتياطي بنجاح!",
"Restore completed successfully!": "تمت الاستعادة بنجاح!",
"Your library has been saved to the selected location.": "تم حفظ مكتبتك في الموقع المحدد.",
"{{added}} books added, {{updated}} books updated.": "تمت إضافة {{added}} كتب، وتحديث {{updated}} كتب.",
"Operation failed": "فشلت العملية",
"Loading library...": "جارٍ تحميل المكتبة...",
"{{count}} books refreshed_zero": "لم يتم تحديث أي كتاب",
"{{count}} books refreshed_one": "تم تحديث كتاب واحد",
"{{count}} books refreshed_two": "تم تحديث كتابين",
"{{count}} books refreshed_few": "تم تحديث {{count}} كتب",
"{{count}} books refreshed_many": "تم تحديث {{count}} كتابًا",
"{{count}} books refreshed_other": "تم تحديث {{count}} كتاب",
"Failed to refresh metadata": "فشل تحديث البيانات الوصفية",
"Refresh Metadata": "تحديث البيانات الوصفية"
}
@@ -186,8 +186,8 @@
"Add your notes here...": "এখানে আপনার নোট যোগ করুন...",
"Search notes and excerpts...": "নোট ও উদ্ধৃতি খুঁজুন...",
"{{time}} min left in chapter": "অধ্যায়ে {{time}} মিনিট বাকি",
"{{count}} pages left in chapter_one": "অধ্যায়ে ১ পৃষ্ঠা বাকি",
"{{count}} pages left in chapter_other": "অধ্যায়ে {{count}} পৃষ্ঠা বাকি",
"{{count}} pages left in chapter_one": "<1>অধ্যায়ে ১ পৃষ্ঠা বাকি</1>",
"{{count}} pages left in chapter_other": "<1>অধ্যায়ে </1><0>{{count}}</0><1> পৃষ্ঠা বাকি</1>",
"Theme Mode": "থিম মোড",
"Invert Image In Dark Mode": "ডার্ক মোডে ছবি উল্টান",
"Override Book Color": "বইয়ের রঙ পরিবর্তন",
@@ -319,7 +319,6 @@
"Table of Contents": "সূচিপত্র",
"Sidebar": "সাইডবার",
"Disable Translation": "অনুবাদ নিষ্ক্রিয়",
"TTS not supported for PDF": "PDF এর জন্য TTS সমর্থিত নয়",
"TTS not supported for this document": "এই ডকুমেন্টের জন্য TTS সমর্থিত নয়",
"No Timeout": "কোন টাইমআউট নেই",
"{{value}} minute": "{{value}} মিনিট",
@@ -599,11 +598,10 @@
"Size": "আকার",
"Cover": "ঢাকা",
"Contain": "ধারণ",
"{{number}} pages left in chapter": "অধ্যায়ে {{number}} পৃষ্ঠা বাকি",
"{{number}} pages left in chapter": "<1>অধ্যায়ে </1><0>{{number}}</0><1> পৃষ্ঠা বাকি</1>",
"Device": "যন্ত্র",
"E-Ink Mode": "ই-ইঙ্ক মোড",
"Highlight Colors": "হাইলাইট রঙ",
"Auto Screen Brightness": "স্বয়ংক্রিয় স্ক্রিন উজ্জ্বলতা",
"Pagination": "পৃষ্ঠা বিন্যাস",
"Disable Double Tap": "ডাবল ট্যাপ অক্ষম করুন",
"Tap to Paginate": "পৃষ্ঠায় যেতে ট্যাপ করুন",
@@ -833,6 +831,9 @@
"Clear search": "অনুসন্ধান সাফ করুন",
"Clear search history": "অনুসন্ধান ইতিহাস সাফ করুন",
"Tap to Toggle Footer": "ফুটার টগল করতে আলতো চাপুন",
"Show Current Time": "বর্তমান সময় দেখান",
"Use 24 Hour Clock": "২৪ ঘণ্টার ঘড়ি ব্যবহার করুন",
"Show Current Battery Status": "বর্তমান ব্যাটারি অবস্থা দেখান",
"Exported successfully": "সফলভাবে রপ্তানি হয়েছে",
"Book exported successfully.": "বইটি সফলভাবে রপ্তানি হয়েছে।",
"Failed to export the book.": "বই রপ্তানি করতে ব্যর্থ।",
@@ -989,10 +990,8 @@
"Play (Space)": "চালান (Space)",
"Skip forward 15 words": "১৫ শব্দ এগিয়ে যান",
"Forward 15 words (Shift+Right)": "১৫ শব্দ এগিয়ে যান (Shift+Right)",
"Pause:": "বিরতি:",
"Decrease speed": "গতি কমান",
"Slower (Left/Down)": "ধীর (Left/Down)",
"Current speed": "বর্তমান গতি",
"Increase speed": "গতি বাড়ান",
"Faster (Right/Up)": "দ্রুত (Right/Up)",
"Start RSVP Reading": "RSVP পড়া শুরু করুন",
@@ -1031,5 +1030,77 @@
"Books": "বই",
"Groups": "দল",
"Back to TTS Location": "টিটিএস অবস্থানে ফিরে যান",
"Metadata": "মেটাডেটা"
"Metadata": "মেটাডেটা",
"Image viewer": "চিত্র প্রদর্শক",
"Previous Image": "পূর্ববর্তী চিত্র",
"Next Image": "পরবর্তী চিত্র",
"Zoomed": "জুম করা হয়েছে",
"Zoom level": "জুম স্তর",
"Table viewer": "টেবিল প্রদর্শক",
"Unable to connect to Readwise. Please check your network connection.": "Readwise এর সাথে সংযোগ করতে অক্ষম। আপনার নেটওয়ার্ক সংযোগটি পরীক্ষা করুন।",
"Invalid Readwise access token": "অকার্যকর Readwise অ্যাক্সেস টোকেন",
"Disconnected from Readwise": "Readwise থেকে সংযোগ বিচ্ছিন্ন হয়েছে",
"Never": "কখনো না",
"Readwise Settings": "Readwise সেটিংস",
"Connected to Readwise": "Readwise এর সাথে সংযুক্ত",
"Last synced: {{time}}": "শেষ সিঙ্ক করা হয়েছে: {{time}}",
"Sync Enabled": "সিঙ্কিং সক্ষম",
"Disconnect": "সংযোগ বিচ্ছিন্ন করুন",
"Connect your Readwise account to sync highlights.": "হাইলাইটগুলি সিঙ্ক করার জন্য আপনার Readwise অ্যাকাউন্টটি সংযোগ করুন।",
"Get your access token at": "আপনার অ্যাক্সেস টোকেনটি এখানে পাবেন",
"Access Token": "অ্যাক্সেস টোকেন",
"Paste your Readwise access token": "আপনার Readwise অ্যাক্সেস টোকেনটি পেস্ট করুন",
"Config": "কনফিগ",
"Readwise Sync": "Readwise সিঙ্ক",
"Push Highlights": "হাইলাইট পাঠান",
"Highlights synced to Readwise": "হাইলাইটগুলি Readwise এ সিঙ্ক করা হয়েছে",
"Readwise sync failed: no internet connection": "Readwise সিঙ্ক ব্যর্থ হয়েছে: ইন্টারনেট সংযোগ নেই",
"Readwise sync failed: {{error}}": "Readwise সিঙ্ক ব্যর্থ হয়েছে: {{error}}",
"System Screen Brightness": "সিস্টেম স্ক্রিন উজ্জ্বলতা",
"Page:": "পৃষ্ঠা:",
"Page: {{number}}": "পৃষ্ঠা: {{number}}",
"Annotation page number": "টীকা পৃষ্ঠা নম্বর",
"Translating...": "অনুবাদ করা হচ্ছে...",
"Show Battery Percentage": "ব্যাটারি শতাংশ দেখান",
"Hide Scrollbar": "স্ক্রলবার লুকান",
"Skip to last reading position": "শেষ পড়ার অবস্থানে যান",
"Show context": "প্রসঙ্গ দেখান",
"Hide context": "প্রসঙ্গ লুকান",
"Decrease font size": "ফন্টের আকার কমান",
"Increase font size": "ফন্টের আকার বাড়ান",
"Focus": "ফোকাস",
"Theme color": "থিম রঙ",
"Import failed": "আমদানি ব্যর্থ",
"Available Formatters:": "উপলব্ধ ফরম্যাটার:",
"Format date": "তারিখ ফরম্যাট",
"Markdown block quote (> per line)": "Markdown ব্লক কোট (প্রতি লাইনে >)",
"Newlines to <br>": "নতুন লাইন থেকে <br>",
"Change case": "অক্ষরের কেস পরিবর্তন",
"Trim whitespace": "শূন্যস্থান ছাঁটা",
"Truncate to n characters": "n অক্ষরে সংক্ষিপ্ত করুন",
"Replace text": "টেক্সট প্রতিস্থাপন",
"Fallback value": "ফলব্যাক মান",
"Get length": "দৈর্ঘ্য নিন",
"First/last element": "প্রথম/শেষ উপাদান",
"Join array": "অ্যারে যুক্ত করুন",
"Backup failed: {{error}}": "ব্যাকআপ ব্যর্থ: {{error}}",
"Select Backup": "ব্যাকআপ নির্বাচন করুন",
"Restore failed: {{error}}": "পুনরুদ্ধার ব্যর্থ: {{error}}",
"Backup & Restore": "ব্যাকআপ ও পুনরুদ্ধার",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "আপনার লাইব্রেরির ব্যাকআপ তৈরি করুন বা পূর্ববর্তী ব্যাকআপ থেকে পুনরুদ্ধার করুন। পুনরুদ্ধার আপনার বর্তমান লাইব্রেরির সাথে মার্জ হবে।",
"Backup Library": "লাইব্রেরি ব্যাকআপ",
"Restore Library": "লাইব্রেরি পুনরুদ্ধার",
"Creating backup...": "ব্যাকআপ তৈরি হচ্ছে...",
"Restoring library...": "লাইব্রেরি পুনরুদ্ধার হচ্ছে...",
"{{current}} of {{total}} items": "{{current}} / {{total}} আইটেম",
"Backup completed successfully!": "ব্যাকআপ সফলভাবে সম্পন্ন!",
"Restore completed successfully!": "পুনরুদ্ধার সফলভাবে সম্পন্ন!",
"Your library has been saved to the selected location.": "আপনার লাইব্রেরি নির্বাচিত স্থানে সংরক্ষিত হয়েছে।",
"{{added}} books added, {{updated}} books updated.": "{{added}}টি বই যোগ হয়েছে, {{updated}}টি বই আপডেট হয়েছে।",
"Operation failed": "অপারেশন ব্যর্থ",
"Loading library...": "লাইব্রেরি লোড হচ্ছে...",
"{{count}} books refreshed_one": "{{count}}টি বইয়ের মেটাডেটা রিফ্রেশ হয়েছে",
"{{count}} books refreshed_other": "{{count}}টি বইয়ের মেটাডেটা রিফ্রেশ হয়েছে",
"Failed to refresh metadata": "মেটাডেটা রিফ্রেশ ব্যর্থ",
"Refresh Metadata": "মেটাডেটা রিফ্রেশ"
}
@@ -106,7 +106,6 @@
"Wikipedia": "ཝེ་ཁི་པི་ཌི་ཡ།",
"Writing Mode": "པར་སྒྲིག་གི་རྣམ་པ།",
"Your Library": "དཔེ་མཛོད།",
"TTS not supported for PDF": "PDF ཡིག་ཆར་ TTS རྒྱབ་སྐྱོར་མི་བྱེད།",
"Override Book Font": "དཔེ་དེབ་ཀྱི་ཡིག་གཟུགས་བརྗེ་བ།",
"Apply to All Books": "དཔེ་དེབ་ཡོད་རྒུར་སྤྱོད་པ།",
"Apply to This Book": "དཔེ་དེབ་འདིར་སྤྱོད་པ།",
@@ -331,7 +330,7 @@
"Fit": "མཐུན་སྒྲིག",
"Reset {{settings}}": "{{settings}} བསྐྱར་སྒྲིག",
"Reset Settings": "སྒྲིག་བཀོད་བསྐྱར་སྒྲིག",
"{{count}} pages left in chapter_other": "ལེའུ་འདིར་ད་དུང་ {{count}} ཤོག་ལྷེ་ལྷག་ཡོད།",
"{{count}} pages left in chapter_other": "<1>ལེའུ་འདིར་ད་དུང་ </1><0>{{count}}</0><1> ཤོག་ལྷེ་ལྷག་ཡོད།</1>",
"Show Remaining Pages": "ལྷག་མའི་ཤོག་ལྷེའི་གྲངས་ཀ་མངོན་པ།",
"Source Han Serif CN": "སི་ཡོན་སུང་ཐི།",
"Huiwen-MinchoGBK": "ཧུའེ་ཝུན་མིང་ཁྲའོ་ཐི།",
@@ -595,11 +594,10 @@
"Size": "ཨང་",
"Cover": "དེབ་གྱི་སྤྱོད་བྱས་མ་ཐུབ།",
"Contain": "འབྱོར་བ།",
"{{number}} pages left in chapter": "དོན་ཚན་ནང་ཤོག་ཨང་ {{number}} ལོག་གི་འདུག།",
"{{number}} pages left in chapter": "<1>དོན་ཚན་ནང་ཤོག་ཨང་ </1><0>{{number}}</0><1> ལོག་གི་འདུག།</1>",
"Device": "རྐྱབ་སྐོར",
"E-Ink Mode": "ཡིག་སྒྱུར་སྤོ་བ",
"Highlight Colors": "མདོག་ཚད་ཀྱི་བསྡུར་བ།",
"Auto Screen Brightness": "རྒྱབ་སྐོར་གྱི་འོད་ཟེར་རང་འགུལ།",
"Pagination": "ཤོག་གདོང་།",
"Disable Double Tap": "བརྒྱབ་གདོང་གཉིས་མ་འགྱོད།",
"Tap to Paginate": "ཤོག་གདོང་ལ་ཐོག་འགྲོ།",
@@ -823,6 +821,9 @@
"Clear search": "འཚོལ་བཤེར་གཙང་སེལ།",
"Clear search history": "འཚོལ་བཤེར་ལོ་རྒྱུས་གཙང་སེལ།",
"Tap to Toggle Footer": "ཞབས་མཇུག་སྒོ་འབྱེད་བྱེད་པར་གནོན།",
"Show Current Time": "ད་ལྟའི་དུས་ཚོད་སྟོན་པ།",
"Use 24 Hour Clock": "ཆུ་ཚོད་ ༢༤ ཅན་གྱི་ཟློས་འཁོར་བཀོལ་བ།",
"Show Current Battery Status": "ད་ལྟའི་གློག་རྫས་གནས་བབ་སྟོན་པ།",
"Exported successfully": "ཕྱིར་འདོན་ལེགས་གྲུབ་བྱུང་།",
"Book exported successfully.": "དཔེ་དེབ་ཕྱིར་འདོན་ལེགས་གྲུབ་བྱུང་།",
"Failed to export the book.": "དཔེ་དེབ་ཕྱིར་འདོན་མི་ཐུབ།",
@@ -977,10 +978,8 @@
"Play (Space)": "གཏོང་བ། (Space)",
"Skip forward 15 words": "ཚིག་ ༡༥ མདུན་ལ་བཤུད་པ།",
"Forward 15 words (Shift+Right)": "ཚིག་ ༡༥ མདུན་ལ་བཤུད་པ། (Shift+Right)",
"Pause:": "མཚམས་འཇོག་པ།",
"Decrease speed": "མགྱོགས་ཚད་འགོར་དུ་གཏོང་བ།",
"Slower (Left/Down)": "འགོར་བ། (Left/Down)",
"Current speed": "ད་ལྟའི་མགྱོགས་ཚད།",
"Increase speed": "མགྱོགས་ཚད་མྱུར་དུ་གཏོང་བ།",
"Faster (Right/Up)": "མྱུར་བ། (Right/Up)",
"Start RSVP Reading": "RSVP ཀློག་འགོ་འཛུགས་པ།",
@@ -1019,5 +1018,76 @@
"Books": "དཔེ་ཆ།",
"Groups": "ཚོགས་པ།",
"Back to TTS Location": "TTS གནས་སར་ཕྱིར་ལོག་པ།",
"Metadata": "གནད་སྨིན་གོ་དོན།"
"Metadata": "གནད་སྨིན་གོ་དོན།",
"Image viewer": "པར་རིས་ལྟ་བྱེད།",
"Previous Image": "སྔོན་མའི་པར་རིས།",
"Next Image": "རྗེས་མའི་པར་རིས།",
"Zoomed": "ཆེར་བསྐྱེད་ཟིན།",
"Zoom level": "ཆེར་བསྐྱེད་རིམ་པ།",
"Table viewer": "རེའུ་མིག་ལྟ་བྱེད།",
"Unable to connect to Readwise. Please check your network connection.": "Readwise ལ་མཐུད་ཐུབ་མ་སོང་། ཁྱེད་ཀྱི་དྲ་རྒྱའི་མཐུད་ལམ་ལ་བརྟག་དཔྱད་གནང་རོགས།",
"Invalid Readwise access token": "Readwise འཛུལ་སྤྱོད་ལག་ཁྱེར་ནོར་འདུག",
"Disconnected from Readwise": "Readwise ནས་མཐུད་ལམ་བཅད་ཟིན།",
"Never": "ནམ་ཡང་མིན།",
"Readwise Settings": "Readwise སྒྲིག་བཀོད།",
"Connected to Readwise": "Readwise ལ་མཐུད་ཟིན།",
"Last synced: {{time}}": "མཐའ་མའི་མཉམ་བྱུང་དུས་ཚོད། {{time}}",
"Sync Enabled": "མཉམ་བྱུང་ནུས་པ་སྤར་ཟིན།",
"Disconnect": "མཐུད་ལམ་གཅོད་པ།",
"Connect your Readwise account to sync highlights.": "ཁྱེད་ཀྱི་ Readwise རྩིས་ཐོ་མཐུད་ནས་བཀོད་མཆན་མཉམ་བྱུང་གནང་རོགས།",
"Get your access token at": "འཛུལ་སྤྱོད་ལག་ཁྱེར་འདི་ནས་ལེན་རོགས།",
"Access Token": "འཛུལ་སྤྱོད་ལག་ཁྱེེར།",
"Paste your Readwise access token": "Readwise འཛུལ་སྤྱོད་ལག་ཁྱེར་འདིར་སྦྱོར་རོགས།",
"Config": "སྒྲིག་བཀོད།",
"Readwise Sync": "Readwise མཉམ་བྱུང་།",
"Push Highlights": "བཀོད་མཆན་སྐྱེལ་བ།",
"Highlights synced to Readwise": "བཀོད་མཆན་ Readwise ལ་མཉམ་བྱུང་བྱས་ཟིན།",
"Readwise sync failed: no internet connection": "Readwise མཉམ་བྱུང་མ་ཐུབ། དྲ་རྒྱའི་མཐུད་ལམ་མི་འདུག",
"Readwise sync failed: {{error}}": "Readwise མཉམ་བྱུང་མ་ཐུབ། {{error}}",
"System Screen Brightness": "མ་ལག་ཤེལ་སྒོའི་གསལ་ཚད།",
"Page:": "ཤོག་ལྷེ།:",
"Page: {{number}}": "ཤོག་ལྷེ།: {{number}}",
"Annotation page number": "མཆན་འགྲེལ་ཤོག་ཨང་།",
"Translating...": "ཡིག་བསྒྱུར་བྱེད་བཞིན་པ།...",
"Show Battery Percentage": "གློག་རྫས་བརྒྱ་ཆ་སྟོན།",
"Hide Scrollbar": "འགྲིལ་ཤིང་སྦེད་པ།",
"Skip to last reading position": "མཐའ་མའི་ཀློག་གནས་སུ་མཆོང་",
"Show context": "སྐབས་དོན་མངོན་པ",
"Hide context": "སྐབས་དོན་སྦས་པ",
"Decrease font size": "ཡིག་གཟུགས་ཆུང་དུ་གཏོང་བ",
"Increase font size": "ཡིག་གཟུགས་ཆེ་རུ་གཏོང་བ",
"Focus": "དམིགས་གཏད",
"Theme color": "བརྗོད་དོན་ཚོན་མདོག",
"Import failed": "ནང་འདྲེན་མི་ཐུབ།",
"Available Formatters:": "སྒྲིག་བཟོ་ཆས་ཡོད་པ།:",
"Format date": "ཚེས་གྲངས་སྒྲིག་བཟོ",
"Markdown block quote (> per line)": "Markdown དྲིལ་བསྡུ་འདྲེན་ (མི་ལྟར་ > )",
"Newlines to <br>": "གསར་བརྗེ་ <br> ལ་བསྒྱུར",
"Change case": "ཡིག་གཟུགས་བསྒྱུར",
"Trim whitespace": "སྟོང་ཆ་གཙང་བཟོ",
"Truncate to n characters": "n ཡི་གེར་ཐུང་དུ་བཅད",
"Replace text": "ཡི་གེ་བརྗེ་བ",
"Fallback value": "ཚབ་ཀྱི་གྲངས་ཀ",
"Get length": "རིང་ཚད་ལེན",
"First/last element": "དང་པོ/མཇུག་གི་རྒྱུ་ཆ",
"Join array": "ཚོ་སྒྲིག་སྦྲེལ",
"Backup failed: {{error}}": "གྲབས་ཉར་བྱེད་མ་ཐུབ: {{error}}",
"Select Backup": "གྲབས་ཉར་འདེམས་པ",
"Restore failed: {{error}}": "སླར་གསོ་བྱེད་མ་ཐུབ: {{error}}",
"Backup & Restore": "གྲབས་ཉར་དང་སླར་གསོ",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "ཁྱོད་ཀྱི་དཔེ་མཛོད་ཀྱི་གྲབས་ཉར་བཟོ་བའམ་སྔོན་གྱི་གྲབས་ཉར་ནས་སླར་གསོ་བྱོས། སླར་གསོ་བྱས་ན་ཁྱོད་ཀྱི་ད་ལྟའི་དཔེ་མཛོད་དང་སྤྲོད་རེས་བྱེད།",
"Backup Library": "དཔེ་མཛོད་གྲབས་ཉར",
"Restore Library": "དཔེ་མཛོད་སླར་གསོ",
"Creating backup...": "གྲབས་ཉར་བཟོ་བཞིན་པ...",
"Restoring library...": "དཔེ་མཛོད་སླར་གསོ་བྱེད་བཞིན་པ...",
"{{current}} of {{total}} items": "{{current}} / {{total}} རྣམ་གྲངས",
"Backup completed successfully!": "གྲབས་ཉར་ལེགས་གྲུབ་བྱུང་!",
"Restore completed successfully!": "སླར་གསོ་ལེགས་གྲུབ་བྱུང་!",
"Your library has been saved to the selected location.": "ཁྱོད་ཀྱི་དཔེ་མཛོད་འདེམས་ས་དེར་ཉར་ཟིན།",
"{{added}} books added, {{updated}} books updated.": "དཔེ་དེབ {{added}} བསྣན་ཟིན, {{updated}} གསར་བསྒྱུར་བྱས་ཟིན།",
"Operation failed": "བཀོལ་སྤྱོད་བྱེད་མ་ཐུབ",
"Loading library...": "དཔེ་མཛོད་འཇུག་བཞིན་པ...",
"{{count}} books refreshed_other": "تم تحديث {{count}} كتب",
"Failed to refresh metadata": "མེ་ཊ་ཌེ་ཊ་གསར་བསྒྱུར་བྱེད་མ་ཐུབ",
"Refresh Metadata": "མེ་ཊ་ཌེ་ཊ་གསར་བསྒྱུར"
}
@@ -105,7 +105,6 @@
"Wikipedia": "Wikipedia",
"Writing Mode": "Schreibmodus",
"Your Library": "Ihre Bibliothek",
"TTS not supported for PDF": "TTS wird für PDF nicht unterstützt",
"Override Book Font": "Buch-Schriftart überschreiben",
"Apply to All Books": "Auf alle Bücher anwenden",
"Apply to This Book": "Auf dieses Buch anwenden",
@@ -334,8 +333,8 @@
"Fit": "Anpassen",
"Reset {{settings}}": "{{settings}} zurücksetzen",
"Reset Settings": "Einstellungen zurücksetzen",
"{{count}} pages left in chapter_one": "{{count}} Seite verbleibend im Kapitel",
"{{count}} pages left in chapter_other": "{{count}} Seiten verbleibend im Kapitel",
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> Seite verbleibend im Kapitel</1>",
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> Seiten verbleibend im Kapitel</1>",
"Show Remaining Pages": "Verbleibende Seiten anzeigen",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -599,11 +598,10 @@
"Size": "Größe",
"Cover": "Cover",
"Contain": "Inhalt",
"{{number}} pages left in chapter": "{{number}} Seiten verbleibend im Kapitel",
"{{number}} pages left in chapter": "<0>{{number}}</0><1> Seiten verbleibend im Kapitel</1>",
"Device": "Gerät",
"E-Ink Mode": "E-Ink-Modus",
"Highlight Colors": "Hervorhebungsfarben",
"Auto Screen Brightness": "Automatische Bildschirmhelligkeit",
"Pagination": "Seitenumbruch",
"Disable Double Tap": "Doppeltippen deaktivieren",
"Tap to Paginate": "Tippen zum Blättern",
@@ -833,6 +831,9 @@
"Clear search": "Suche löschen",
"Clear search history": "Suchverlauf löschen",
"Tap to Toggle Footer": "Tippen, um Fußzeile umzuschalten",
"Show Current Time": "Aktuelle Uhrzeit anzeigen",
"Use 24 Hour Clock": "24-Stunden-Format verwenden",
"Show Current Battery Status": "Aktuellen Akkustand anzeigen",
"Exported successfully": "Erfolgreich exportiert",
"Book exported successfully.": "Buch erfolgreich exportiert.",
"Failed to export the book.": "Buch konnte nicht exportiert werden.",
@@ -989,10 +990,8 @@
"Play (Space)": "Abspielen (Leertaste)",
"Skip forward 15 words": "15 Wörter vor",
"Forward 15 words (Shift+Right)": "15 Wörter vor (Shift+Rechts)",
"Pause:": "Pause:",
"Decrease speed": "Geschwindigkeit verringern",
"Slower (Left/Down)": "Langsamer (Links/Unten)",
"Current speed": "Aktuelle Geschwindigkeit",
"Increase speed": "Geschwindigkeit erhöhen",
"Faster (Right/Up)": "Schneller (Rechts/Oben)",
"Start RSVP Reading": "RSVP-Lesen starten",
@@ -1031,5 +1030,77 @@
"Books": "Bücher",
"Groups": "Gruppen",
"Back to TTS Location": "Zurück zur TTS-Position",
"Metadata": "Metadaten"
"Metadata": "Metadaten",
"Image viewer": "Bildbetrachter",
"Previous Image": "Vorheriges Bild",
"Next Image": "Nächstes Bild",
"Zoomed": "Gezoomt",
"Zoom level": "Zoomstufe",
"Table viewer": "Tabellenbetrachter",
"Unable to connect to Readwise. Please check your network connection.": "Verbindung zu Readwise nicht möglich. Bitte überprüfen Sie Ihre Netzwerkverbindung.",
"Invalid Readwise access token": "Ungültiges Readwise-Zugriffstoken",
"Disconnected from Readwise": "Von Readwise getrennt",
"Never": "Nie",
"Readwise Settings": "Readwise-Einstellungen",
"Connected to Readwise": "Mit Readwise verbunden",
"Last synced: {{time}}": "Zuletzt synchronisiert: {{time}}",
"Sync Enabled": "Synchronisierung aktiviert",
"Disconnect": "Trennen",
"Connect your Readwise account to sync highlights.": "Verbinden Sie Ihr Readwise-Konto, um Highlights zu synchronisieren.",
"Get your access token at": "Holen Sie sich Ihr Zugriffstoken unter",
"Access Token": "Zugriffstoken",
"Paste your Readwise access token": "Fügen Sie Ihr Readwise-Zugriffstoken ein",
"Config": "Konfiguration",
"Readwise Sync": "Readwise-Synchronisierung",
"Push Highlights": "Highlights übertragen",
"Highlights synced to Readwise": "Highlights mit Readwise synchronisiert",
"Readwise sync failed: no internet connection": "Readwise-Synchronisierung fehlgeschlagen: Keine Internetverbindung",
"Readwise sync failed: {{error}}": "Readwise-Synchronisierung fehlgeschlagen: {{error}}",
"System Screen Brightness": "System-Bildschirmhelligkeit",
"Page:": "Seite:",
"Page: {{number}}": "Seite: {{number}}",
"Annotation page number": "Seitenzahl der Anmerkung",
"Translating...": "Übersetzen...",
"Show Battery Percentage": "Akkustand in Prozent anzeigen",
"Hide Scrollbar": "Scrollleiste ausblenden",
"Skip to last reading position": "Zur letzten Leseposition springen",
"Show context": "Kontext anzeigen",
"Hide context": "Kontext ausblenden",
"Decrease font size": "Schriftgröße verkleinern",
"Increase font size": "Schriftgröße vergrößern",
"Focus": "Fokus",
"Theme color": "Themenfarbe",
"Import failed": "Import fehlgeschlagen",
"Available Formatters:": "Verfügbare Formatierer:",
"Format date": "Datum formatieren",
"Markdown block quote (> per line)": "Markdown-Blockzitat (> pro Zeile)",
"Newlines to <br>": "Zeilenumbrüche zu <br>",
"Change case": "Groß-/Kleinschreibung ändern",
"Trim whitespace": "Leerzeichen entfernen",
"Truncate to n characters": "Auf n Zeichen kürzen",
"Replace text": "Text ersetzen",
"Fallback value": "Ersatzwert",
"Get length": "Länge ermitteln",
"First/last element": "Erstes/letztes Element",
"Join array": "Array verbinden",
"Backup failed: {{error}}": "Sicherung fehlgeschlagen: {{error}}",
"Select Backup": "Sicherung auswählen",
"Restore failed: {{error}}": "Wiederherstellung fehlgeschlagen: {{error}}",
"Backup & Restore": "Sichern & Wiederherstellen",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Erstellen Sie eine Sicherung Ihrer Bibliothek oder stellen Sie eine frühere Sicherung wieder her. Die Wiederherstellung wird mit Ihrer aktuellen Bibliothek zusammengeführt.",
"Backup Library": "Bibliothek sichern",
"Restore Library": "Bibliothek wiederherstellen",
"Creating backup...": "Sicherung wird erstellt...",
"Restoring library...": "Bibliothek wird wiederhergestellt...",
"{{current}} of {{total}} items": "{{current}} von {{total}} Elementen",
"Backup completed successfully!": "Sicherung erfolgreich abgeschlossen!",
"Restore completed successfully!": "Wiederherstellung erfolgreich abgeschlossen!",
"Your library has been saved to the selected location.": "Ihre Bibliothek wurde am ausgewählten Ort gespeichert.",
"{{added}} books added, {{updated}} books updated.": "{{added}} Bücher hinzugefügt, {{updated}} Bücher aktualisiert.",
"Operation failed": "Vorgang fehlgeschlagen",
"Loading library...": "Bibliothek wird geladen...",
"{{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"
}
@@ -106,7 +106,6 @@
"Wikipedia": "Βικιπαίδεια",
"Writing Mode": "Λειτουργία γραφής",
"Your Library": "Η βιβλιοθήκη σας",
"TTS not supported for PDF": "Η μετατροπή κειμένου σε ομιλία δεν υποστηρίζεται για αρχεία PDF",
"Override Book Font": "Παράκαμψη γραμματοσειράς βιβλίου",
"Apply to All Books": "Εφαρμογή σε όλα τα βιβλία",
"Apply to This Book": "Εφαρμογή σε αυτό το βιβλίο",
@@ -335,8 +334,8 @@
"Fit": "Προσαρμογή",
"Reset {{settings}}": "Επαναφορά {{settings}}",
"Reset Settings": "Επαναφορά ρυθμίσεων",
"{{count}} pages left in chapter_one": "Μένει {{count}} σελίδα",
"{{count}} pages left in chapter_other": "Μένουν {{count}} σελίδες",
"{{count}} pages left in chapter_one": "<1>Μένει </1><0>{{count}}</0><1> σελίδα</1>",
"{{count}} pages left in chapter_other": "<1>Μένουν </1><0>{{count}}</0><1> σελίδες</1>",
"Show Remaining Pages": "Εμφάνιση υπολοίπων",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -599,11 +598,10 @@
"Size": "Μέγεθος",
"Cover": "Εξώφυλλο",
"Contain": "Περιέχει",
"{{number}} pages left in chapter": "Μένουν {{number}} σελίδες στο κεφάλαιο",
"{{number}} pages left in chapter": "<1>Μένουν </1><0>{{number}}</0><1> σελίδες στο κεφάλαιο</1>",
"Device": "Συσκευή",
"E-Ink Mode": "Λειτουργία E-Ink",
"Highlight Colors": "Χρώματα επισήμανσης",
"Auto Screen Brightness": "Αυτόματη φωτεινότητα οθόνης",
"Pagination": "Σελιδοποίηση",
"Disable Double Tap": "Απενεργοποίηση διπλού ταπ",
"Tap to Paginate": "Ταπ για σελιδοποίηση",
@@ -833,6 +831,9 @@
"Clear search": "Εκκαθάριση αναζήτησης",
"Clear search history": "Εκκαθάριση ιστορικού αναζήτησης",
"Tap to Toggle Footer": "Πατήστε για εναλλαγή υποσέλιδου",
"Show Current Time": "Εμφάνιση τρέχουσας ώρας",
"Use 24 Hour Clock": "Χρήση 24ωρης μορφής ώρας",
"Show Current Battery Status": "Εμφάνιση τρέχουσας κατάστασης μπαταρίας",
"Exported successfully": "Εξαγωγή επιτυχής",
"Book exported successfully.": "Το βιβλίο εξήχθη επιτυχώς.",
"Failed to export the book.": "Αποτυχία εξαγωγής του βιβλίου.",
@@ -989,10 +990,8 @@
"Play (Space)": "Αναπαραγωγή (Διάστημα)",
"Skip forward 15 words": "Προώθηση 15 λέξεων",
"Forward 15 words (Shift+Right)": "Εμπρός 15 λέξεις (Shift+Δεξιά)",
"Pause:": "Παύση:",
"Decrease speed": "Μείωση ταχύτητας",
"Slower (Left/Down)": "Πιο αργά (Αριστερά/Κάτω)",
"Current speed": "Τρέχουσα ταχύτητα",
"Increase speed": "Αύξηση ταχύτητας",
"Faster (Right/Up)": "Πιο γρήγορα (Δεξιά/Πάνω)",
"Start RSVP Reading": "Έναρξη ανάγνωσης RSVP",
@@ -1031,5 +1030,77 @@
"Books": "Βιβλία",
"Groups": "Ομάδες",
"Back to TTS Location": "Επιστροφή στην τοποθεσία TTS",
"Metadata": "Μεταδεδομένα"
"Metadata": "Μεταδεδομένα",
"Image viewer": "Πρόγραμμα προβολής εικόνων",
"Previous Image": "Προηγούμενη εικόνα",
"Next Image": "Επόμενη εικόνα",
"Zoomed": "Ζουμαρισμένο",
"Zoom level": "Επίπεδο ζουμ",
"Table viewer": "Πρόγραμμα προβολής πινάκων",
"Unable to connect to Readwise. Please check your network connection.": "Αδυναμία σύνδεσης στο Readwise. Ελέγξτε τη σύνδεση του δικτύου σας.",
"Invalid Readwise access token": "Μη έγκυρο διακριτικό πρόσβασης Readwise",
"Disconnected from Readwise": "Αποσυνδέθηκε από το Readwise",
"Never": "Ποτέ",
"Readwise Settings": "Ρυθμίσεις Readwise",
"Connected to Readwise": "Συνδέθηκε στο Readwise",
"Last synced: {{time}}": "Τελευταίος συγχρονισμός: {{time}}",
"Sync Enabled": "Ο συγχρονισμός ενεργοποιήθηκε",
"Disconnect": "Αποσύνδεση",
"Connect your Readwise account to sync highlights.": "Συνδέστε τον λογαριασμό σας Readwise για να συγχρονίσετε τις επισημάνσεις.",
"Get your access token at": "Αποκτήστε το διακριτικό πρόσβασής σας στο",
"Access Token": "Διακριτικό πρόσβασης",
"Paste your Readwise access token": "Επικολλήστε το διακριτικό πρόσβασης Readwise",
"Config": "Ρύθμιση παραμέτρων",
"Readwise Sync": "Συγχρονισμός Readwise",
"Push Highlights": "Προώθηση επισημάνσεων",
"Highlights synced to Readwise": "Οι επισημάνσεις συγχρονίστηκαν στο Readwise",
"Readwise sync failed: no internet connection": "Ο συγχρονισμός Readwise απέτυχε: δεν υπάρχει σύνδεση στο διαδίκτυο",
"Readwise sync failed: {{error}}": "Ο συγχρονισμός Readwise απέτυχε: {{error}}",
"System Screen Brightness": "Φωτεινότητα οθόνης συστήματος",
"Page:": "Σελίδα:",
"Page: {{number}}": "Σελίδα: {{number}}",
"Annotation page number": "Αριθμός σελίδας σχολίου",
"Translating...": "Μετάφραση...",
"Show Battery Percentage": "Εμφάνιση ποσοστού μπαταρίας",
"Hide Scrollbar": "Απόκρυψη γραμμής κύλισης",
"Skip to last reading position": "Μετάβαση στην τελευταία θέση ανάγνωσης",
"Show context": "Εμφάνιση πλαισίου",
"Hide context": "Απόκρυψη πλαισίου",
"Decrease font size": "Μείωση μεγέθους γραμματοσειράς",
"Increase font size": "Αύξηση μεγέθους γραμματοσειράς",
"Focus": "Εστίαση",
"Theme color": "Χρώμα θέματος",
"Import failed": "Η εισαγωγή απέτυχε",
"Available Formatters:": "Διαθέσιμοι μορφοποιητές:",
"Format date": "Μορφοποίηση ημερομηνίας",
"Markdown block quote (> per line)": "Παράθεση Markdown (> ανά γραμμή)",
"Newlines to <br>": "Αλλαγές γραμμής σε <br>",
"Change case": "Αλλαγή πεζών/κεφαλαίων",
"Trim whitespace": "Αφαίρεση κενών",
"Truncate to n characters": "Περικοπή σε n χαρακτήρες",
"Replace text": "Αντικατάσταση κειμένου",
"Fallback value": "Εναλλακτική τιμή",
"Get length": "Λήψη μήκους",
"First/last element": "Πρώτο/τελευταίο στοιχείο",
"Join array": "Ένωση πίνακα",
"Backup failed: {{error}}": "Η δημιουργία αντιγράφου ασφαλείας απέτυχε: {{error}}",
"Select Backup": "Επιλογή αντιγράφου ασφαλείας",
"Restore failed: {{error}}": "Η επαναφορά απέτυχε: {{error}}",
"Backup & Restore": "Αντίγραφα ασφαλείας & Επαναφορά",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Δημιουργήστε αντίγραφο ασφαλείας της βιβλιοθήκης σας ή επαναφέρετε από προηγούμενο αντίγραφο. Η επαναφορά θα συγχωνευθεί με την τρέχουσα βιβλιοθήκη σας.",
"Backup Library": "Αντίγραφο βιβλιοθήκης",
"Restore Library": "Επαναφορά βιβλιοθήκης",
"Creating backup...": "Δημιουργία αντιγράφου ασφαλείας...",
"Restoring library...": "Επαναφορά βιβλιοθήκης...",
"{{current}} of {{total}} items": "{{current}} από {{total}} στοιχεία",
"Backup completed successfully!": "Το αντίγραφο ασφαλείας ολοκληρώθηκε!",
"Restore completed successfully!": "Η επαναφορά ολοκληρώθηκε!",
"Your library has been saved to the selected location.": "Η βιβλιοθήκη σας αποθηκεύτηκε στην επιλεγμένη τοποθεσία.",
"{{added}} books added, {{updated}} books updated.": "{{added}} βιβλία προστέθηκαν, {{updated}} βιβλία ενημερώθηκαν.",
"Operation failed": "Η λειτουργία απέτυχε",
"Loading library...": "Φόρτωση βιβλιοθήκης...",
"{{count}} books refreshed_one": "{{count}} βιβλίο ανανεώθηκε",
"{{count}} books refreshed_other": "{{count}} βιβλία ανανεώθηκαν",
"Failed to refresh metadata": "Αποτυχία ανανέωσης μεταδεδομένων",
"Refresh Metadata": "Ανανέωση μεταδεδομένων"
}
@@ -8,8 +8,8 @@
"Are you sure to delete {{count}} selected book(s)?_other": "Are you sure to delete {{count}} selected books?",
"Search in {{count}} Book(s)..._one": "Search in {{count}} book...",
"Search in {{count}} Book(s)..._other": "Search in {{count}} books...",
"{{count}} pages left in chapter_one": "{{count}} page left in chapter",
"{{count}} pages left in chapter_other": "{{count}} pages left in chapter",
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> page left in chapter</1>",
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> pages left in chapter</1>",
"Deleted {{count}} file(s)_one": "Deleted {{count}} file",
"Deleted {{count}} file(s)_other": "Deleted {{count}} files",
"Failed to delete {{count}} file(s)_one": "Failed to delete {{count}} file",
@@ -133,7 +133,6 @@
"Wikipedia": "Wikipedia",
"Writing Mode": "Modo de escritura",
"Your Library": "Tu biblioteca",
"TTS not supported for PDF": "TTS no es compatible con PDF",
"Override Book Font": "Sobrescribir fuente del libro",
"Apply to All Books": "Aplicar a todos los libros",
"Apply to This Book": "Aplicar a este libro",
@@ -365,9 +364,9 @@
"Fit": "Encajar",
"Reset {{settings}}": "Restablecer {{settings}}",
"Reset Settings": "Restablecer configuración",
"{{count}} pages left in chapter_one": "{{count}} página restante en el capítulo",
"{{count}} pages left in chapter_many": "{{count}} páginas restantes en el capítulo",
"{{count}} pages left in chapter_other": "{{count}} páginas restantes en el capítulo",
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> página restante en el capítulo</1>",
"{{count}} pages left in chapter_many": "<0>{{count}}</0><1> páginas restantes en el capítulo</1>",
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> páginas restantes en el capítulo</1>",
"Show Remaining Pages": "Mostrar resto",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -603,11 +602,10 @@
"Size": "Tamaño",
"Cover": "Cubierta",
"Contain": "Contener",
"{{number}} pages left in chapter": "{{number}} páginas restantes en el capítulo",
"{{number}} pages left in chapter": "<0>{{number}}</0><1> páginas restantes en el capítulo</1>",
"Device": "Dispositivo",
"E-Ink Mode": "Modo E-Ink",
"Highlight Colors": "Colores de resaltado",
"Auto Screen Brightness": "Brillo automático de pantalla",
"Pagination": "Paginación",
"Disable Double Tap": "Deshabilitar Doble Toque",
"Tap to Paginate": "Tocar para Paginación",
@@ -843,6 +841,9 @@
"Clear search": "Borrar búsqueda",
"Clear search history": "Borrar historial de búsqueda",
"Tap to Toggle Footer": "Toca para alternar pie de página",
"Show Current Time": "Mostrar hora actual",
"Use 24 Hour Clock": "Usar formato de 24 horas",
"Show Current Battery Status": "Mostrar el estado actual de la batería",
"Exported successfully": "Exportado con éxito",
"Book exported successfully.": "Libro exportado con éxito.",
"Failed to export the book.": "Error al exportar el libro.",
@@ -1001,10 +1002,8 @@
"Play (Space)": "Reproducir (Espacio)",
"Skip forward 15 words": "Adelantar 15 palabras",
"Forward 15 words (Shift+Right)": "Adelantar 15 palabras (Shift+Derecha)",
"Pause:": "Pausa:",
"Decrease speed": "Disminuir velocidad",
"Slower (Left/Down)": "Más lento (Izquierda/Abajo)",
"Current speed": "Velocidad actual",
"Increase speed": "Aumentar velocidad",
"Faster (Right/Up)": "Más rápido (Derecha/Arriba)",
"Start RSVP Reading": "Iniciar lectura RSVP",
@@ -1043,5 +1042,78 @@
"Books": "Libros",
"Groups": "Grupos",
"Back to TTS Location": "Volver a la ubicación de TTS",
"Metadata": "Metadatos"
"Metadata": "Metadatos",
"Image viewer": "Visor de imágenes",
"Previous Image": "Imagen anterior",
"Next Image": "Imagen siguiente",
"Zoomed": "Con zoom",
"Zoom level": "Nivel de zoom",
"Table viewer": "Visor de tablas",
"Unable to connect to Readwise. Please check your network connection.": "No se pudo conectar a Readwise. Por favor, comprueba tu conexión a la red.",
"Invalid Readwise access token": "Token de acceso a Readwise no válido",
"Disconnected from Readwise": "Desconectado de Readwise",
"Never": "Nunca",
"Readwise Settings": "Ajustes de Readwise",
"Connected to Readwise": "Conectado a Readwise",
"Last synced: {{time}}": "Última sincronización: {{time}}",
"Sync Enabled": "Sincronización habilitada",
"Disconnect": "Desconectar",
"Connect your Readwise account to sync highlights.": "Conecta tu cuenta de Readwise para sincronizar los resaltados.",
"Get your access token at": "Obtén tu token de acceso en",
"Access Token": "Token de acceso",
"Paste your Readwise access token": "Pega tu token de acceso de Readwise",
"Config": "Configuración",
"Readwise Sync": "Sincronización con Readwise",
"Push Highlights": "Enviar resaltados",
"Highlights synced to Readwise": "Resaltados sincronizados con Readwise",
"Readwise sync failed: no internet connection": "Error en la sincronización con Readwise: sin conexión a internet",
"Readwise sync failed: {{error}}": "Error en la sincronización con Readwise: {{error}}",
"System Screen Brightness": "Brillo de pantalla del sistema",
"Page:": "Página:",
"Page: {{number}}": "Página: {{number}}",
"Annotation page number": "Número de página de la anotación",
"Translating...": "Traduciendo...",
"Show Battery Percentage": "Mostrar porcentaje de batería",
"Hide Scrollbar": "Ocultar barra de desplazamiento",
"Skip to last reading position": "Ir a la última posición de lectura",
"Show context": "Mostrar contexto",
"Hide context": "Ocultar contexto",
"Decrease font size": "Reducir tamaño de fuente",
"Increase font size": "Aumentar tamaño de fuente",
"Focus": "Enfoque",
"Theme color": "Color del tema",
"Import failed": "Error de importación",
"Available Formatters:": "Formateadores disponibles:",
"Format date": "Formatear fecha",
"Markdown block quote (> per line)": "Cita en bloque Markdown (> por línea)",
"Newlines to <br>": "Saltos de línea a <br>",
"Change case": "Cambiar mayúsculas/minúsculas",
"Trim whitespace": "Recortar espacios",
"Truncate to n characters": "Truncar a n caracteres",
"Replace text": "Reemplazar texto",
"Fallback value": "Valor alternativo",
"Get length": "Obtener longitud",
"First/last element": "Primer/último elemento",
"Join array": "Unir array",
"Backup failed: {{error}}": "Error en la copia de seguridad: {{error}}",
"Select Backup": "Seleccionar copia de seguridad",
"Restore failed: {{error}}": "Error en la restauración: {{error}}",
"Backup & Restore": "Copia de seguridad y restauración",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Crea una copia de seguridad de tu biblioteca o restaura desde una copia anterior. La restauración se fusionará con tu biblioteca actual.",
"Backup Library": "Hacer copia de seguridad",
"Restore Library": "Restaurar biblioteca",
"Creating backup...": "Creando copia de seguridad...",
"Restoring library...": "Restaurando biblioteca...",
"{{current}} of {{total}} items": "{{current}} de {{total}} elementos",
"Backup completed successfully!": "¡Copia de seguridad completada!",
"Restore completed successfully!": "¡Restauración completada!",
"Your library has been saved to the selected location.": "Tu biblioteca se ha guardado en la ubicación seleccionada.",
"{{added}} books added, {{updated}} books updated.": "{{added}} libros añadidos, {{updated}} libros actualizados.",
"Operation failed": "La operación falló",
"Loading library...": "Cargando biblioteca...",
"{{count}} books refreshed_one": "{{count}} libro actualizado",
"{{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"
}
@@ -105,7 +105,6 @@
"Wikipedia": "ویکی‌پدیا",
"Writing Mode": "حالت نوشتن",
"Your Library": "کتابخانه‌ی شما",
"TTS not supported for PDF": "قابلیت تبدیل متن به گفتار برای PDF پشتیبانی نمی‌شود",
"Override Book Font": "جایگزینی فونت کتاب",
"Apply to All Books": "اِعمال برای همه‌ی کتاب‌ها",
"Apply to This Book": "اِعمال برای این کتاب",
@@ -334,8 +333,8 @@
"Fit": "متناسب",
"Reset {{settings}}": "بازنشانی {{settings}}",
"Reset Settings": "بازنشانی تنظیمات",
"{{count}} pages left in chapter_one": "{{count}} صفحه تا انتهای فصل",
"{{count}} pages left in chapter_other": "{{count}} صفحه تا انتهای فصل",
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> صفحه تا انتهای فصل</1>",
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> صفحه تا انتهای فصل</1>",
"Show Remaining Pages": "نمایش صفحات باقی‌مانده",
"Source Han Serif CN": "Source Han Serif CN",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -599,11 +598,10 @@
"Size": "اندازه",
"Cover": "جلد",
"Contain": "شامل",
"{{number}} pages left in chapter": "{{number}} صفحه تا انتهای فصل",
"{{number}} pages left in chapter": "<0>{{number}}</0><1> صفحه تا انتهای فصل</1>",
"Device": "دستگاه",
"E-Ink Mode": "حالت E-Ink",
"Highlight Colors": "رنگ‌های هایلایت",
"Auto Screen Brightness": "روشنایی خودکار صفحه",
"Pagination": "صفحه‌بندی",
"Disable Double Tap": "غیرفعال کردن کلیک دوبل",
"Tap to Paginate": "ضربه برای صفحه‌بندی",
@@ -833,6 +831,9 @@
"Clear search": "پاک کردن جستجو",
"Clear search history": "پاک کردن تاریخچه جستجو",
"Tap to Toggle Footer": "برای تغییر پاورقی ضربه بزنید",
"Show Current Time": "نمایش زمان فعلی",
"Use 24 Hour Clock": "استفاده از ساعت ۲۴ ساعته",
"Show Current Battery Status": "نمایش وضعیت فعلی باتری",
"Exported successfully": "صادرات موفق",
"Book exported successfully.": "کتاب با موفقیت صادر شد.",
"Failed to export the book.": "صادر کردن کتاب ناموفق بود.",
@@ -989,10 +990,8 @@
"Play (Space)": "پخش (Space)",
"Skip forward 15 words": "۱۵ کلمه به جلو",
"Forward 15 words (Shift+Right)": "۱۵ کلمه به جلو (Shift+Right)",
"Pause:": "توقف:",
"Decrease speed": "کاهش سرعت",
"Slower (Left/Down)": "کندتر (Left/Down)",
"Current speed": "سرعت فعلی",
"Increase speed": "افزایش سرعت",
"Faster (Right/Up)": "سریع‌تر (Right/Up)",
"Start RSVP Reading": "شروع مطالعه RSVP",
@@ -1031,5 +1030,77 @@
"Books": "کتاب‌ها",
"Groups": "گروه‌ها",
"Back to TTS Location": "بازگشت به مکان TTS",
"Metadata": "فراداده"
"Metadata": "فراداده",
"Image viewer": "نمایشگر تصویر",
"Previous Image": "تصویر قبلی",
"Next Image": "تصویر بعدی",
"Zoomed": "بزرگنمایی شده",
"Zoom level": "سطح بزرگنمایی",
"Table viewer": "نمایشگر جدول",
"Unable to connect to Readwise. Please check your network connection.": "اتصال به Readwise امکان‌پذیر نیست. لطفاً اتصال شبکه خود را بررسی کنید.",
"Invalid Readwise access token": "توکن دسترسی Readwise نامعتبر است",
"Disconnected from Readwise": "اتصال از Readwise قطع شد",
"Never": "هرگز",
"Readwise Settings": "تنظیمات Readwise",
"Connected to Readwise": "به Readwise متصل شد",
"Last synced: {{time}}": "آخرین همگام‌سازی: {{time}}",
"Sync Enabled": "همگام‌سازی فعال شد",
"Disconnect": "قطع اتصال",
"Connect your Readwise account to sync highlights.": "برای همگام‌سازی هایلایت‌ها، حساب Readwise خود را متصل کنید.",
"Get your access token at": "توکن دسترسی خود را از اینجا دریافت کنید:",
"Access Token": "توکن دسترسی",
"Paste your Readwise access token": "توکن دسترسی Readwise خود را جای‌گذاری کنید",
"Config": "پیکربندی",
"Readwise Sync": "همگام‌سازی Readwise",
"Push Highlights": "ارسال هایلایت‌ها",
"Highlights synced to Readwise": "هایلایت‌ها با Readwise همگام‌سازی شدند",
"Readwise sync failed: no internet connection": "همگام‌سازی Readwise ناموفق بود: اتصال اینترنت برقرار نیست",
"Readwise sync failed: {{error}}": "همگام‌سازی Readwise ناموفق بود: {{error}}",
"System Screen Brightness": "روشنایی صفحه نمایش سیستم",
"Page:": "صفحه:",
"Page: {{number}}": "صفحه: {{number}}",
"Annotation page number": "شماره صفحه یادداشت",
"Translating...": "در حال ترجمه...",
"Show Battery Percentage": "نمایش درصد باتری",
"Hide Scrollbar": "مخفی کردن نوار پیمایش",
"Skip to last reading position": "رفتن به آخرین موقعیت مطالعه",
"Show context": "نمایش زمینه",
"Hide context": "پنهان کردن زمینه",
"Decrease font size": "کاهش اندازه قلم",
"Increase font size": "افزایش اندازه قلم",
"Focus": "تمرکز",
"Theme color": "رنگ پوسته",
"Import failed": "وارد کردن ناموفق بود",
"Available Formatters:": "قالب‌بندهای موجود:",
"Format date": "قالب‌بندی تاریخ",
"Markdown block quote (> per line)": "نقل‌قول Markdown (> در هر خط)",
"Newlines to <br>": "خطوط جدید به <br>",
"Change case": "تغییر بزرگی/کوچکی حروف",
"Trim whitespace": "حذف فاصله‌ها",
"Truncate to n characters": "کوتاه کردن به n نویسه",
"Replace text": "جایگزینی متن",
"Fallback value": "مقدار پیش‌فرض",
"Get length": "دریافت طول",
"First/last element": "عنصر اول/آخر",
"Join array": "پیوستن آرایه",
"Backup failed: {{error}}": "پشتیبان‌گیری ناموفق: {{error}}",
"Select Backup": "انتخاب پشتیبان",
"Restore failed: {{error}}": "بازیابی ناموفق: {{error}}",
"Backup & Restore": "پشتیبان‌گیری و بازیابی",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "از کتابخانه خود پشتیبان بگیرید یا از پشتیبان قبلی بازیابی کنید. بازیابی با کتابخانه فعلی شما ادغام خواهد شد.",
"Backup Library": "پشتیبان‌گیری کتابخانه",
"Restore Library": "بازیابی کتابخانه",
"Creating backup...": "در حال ایجاد پشتیبان...",
"Restoring library...": "در حال بازیابی کتابخانه...",
"{{current}} of {{total}} items": "{{current}} از {{total}} مورد",
"Backup completed successfully!": "پشتیبان‌گیری با موفقیت انجام شد!",
"Restore completed successfully!": "بازیابی با موفقیت انجام شد!",
"Your library has been saved to the selected location.": "کتابخانه شما در مکان انتخاب‌شده ذخیره شد.",
"{{added}} books added, {{updated}} books updated.": "{{added}} کتاب اضافه شد، {{updated}} کتاب به‌روزرسانی شد.",
"Operation failed": "عملیات ناموفق",
"Loading library...": "در حال بارگذاری کتابخانه...",
"{{count}} books refreshed_one": "{{count}} کتاب به‌روزرسانی شد",
"{{count}} books refreshed_other": "{{count}} کتاب به‌روزرسانی شد",
"Failed to refresh metadata": "به‌روزرسانی فراداده ناموفق",
"Refresh Metadata": "به‌روزرسانی فراداده"
}
@@ -106,7 +106,6 @@
"Wikipedia": "Wikipédia",
"Writing Mode": "Mode écriture",
"Your Library": "Votre bibliothèque",
"TTS not supported for PDF": "La synthèse vocale n'est pas prise en charge pour les fichiers PDF",
"Override Book Font": "Remplacer la police du livre",
"Apply to All Books": "Appliquer à tous les livres",
"Apply to This Book": "Appliquer à ce livre",
@@ -337,9 +336,9 @@
"Fit": "Adapter",
"Reset {{settings}}": "Réinitialiser {{settings}}",
"Reset Settings": "Réinitialiser les paramètres",
"{{count}} pages left in chapter_one": "{{count}} page restant dans le chapitre",
"{{count}} pages left in chapter_many": "{{count}} pages restantes dans le chapitre",
"{{count}} pages left in chapter_other": "{{count}} pages restantes dans le chapitre",
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> page restant dans le chapitre</1>",
"{{count}} pages left in chapter_many": "<0>{{count}}</0><1> pages restantes dans le chapitre</1>",
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> pages restantes dans le chapitre</1>",
"Show Remaining Pages": "Voir restantes",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -603,11 +602,10 @@
"Size": "Taille",
"Cover": "Couverture",
"Contain": "Contenir",
"{{number}} pages left in chapter": "{{number}} pages restantes dans le chapitre",
"{{number}} pages left in chapter": "<0>{{number}}</0><1> pages restantes dans le chapitre</1>",
"Device": "Appareil",
"E-Ink Mode": "Mode E-Ink",
"Highlight Colors": "Couleurs de Surlignage",
"Auto Screen Brightness": "Luminosité Automatique de l'Écran",
"Pagination": "Pagination",
"Disable Double Tap": "Désactiver le Double Tap",
"Tap to Paginate": "Tapoter pour Paginater",
@@ -843,6 +841,9 @@
"Clear search": "Effacer la recherche",
"Clear search history": "Effacer l'historique de recherche",
"Tap to Toggle Footer": "Appuyez pour afficher/masquer le pied de page",
"Show Current Time": "Afficher l'heure actuelle",
"Use 24 Hour Clock": "Utiliser le format 24 heures",
"Show Current Battery Status": "Afficher l'état actuel de la batterie",
"Exported successfully": "Exporté avec succès",
"Book exported successfully.": "Livre exporté avec succès.",
"Failed to export the book.": "Échec de l'exportation du livre.",
@@ -1001,10 +1002,8 @@
"Play (Space)": "Lecture (Espace)",
"Skip forward 15 words": "Avancer de 15 mots",
"Forward 15 words (Shift+Right)": "Avancer de 15 mots (Shift+Right)",
"Pause:": "Pause :",
"Decrease speed": "Diminuer la vitesse",
"Slower (Left/Down)": "Plus lent (Gauche/Bas)",
"Current speed": "Vitesse actuelle",
"Increase speed": "Augmenter la vitesse",
"Faster (Right/Up)": "Plus rapide (Droite/Haut)",
"Start RSVP Reading": "Démarrer la lecture RSVP",
@@ -1043,5 +1042,78 @@
"Books": "Livres",
"Groups": "Groupes",
"Back to TTS Location": "Retour à l'emplacement TTS",
"Metadata": "Métadonnées"
"Metadata": "Métadonnées",
"Image viewer": "Visionneuse d'images",
"Previous Image": "Image précédente",
"Next Image": "Image suivante",
"Zoomed": "Zoomé",
"Zoom level": "Niveau de zoom",
"Table viewer": "Visionneuse de tableaux",
"Unable to connect to Readwise. Please check your network connection.": "Impossible de se connecter à Readwise. Veuillez vérifier votre connexion réseau.",
"Invalid Readwise access token": "Jeton d'accès Readwise invalide",
"Disconnected from Readwise": "Déconnecté de Readwise",
"Never": "Jamais",
"Readwise Settings": "Paramètres Readwise",
"Connected to Readwise": "Connecté à Readwise",
"Last synced: {{time}}": "Dernière synchronisation : {{time}}",
"Sync Enabled": "Synchronisation activée",
"Disconnect": "Déconnecter",
"Connect your Readwise account to sync highlights.": "Connectez votre compte Readwise pour synchroniser les surlignages.",
"Get your access token at": "Obtenez votre jeton d'accès sur",
"Access Token": "Jeton d'accès",
"Paste your Readwise access token": "Collez votre jeton d'accès Readwise",
"Config": "Configuration",
"Readwise Sync": "Synchronisation Readwise",
"Push Highlights": "Pousser les surlignages",
"Highlights synced to Readwise": "Surlignages synchronisés avec Readwise",
"Readwise sync failed: no internet connection": "Échec de la synchronisation Readwise : pas de connexion internet",
"Readwise sync failed: {{error}}": "Échec de la synchronisation Readwise : {{error}}",
"System Screen Brightness": "Luminosité de l'écran système",
"Page:": "Page :",
"Page: {{number}}": "Page : {{number}}",
"Annotation page number": "Numéro de page de l'annotation",
"Translating...": "Traduction en cours...",
"Show Battery Percentage": "Afficher le pourcentage de batterie",
"Hide Scrollbar": "Masquer la barre de défilement",
"Skip to last reading position": "Aller à la dernière position de lecture",
"Show context": "Afficher le contexte",
"Hide context": "Masquer le contexte",
"Decrease font size": "Réduire la taille de police",
"Increase font size": "Augmenter la taille de police",
"Focus": "Focus",
"Theme color": "Couleur du thème",
"Import failed": "Échec de l'importation",
"Available Formatters:": "Formateurs disponibles :",
"Format date": "Formater la date",
"Markdown block quote (> per line)": "Citation Markdown (> par ligne)",
"Newlines to <br>": "Retours à la ligne en <br>",
"Change case": "Changer la casse",
"Trim whitespace": "Supprimer les espaces",
"Truncate to n characters": "Tronquer à n caractères",
"Replace text": "Remplacer le texte",
"Fallback value": "Valeur par défaut",
"Get length": "Obtenir la longueur",
"First/last element": "Premier/dernier élément",
"Join array": "Joindre le tableau",
"Backup failed: {{error}}": "Échec de la sauvegarde : {{error}}",
"Select Backup": "Sélectionner une sauvegarde",
"Restore failed: {{error}}": "Échec de la restauration : {{error}}",
"Backup & Restore": "Sauvegarde et restauration",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Créez une sauvegarde de votre bibliothèque ou restaurez à partir d'une sauvegarde précédente. La restauration fusionnera avec votre bibliothèque actuelle.",
"Backup Library": "Sauvegarder la bibliothèque",
"Restore Library": "Restaurer la bibliothèque",
"Creating backup...": "Création de la sauvegarde...",
"Restoring library...": "Restauration de la bibliothèque...",
"{{current}} of {{total}} items": "{{current}} sur {{total}} éléments",
"Backup completed successfully!": "Sauvegarde terminée avec succès !",
"Restore completed successfully!": "Restauration terminée avec succès !",
"Your library has been saved to the selected location.": "Votre bibliothèque a été enregistrée à l'emplacement sélectionné.",
"{{added}} books added, {{updated}} books updated.": "{{added}} livres ajoutés, {{updated}} livres mis à jour.",
"Operation failed": "L'opération a échoué",
"Loading library...": "Chargement de la bibliothèque...",
"{{count}} books refreshed_one": "{{count}} livre rafraîchi",
"{{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"
}
@@ -465,10 +465,10 @@
"Next Paragraph": "פסקה הבאה",
"Exit Paragraph Mode": "צא ממצב פסקה",
"{{time}} min left in chapter": "נותרו {{time}} דקות בפרק",
"{{number}} pages left in chapter": "נותרו {{number}} דפים בפרק",
"{{count}} pages left in chapter_one": "נותר דף אחד בפרק",
"{{count}} pages left in chapter_two": "נותרו {{count}} דפים בפרק",
"{{count}} pages left in chapter_other": "נותרו {{count}} דפים בפרק",
"{{number}} pages left in chapter": "<1>נותרו </1><0>{{number}}</0><1> דפים בפרק</1>",
"{{count}} pages left in chapter_one": "<1>נותר דף אחד בפרק</1>",
"{{count}} pages left in chapter_two": "<1>נותרו </1><0>{{count}}</0><1> דפים בפרק</1>",
"{{count}} pages left in chapter_other": "<1>נותרו </1><0>{{count}}</0><1> דפים בפרק</1>",
"On {{current}} of {{total}} page": "בדף {{current}} מתוך {{total}}",
"Selection": "בחירה",
"Book": "ספר",
@@ -502,10 +502,8 @@
"Play (Space)": "נגן (רווח)",
"Skip forward 15 words": "קפוץ 15 מילים קדימה",
"Forward 15 words (Shift+Right)": "15 מילים קדימה (Shift+Right)",
"Pause:": "השהה:",
"Decrease speed": "הפחת מהירות",
"Slower (Left/Down)": "איטי יותר (חץ שמאלה/למטה)",
"Current speed": "מהירות נוכחית",
"Increase speed": "הגבר מהירות",
"Faster (Right/Up)": "מהיר יותר (חץ ימינה/למעלה)",
"Start RSVP Reading": "התחל קריאת RSVP",
@@ -570,7 +568,6 @@
"Read Aloud": "הקרא בקול",
"Ready to read aloud": "מוכן להקראה בקול",
"Please log in to use advanced TTS features": "אנא התחבר כדי להשתמש בתכונות TTS מתקדמות",
"TTS not supported for PDF": "TTS אינו נתמך עבור PDF",
"TTS not supported for this document": "TTS אינו נתמך עבור מסמך זה",
"Back to TTS Location": "חזור למיקום ה-TTS",
"No Timeout": "ללא הגבלת זמן",
@@ -878,7 +875,6 @@
"Device": "מכשיר",
"E-Ink Mode": "מצב E-Ink",
"Color E-Ink Mode": "מצב E-Ink צבעוני",
"Auto Screen Brightness": "בהירות מסך אוטומטית",
"Security": "אבטחה",
"Allow JavaScript": "אפשר JavaScript",
"Enable only if you trust the file.": "הפעל רק אם אתה בוטח בקובץ.",
@@ -967,6 +963,9 @@
"Page Number": "מספר דף",
"Percentage": "אחוזים",
"Tap to Toggle Footer": "הקש להצגת/הסתרת כותרת תחתונה",
"Show Current Time": "הצגת השעה הנוכחית",
"Use 24 Hour Clock": "השתמש בשעון 24 שעות",
"Show Current Battery Status": "הצג מצב סוללה נוכחי",
"Screen": "מסך",
"Orientation": "כיוון מסך (Orientation)",
"Portrait": "לאורך (Portrait)",
@@ -1043,5 +1042,78 @@
"Reveal in Finder": "הצג ב-Finder",
"Reveal in File Explorer": "הצג בסייר הקבצים",
"Reveal in Folder": "הצג בתיקייה",
"Metadata": "מטא-נתונים"
"Metadata": "מטא-נתונים",
"Image viewer": "מציג תמונות",
"Previous Image": "תמונה הקודמת",
"Next Image": "תמונה הבאה",
"Zoomed": "בזום",
"Zoom level": "רמת זום",
"Table viewer": "מציג טבלאות",
"Unable to connect to Readwise. Please check your network connection.": "לא ניתן להתחבר ל-Readwise. אנא בדוק את חיבור הרשת שלך.",
"Invalid Readwise access token": "אסימون גישה של Readwise לא חוקי",
"Disconnected from Readwise": "נותק מ-Readwise",
"Never": "לעולם לא",
"Readwise Settings": "הגדרות Readwise",
"Connected to Readwise": "מחובר ל-Readwise",
"Last synced: {{time}}": "סונכרן לאחרונה: {{time}}",
"Sync Enabled": "סנכרון מופעל",
"Disconnect": "נתק",
"Connect your Readwise account to sync highlights.": "חבר את חשבון Readwise שלך כדי לסנכרן את ההדגשות.",
"Get your access token at": "קבל את אסימון הגישה שלך ב-",
"Access Token": "אסימון גישה",
"Paste your Readwise access token": "הדבק את אסימון הגישה של Readwise שלך",
"Config": "תצורה",
"Readwise Sync": "סנכרון Readwise",
"Push Highlights": "שלח הדגשות",
"Highlights synced to Readwise": "ההדגשות סונכרנו ל-Readwise",
"Readwise sync failed: no internet connection": "סנכרון Readwise נכשל: אין חיבור לאינטרנט",
"Readwise sync failed: {{error}}": "סנכרון Readwise נכשל: {{error}}",
"System Screen Brightness": "בהירות מסך המערכת",
"Page:": "עמוד:",
"Page: {{number}}": "עמוד: {{number}}",
"Annotation page number": "מספר עמוד הערה",
"Translating...": "מתרגם...",
"Show Battery Percentage": "הצג אחוזי סוללה",
"Hide Scrollbar": "הסתר סרגל גלילה",
"Skip to last reading position": "דלג למיקום הקריאה האחרון",
"Show context": "הצג הקשר",
"Hide context": "הסתר הקשר",
"Decrease font size": "הקטן גודל גופן",
"Increase font size": "הגדל גודל גופן",
"Focus": "מיקוד",
"Theme color": "צבע ערכת נושא",
"Import failed": "הייבוא נכשל",
"Available Formatters:": "מעצבים זמינים:",
"Format date": "עיצוב תאריך",
"Markdown block quote (> per line)": "ציטוט Markdown (> בכל שורה)",
"Newlines to <br>": "שורות חדשות ל-<br>",
"Change case": "שינוי רישיות",
"Trim whitespace": "חיתוך רווחים",
"Truncate to n characters": "קיצוץ ל-n תווים",
"Replace text": "החלפת טקסט",
"Fallback value": "ערך חלופי",
"Get length": "קבלת אורך",
"First/last element": "אלמנט ראשון/אחרון",
"Join array": "חיבור מערך",
"Backup failed: {{error}}": "הגיבוי נכשל: {{error}}",
"Select Backup": "בחר גיבוי",
"Restore failed: {{error}}": "השחזור נכשל: {{error}}",
"Backup & Restore": "גיבוי ושחזור",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "צור גיבוי של הספרייה שלך או שחזר מגיבוי קודם. השחזור ימוזג עם הספרייה הנוכחית שלך.",
"Backup Library": "גיבוי ספרייה",
"Restore Library": "שחזור ספרייה",
"Creating backup...": "יוצר גיבוי...",
"Restoring library...": "משחזר ספרייה...",
"{{current}} of {{total}} items": "{{current}} מתוך {{total}} פריטים",
"Backup completed successfully!": "הגיבוי הושלם בהצלחה!",
"Restore completed successfully!": "השחזור הושלם בהצלחה!",
"Your library has been saved to the selected location.": "הספרייה שלך נשמרה במיקום שנבחר.",
"{{added}} books added, {{updated}} books updated.": "{{added}} ספרים נוספו, {{updated}} ספרים עודכנו.",
"Operation failed": "הפעולה נכשלה",
"Loading library...": "טוען ספרייה...",
"{{count}} books refreshed_one": "ספר {{count}} רוענן",
"{{count}} books refreshed_two": "{{count}} ספרים רועננו",
"{{count}} books refreshed_other": "{{count}} ספרים רועננו",
"Failed to refresh metadata": "רענון המטא-נתונים נכשל",
"Refresh Metadata": "רענון מטא-נתונים"
}
@@ -106,7 +106,6 @@
"Wikipedia": "विकिपीडिया",
"Writing Mode": "लेखन मोड",
"Your Library": "आपकी लाइब्रेरी",
"TTS not supported for PDF": "PDF के लिए TTS समर्थित नहीं है",
"Override Book Font": "पुस्तक फ़ॉन्ट ओवरराइड करें",
"Apply to All Books": "सभी पुस्तकों पर लागू करें",
"Apply to This Book": "इस पुस्तक पर लागू करें",
@@ -335,8 +334,8 @@
"Fit": "फिट",
"Reset {{settings}}": "रीसेट {{settings}}",
"Reset Settings": "सेटिंग्स रीसेट करें",
"{{count}} pages left in chapter_one": "इस अध्याय में {{count}} पृष्ठ शेष हैं",
"{{count}} pages left in chapter_other": "इस अध्याय में {{count}} पृष्ठ शेष हैं",
"{{count}} pages left in chapter_one": "<1>इस अध्याय में </1><0>{{count}}</0><1> पृष्ठ शेष हैं</1>",
"{{count}} pages left in chapter_other": "<1>इस अध्याय में </1><0>{{count}}</0><1> पृष्ठ शेष हैं</1>",
"Show Remaining Pages": "शेष पृष्ठ दिखाएँ",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -599,11 +598,10 @@
"Size": "आकार",
"Cover": "कवर",
"Contain": "समाहित करें",
"{{number}} pages left in chapter": "इस अध्याय में {{number}} पृष्ठ शेष हैं",
"{{number}} pages left in chapter": "<1>इस अध्याय में </1><0>{{number}}</0><1> पृष्ठ शेष हैं</1>",
"Device": "डिवाइस",
"E-Ink Mode": "ई-इंक मोड",
"Highlight Colors": "हाइलाइट रंग",
"Auto Screen Brightness": "स्वचालित स्क्रीन ब्राइटनेस",
"Pagination": "पृष्ठांकन",
"Disable Double Tap": "डबल टैप अक्षम करें",
"Tap to Paginate": "पृष्ठांकन के लिए टैप करें",
@@ -833,6 +831,9 @@
"Clear search": "खोज साफ़ करें",
"Clear search history": "खोज इतिहास साफ़ करें",
"Tap to Toggle Footer": "फुटर टॉगल करने के लिए टैप करें",
"Show Current Time": "वर्तमान समय दिखाएं",
"Use 24 Hour Clock": "24 घंटे की घड़ी का उपयोग करें",
"Show Current Battery Status": "वर्तमान बैटरी स्थिति दिखाएं",
"Exported successfully": "सफलतापूर्वक निर्यात किया गया",
"Book exported successfully.": "पुस्तक सफलतापूर्वक निर्यात की गई।",
"Failed to export the book.": "पुस्तक निर्यात करने में विफल।",
@@ -989,10 +990,8 @@
"Play (Space)": "चलाएं (Space)",
"Skip forward 15 words": "15 शब्द आगे",
"Forward 15 words (Shift+Right)": "15 शब्द आगे (Shift+Right)",
"Pause:": "विराम:",
"Decrease speed": "गति कम करें",
"Slower (Left/Down)": "धीमी (Left/Down)",
"Current speed": "वर्तमान गति",
"Increase speed": "गति बढ़ाएं",
"Faster (Right/Up)": "तेज़ (Right/Up)",
"Start RSVP Reading": "RSVP पठन शुरू करें",
@@ -1031,5 +1030,77 @@
"Books": "पुस्तकें",
"Groups": "समूह",
"Back to TTS Location": "टीटीएस स्थान पर वापस जाएं",
"Metadata": "मेटाडाटा"
"Metadata": "मेटाडाटा",
"Image viewer": "छवि दर्शक",
"Previous Image": "पिछली छवि",
"Next Image": "अगली छवि",
"Zoomed": "ज़ूम किया गया",
"Zoom level": "ज़ूम स्तर",
"Table viewer": "तालिका दर्शक",
"Unable to connect to Readwise. Please check your network connection.": "Readwise से कनेक्ट करने में असमर्थ। कृपया अपना नेटवर्क कनेक्शन जांचें।",
"Invalid Readwise access token": "अमान्य Readwise एक्सेस टोकन",
"Disconnected from Readwise": "Readwise से डिस्कनेक्ट हो गया",
"Never": "कभी नहीं",
"Readwise Settings": "Readwise सेटिंग्स",
"Connected to Readwise": "Readwise से जुड़ा हुआ",
"Last synced: {{time}}": "पिछला सिंक: {{time}}",
"Sync Enabled": "सिंक सक्षम",
"Disconnect": "डिस्कनेक्ट करें",
"Connect your Readwise account to sync highlights.": "हाइलाइट्स सिंक करने के लिए अपना Readwise खाता कनेक्ट करें।",
"Get your access token at": "अपना एक्सेस टोकन यहाँ प्राप्त करें",
"Access Token": "एक्सेस टोकन",
"Paste your Readwise access token": "अपना Readwise एक्सेस टोकन पेस्ट करें",
"Config": "कॉन्फिग",
"Readwise Sync": "Readwise सिंक",
"Push Highlights": "हाइलाइट्स भेजें",
"Highlights synced to Readwise": "हाइलाइट्स Readwise में सिंक हो गए",
"Readwise sync failed: no internet connection": "Readwise सिंक विफल रहा: कोई इंटरनेट कनेक्शन नहीं",
"Readwise sync failed: {{error}}": "Readwise सिंक विफल रहा: {{error}}",
"System Screen Brightness": "सिस्टम स्क्रीन चमक",
"Page:": "पृष्ठ:",
"Page: {{number}}": "पृष्ठ: {{number}}",
"Annotation page number": "व्याख्या पृष्ठ संख्या",
"Translating...": "अनुवाद हो रहा है...",
"Show Battery Percentage": "बैटरी प्रतिशत दिखाएं",
"Hide Scrollbar": "स्क्रॉलबार छुपाएँ",
"Skip to last reading position": "अंतिम पढ़ने की स्थिति पर जाएँ",
"Show context": "संदर्भ दिखाएँ",
"Hide context": "संदर्भ छुपाएँ",
"Decrease font size": "फ़ॉन्ट आकार घटाएँ",
"Increase font size": "फ़ॉन्ट आकार बढ़ाएँ",
"Focus": "फ़ोकस",
"Theme color": "थीम रंग",
"Import failed": "आयात विफल",
"Available Formatters:": "उपलब्ध फ़ॉर्मेटर:",
"Format date": "तारीख फ़ॉर्मेट करें",
"Markdown block quote (> per line)": "Markdown ब्लॉक कोट (> प्रति पंक्ति)",
"Newlines to <br>": "नई पंक्तियाँ <br> में",
"Change case": "केस बदलें",
"Trim whitespace": "रिक्त स्थान हटाएँ",
"Truncate to n characters": "n अक्षरों तक छोटा करें",
"Replace text": "टेक्स्ट बदलें",
"Fallback value": "फ़ॉलबैक मान",
"Get length": "लंबाई प्राप्त करें",
"First/last element": "पहला/अंतिम तत्व",
"Join array": "ऐरे जोड़ें",
"Backup failed: {{error}}": "बैकअप विफल: {{error}}",
"Select Backup": "बैकअप चुनें",
"Restore failed: {{error}}": "पुनर्स्थापना विफल: {{error}}",
"Backup & Restore": "बैकअप और पुनर्स्थापना",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "अपनी लाइब्रेरी का बैकअप बनाएं या पिछले बैकअप से पुनर्स्थापित करें। पुनर्स्थापना आपकी वर्तमान लाइब्रेरी के साथ मर्ज होगी।",
"Backup Library": "लाइब्रेरी बैकअप",
"Restore Library": "लाइब्रेरी पुनर्स्थापित करें",
"Creating backup...": "बैकअप बनाया जा रहा है...",
"Restoring library...": "लाइब्रेरी पुनर्स्थापित हो रही है...",
"{{current}} of {{total}} items": "{{current}} / {{total}} आइटम",
"Backup completed successfully!": "बैकअप सफलतापूर्वक पूर्ण!",
"Restore completed successfully!": "पुनर्स्थापना सफलतापूर्वक पूर्ण!",
"Your library has been saved to the selected location.": "आपकी लाइब्रेरी चयनित स्थान पर सहेजी गई है।",
"{{added}} books added, {{updated}} books updated.": "{{added}} पुस्तकें जोड़ी गईं, {{updated}} पुस्तकें अपडेट की गईं।",
"Operation failed": "ऑपरेशन विफल",
"Loading library...": "लाइब्रेरी लोड हो रही है...",
"{{count}} books refreshed_one": "{{count}} पुस्तक रिफ्रेश हुई",
"{{count}} books refreshed_other": "{{count}} पुस्तकें रिफ्रेश हुईं",
"Failed to refresh metadata": "मेटाडेटा रिफ्रेश विफल",
"Refresh Metadata": "मेटाडेटा रिफ्रेश करें"
}
@@ -106,7 +106,6 @@
"Wikipedia": "Wikipedia",
"Writing Mode": "Mode Menulis",
"Your Library": "Perpustakaan Anda",
"TTS not supported for PDF": "TTS tidak didukung untuk PDF",
"Override Book Font": "Ganti Font Buku",
"Apply to All Books": "Terapkan ke semua buku",
"Apply to This Book": "Terapkan ke buku ini",
@@ -332,7 +331,7 @@
"Fit": "Pas",
"Reset {{settings}}": "Atur Ulang {{settings}}",
"Reset Settings": "Atur Ulang Pengaturan",
"{{count}} pages left in chapter_other": "{{count}} halaman tersisa di bab ini",
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> halaman tersisa di bab ini</1>",
"Show Remaining Pages": "Tampilkan halaman tersisa",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -595,11 +594,10 @@
"Size": "Ukuran",
"Cover": "Sampul",
"Contain": "Mengandung",
"{{number}} pages left in chapter": "{{number}} halaman tersisa di bab",
"{{number}} pages left in chapter": "<0>{{number}}</0><1> halaman tersisa di bab</1>",
"Device": "Perangkat",
"E-Ink Mode": "Mode E-Ink",
"Highlight Colors": "Warna Sorotan",
"Auto Screen Brightness": "Auto Kecerahan Layar",
"Pagination": "Paginasi",
"Disable Double Tap": "Nonaktifkan Ketuk Ganda",
"Tap to Paginate": "Ketuk untuk Paginasi",
@@ -823,6 +821,9 @@
"Clear search": "Hapus pencarian",
"Clear search history": "Hapus riwayat pencarian",
"Tap to Toggle Footer": "Ketuk untuk beralih footer",
"Show Current Time": "Tampilkan Waktu Sekarang",
"Use 24 Hour Clock": "Gunakan Format 24 Jam",
"Show Current Battery Status": "Tampilkan Status Baterai Saat Ini",
"Exported successfully": "Berhasil diekspor",
"Book exported successfully.": "Buku berhasil diekspor.",
"Failed to export the book.": "Gagal mengekspor buku.",
@@ -977,10 +978,8 @@
"Play (Space)": "Putar (Spasi)",
"Skip forward 15 words": "Lompat maju 15 kata",
"Forward 15 words (Shift+Right)": "Maju 15 kata (Shift+Kanan)",
"Pause:": "Jeda:",
"Decrease speed": "Kurangi kecepatan",
"Slower (Left/Down)": "Lebih lambat (Kiri/Bawah)",
"Current speed": "Kecepatan saat ini",
"Increase speed": "Tambah kecepatan",
"Faster (Right/Up)": "Lebih cepat (Kanan/Atas)",
"Start RSVP Reading": "Mulai Membaca RSVP",
@@ -1019,5 +1018,76 @@
"Books": "Buku",
"Groups": "Grup",
"Back to TTS Location": "Kembali ke Lokasi TTS",
"Metadata": "Metadata"
"Metadata": "Metadata",
"Image viewer": "Penampil gambar",
"Previous Image": "Gambar Sebelumnya",
"Next Image": "Gambar Berikutnya",
"Zoomed": "Diperbesar",
"Zoom level": "Tingkat zoom",
"Table viewer": "Penampil tabel",
"Unable to connect to Readwise. Please check your network connection.": "Tidak dapat terhubung ke Readwise. Silakan periksa koneksi jaringan Anda.",
"Invalid Readwise access token": "Token akses Readwise tidak valid",
"Disconnected from Readwise": "Terputus dari Readwise",
"Never": "Tidak pernah",
"Readwise Settings": "Pengaturan Readwise",
"Connected to Readwise": "Terhubung ke Readwise",
"Last synced: {{time}}": "Terakhir disinkronkan: {{time}}",
"Sync Enabled": "Sinkronisasi Diaktifkan",
"Disconnect": "Putuskan sambungan",
"Connect your Readwise account to sync highlights.": "Hubungkan akun Readwise Anda untuk menyinkronkan sorotan.",
"Get your access token at": "Dapatkan token akses Anda di",
"Access Token": "Token Akses",
"Paste your Readwise access token": "Tempelkan token akses Readwise Anda",
"Config": "Konfigurasi",
"Readwise Sync": "Sinkronisasi Readwise",
"Push Highlights": "Kirim Sorotan",
"Highlights synced to Readwise": "Sorotan disinkronkan ke Readwise",
"Readwise sync failed: no internet connection": "Sinkronisasi Readwise gagal: tidak ada koneksi internet",
"Readwise sync failed: {{error}}": "Sinkronisasi Readwise gagal: {{error}}",
"System Screen Brightness": "Kecerahan Layar Sistem",
"Page:": "Halaman:",
"Page: {{number}}": "Halaman: {{number}}",
"Annotation page number": "Nomor halaman anotasi",
"Translating...": "Menerjemahkan...",
"Show Battery Percentage": "Tampilkan Persentase Baterai",
"Hide Scrollbar": "Sembunyikan Bilah Gulir",
"Skip to last reading position": "Lompat ke posisi baca terakhir",
"Show context": "Tampilkan konteks",
"Hide context": "Sembunyikan konteks",
"Decrease font size": "Perkecil ukuran font",
"Increase font size": "Perbesar ukuran font",
"Focus": "Fokus",
"Theme color": "Warna tema",
"Import failed": "Impor gagal",
"Available Formatters:": "Formatter Tersedia:",
"Format date": "Format tanggal",
"Markdown block quote (> per line)": "Kutipan blok Markdown (> per baris)",
"Newlines to <br>": "Baris baru ke <br>",
"Change case": "Ubah huruf besar/kecil",
"Trim whitespace": "Pangkas spasi",
"Truncate to n characters": "Potong ke n karakter",
"Replace text": "Ganti teks",
"Fallback value": "Nilai cadangan",
"Get length": "Dapatkan panjang",
"First/last element": "Elemen pertama/terakhir",
"Join array": "Gabungkan array",
"Backup failed: {{error}}": "Pencadangan gagal: {{error}}",
"Select Backup": "Pilih Cadangan",
"Restore failed: {{error}}": "Pemulihan gagal: {{error}}",
"Backup & Restore": "Cadangkan & Pulihkan",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Buat cadangan perpustakaan Anda atau pulihkan dari cadangan sebelumnya. Pemulihan akan digabungkan dengan perpustakaan Anda saat ini.",
"Backup Library": "Cadangkan Perpustakaan",
"Restore Library": "Pulihkan Perpustakaan",
"Creating backup...": "Membuat cadangan...",
"Restoring library...": "Memulihkan perpustakaan...",
"{{current}} of {{total}} items": "{{current}} dari {{total}} item",
"Backup completed successfully!": "Pencadangan berhasil!",
"Restore completed successfully!": "Pemulihan berhasil!",
"Your library has been saved to the selected location.": "Perpustakaan Anda telah disimpan di lokasi yang dipilih.",
"{{added}} books added, {{updated}} books updated.": "{{added}} buku ditambahkan, {{updated}} buku diperbarui.",
"Operation failed": "Operasi gagal",
"Loading library...": "Memuat perpustakaan...",
"{{count}} books refreshed_other": "{{count}} buku diperbarui",
"Failed to refresh metadata": "Gagal memperbarui metadata",
"Refresh Metadata": "Perbarui Metadata"
}
@@ -106,7 +106,6 @@
"Wikipedia": "Wikipedia",
"Writing Mode": "Modalità scrittura",
"Your Library": "La tua biblioteca",
"TTS not supported for PDF": "TTS non supportato per PDF",
"Override Book Font": "Sovrascrivi font libro",
"Apply to All Books": "Applica a tutti i libri",
"Apply to This Book": "Applica a questo libro",
@@ -338,9 +337,9 @@
"Fit": "Adatta",
"Reset {{settings}}": "Reimposta {{settings}}",
"Reset Settings": "Reimposta impostazioni",
"{{count}} pages left in chapter_one": "{{count}} pagina rimasta nel capitolo",
"{{count}} pages left in chapter_many": "{{count}} pagine rimaste nel capitolo",
"{{count}} pages left in chapter_other": "{{count}} pagine rimaste nel capitolo",
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> pagina rimasta nel capitolo</1>",
"{{count}} pages left in chapter_many": "<0>{{count}}</0><1> pagine rimaste nel capitolo</1>",
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> pagine rimaste nel capitolo</1>",
"Show Remaining Pages": "Mostra pagine rimanenti",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -603,11 +602,10 @@
"Size": "Dimensione",
"Cover": "Copertura",
"Contain": "Contenere",
"{{number}} pages left in chapter": "{{number}} pagine rimaste nel capitolo",
"{{number}} pages left in chapter": "<0>{{number}}</0><1> pagine rimaste nel capitolo</1>",
"Device": "Dispositivo",
"E-Ink Mode": "Modalità E-Ink",
"Highlight Colors": "Colori Evidenziazione",
"Auto Screen Brightness": "Luminosità schermo automatica",
"Pagination": "Impaginazione",
"Disable Double Tap": "Disabilita Doppio Tap",
"Tap to Paginate": "Tocca per Impaginare",
@@ -843,6 +841,9 @@
"Clear search": "Cancella ricerca",
"Clear search history": "Cancella cronologia ricerche",
"Tap to Toggle Footer": "Tocca per mostrare/nascondere il piè di pagina",
"Show Current Time": "Mostra ora attuale",
"Use 24 Hour Clock": "Usa formato 24 ore",
"Show Current Battery Status": "Mostra lo stato attuale della batteria",
"Exported successfully": "Esportato con successo",
"Book exported successfully.": "Libro esportato con successo.",
"Failed to export the book.": "Impossibile esportare il libro.",
@@ -1001,10 +1002,8 @@
"Play (Space)": "Riproduci (Spazio)",
"Skip forward 15 words": "Avanti di 15 parole",
"Forward 15 words (Shift+Right)": "Avanti di 15 parole (Shift+Destra)",
"Pause:": "Pausa:",
"Decrease speed": "Diminuisci velocità",
"Slower (Left/Down)": "Più lento (Sinistra/Giù)",
"Current speed": "Velocità attuale",
"Increase speed": "Aumenta velocità",
"Faster (Right/Up)": "Più veloce (Destra/Su)",
"Start RSVP Reading": "Avvia lettura RSVP",
@@ -1043,5 +1042,78 @@
"Books": "Libri",
"Groups": "Gruppi",
"Back to TTS Location": "Torna alla posizione TTS",
"Metadata": "Metadati"
"Metadata": "Metadati",
"Image viewer": "Visualizzatore immagini",
"Previous Image": "Immagine precedente",
"Next Image": "Immagine successiva",
"Zoomed": "Zoomato",
"Zoom level": "Livello di zoom",
"Table viewer": "Visualizzatore tabelle",
"Unable to connect to Readwise. Please check your network connection.": "Impossibile connettersi a Readwise. Controlla la tua connessione di rete.",
"Invalid Readwise access token": "Token di accesso Readwise non valido",
"Disconnected from Readwise": "Disconnesso da Readwise",
"Never": "Mai",
"Readwise Settings": "Impostazioni Readwise",
"Connected to Readwise": "Connesso a Readwise",
"Last synced: {{time}}": "Ultima sincronizzazione: {{time}}",
"Sync Enabled": "Sincronizzazione abilitata",
"Disconnect": "Disconnetti",
"Connect your Readwise account to sync highlights.": "Connetti il tuo account Readwise per sincronizzare le evidenziazioni.",
"Get your access token at": "Ottieni il tuo token di accesso su",
"Access Token": "Token di accesso",
"Paste your Readwise access token": "Incolla il tuo token di accesso Readwise",
"Config": "Configurazione",
"Readwise Sync": "Sincronizzazione Readwise",
"Push Highlights": "Invia evidenziazioni",
"Highlights synced to Readwise": "Evidenziazioni sincronizzate su Readwise",
"Readwise sync failed: no internet connection": "Sincronizzazione Readwise fallita: nessuna connessione internet",
"Readwise sync failed: {{error}}": "Sincronizzazione Readwise fallita: {{error}}",
"System Screen Brightness": "Luminosità dello schermo del sistema",
"Page:": "Pagina:",
"Page: {{number}}": "Pagina: {{number}}",
"Annotation page number": "Numero di pagina dell'annotazione",
"Translating...": "Traduzione...",
"Show Battery Percentage": "Mostra percentuale batteria",
"Hide Scrollbar": "Nascondi barra di scorrimento",
"Skip to last reading position": "Vai all'ultima posizione di lettura",
"Show context": "Mostra contesto",
"Hide context": "Nascondi contesto",
"Decrease font size": "Riduci dimensione carattere",
"Increase font size": "Aumenta dimensione carattere",
"Focus": "Messa a fuoco",
"Theme color": "Colore del tema",
"Import failed": "Importazione fallita",
"Available Formatters:": "Formattatori disponibili:",
"Format date": "Formatta data",
"Markdown block quote (> per line)": "Citazione Markdown (> per riga)",
"Newlines to <br>": "A capo in <br>",
"Change case": "Cambia maiuscole/minuscole",
"Trim whitespace": "Rimuovi spazi",
"Truncate to n characters": "Tronca a n caratteri",
"Replace text": "Sostituisci testo",
"Fallback value": "Valore predefinito",
"Get length": "Ottieni lunghezza",
"First/last element": "Primo/ultimo elemento",
"Join array": "Unisci array",
"Backup failed: {{error}}": "Backup fallito: {{error}}",
"Select Backup": "Seleziona backup",
"Restore failed: {{error}}": "Ripristino fallito: {{error}}",
"Backup & Restore": "Backup e ripristino",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Crea un backup della tua libreria o ripristina da un backup precedente. Il ripristino verrà unito alla tua libreria attuale.",
"Backup Library": "Backup libreria",
"Restore Library": "Ripristina libreria",
"Creating backup...": "Creazione backup...",
"Restoring library...": "Ripristino libreria...",
"{{current}} of {{total}} items": "{{current}} di {{total}} elementi",
"Backup completed successfully!": "Backup completato con successo!",
"Restore completed successfully!": "Ripristino completato con successo!",
"Your library has been saved to the selected location.": "La tua libreria è stata salvata nella posizione selezionata.",
"{{added}} books added, {{updated}} books updated.": "{{added}} libri aggiunti, {{updated}} libri aggiornati.",
"Operation failed": "Operazione fallita",
"Loading library...": "Caricamento libreria...",
"{{count}} books refreshed_one": "{{count}} libro aggiornato",
"{{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"
}
@@ -106,7 +106,6 @@
"Wikipedia": "ウィキペディア",
"Writing Mode": "書き込みモード",
"Your Library": "ライブラリー",
"TTS not supported for PDF": "PDFではTTSがサポートされていません",
"Override Book Font": "書籍フォントを上書き",
"Apply to All Books": "すべての書籍に適用",
"Apply to This Book": "この書籍に適用",
@@ -332,7 +331,7 @@
"Fit": "フィット",
"Reset {{settings}}": "{{settings}}をリセット",
"Reset Settings": "設定をリセット",
"{{count}} pages left in chapter_other": "{{count}} ページ残り",
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> ページ残り</1>",
"Show Remaining Pages": "残りを表示",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -595,11 +594,10 @@
"Size": "サイズ",
"Cover": "カバー",
"Contain": "含む",
"{{number}} pages left in chapter": "章に{{number}}ページ残り",
"{{number}} pages left in chapter": "<1>章に</1><0>{{number}}</0><1>ページ残り</1>",
"Device": "デバイス",
"E-Ink Mode": "E-Inkモード",
"Highlight Colors": "ハイライトカラー",
"Auto Screen Brightness": "自動画面の明るさ",
"Pagination": "ページネーション",
"Disable Double Tap": "ダブルタップを無効にする",
"Tap to Paginate": "タップしてページを切り替える",
@@ -823,6 +821,9 @@
"Clear search": "検索をクリア",
"Clear search history": "検索履歴をクリア",
"Tap to Toggle Footer": "タップでフッターを切り替え",
"Show Current Time": "現在時刻を表示",
"Use 24 Hour Clock": "24時間表示を使用",
"Show Current Battery Status": "バッテリー残量を表示",
"Exported successfully": "エクスポート成功",
"Book exported successfully.": "書籍のエクスポートに成功しました。",
"Failed to export the book.": "書籍のエクスポートに失敗しました。",
@@ -977,10 +978,8 @@
"Play (Space)": "再生 (Space)",
"Skip forward 15 words": "15単語進む",
"Forward 15 words (Shift+Right)": "15単語進む (Shift+右)",
"Pause:": "一時停止:",
"Decrease speed": "速度を下げる",
"Slower (Left/Down)": "低速 (左/下)",
"Current speed": "現在の速度",
"Increase speed": "速度を上げる",
"Faster (Right/Up)": "高速 (右/上)",
"Start RSVP Reading": "RSVP読書を開始",
@@ -1019,5 +1018,76 @@
"Books": "書籍",
"Groups": "グループ",
"Back to TTS Location": "TTSの位置に戻る",
"Metadata": "メタデータ"
"Metadata": "メタデータ",
"Image viewer": "画像ビューア",
"Previous Image": "前の画像",
"Next Image": "次の画像",
"Zoomed": "ズーム済み",
"Zoom level": "ズームレベル",
"Table viewer": "テーブルビューア",
"Unable to connect to Readwise. Please check your network connection.": "Readwise に接続できません。ネットワーク接続を確認してください。",
"Invalid Readwise access token": "無効な Readwise アクセストークン",
"Disconnected from Readwise": "Readwise から切断されました",
"Never": "一度もなし",
"Readwise Settings": "Readwise 設定",
"Connected to Readwise": "Readwise に接続済み",
"Last synced: {{time}}": "最終同期:{{time}}",
"Sync Enabled": "同期有効",
"Disconnect": "切断",
"Connect your Readwise account to sync highlights.": "ハイライトを同期するには Readwise アカウントを接続してください。",
"Get your access token at": "アクセストークンの取得先:",
"Access Token": "アクセストークン",
"Paste your Readwise access token": "Readwise アクセストークンを貼り付けてください",
"Config": "設定",
"Readwise Sync": "Readwise 同期",
"Push Highlights": "ハイライトをプッシュ",
"Highlights synced to Readwise": "ハイライトが Readwise に同期されました",
"Readwise sync failed: no internet connection": "Readwise 同期失敗:インターネット接続がありません",
"Readwise sync failed: {{error}}": "Readwise 同期失敗:{{error}}",
"System Screen Brightness": "システムの画面の明るさ",
"Page:": "ページ:",
"Page: {{number}}": "ページ: {{number}}",
"Annotation page number": "注釈のページ番号",
"Translating...": "翻訳中...",
"Show Battery Percentage": "バッテリー残量を表示",
"Hide Scrollbar": "スクロールバーを非表示",
"Skip to last reading position": "最後の読書位置へ移動",
"Show context": "コンテキストを表示",
"Hide context": "コンテキストを非表示",
"Decrease font size": "文字サイズを縮小",
"Increase font size": "文字サイズを拡大",
"Focus": "フォーカス",
"Theme color": "テーマカラー",
"Import failed": "インポートに失敗しました",
"Available Formatters:": "利用可能なフォーマッター:",
"Format date": "日付のフォーマット",
"Markdown block quote (> per line)": "Markdown引用ブロック (行ごとに >)",
"Newlines to <br>": "改行を <br> に変換",
"Change case": "大文字/小文字の変更",
"Trim whitespace": "空白を除去",
"Truncate to n characters": "n文字に切り詰め",
"Replace text": "テキストを置換",
"Fallback value": "フォールバック値",
"Get length": "長さを取得",
"First/last element": "最初/最後の要素",
"Join array": "配列を結合",
"Backup failed: {{error}}": "バックアップに失敗しました: {{error}}",
"Select Backup": "バックアップを選択",
"Restore failed: {{error}}": "復元に失敗しました: {{error}}",
"Backup & Restore": "バックアップと復元",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "ライブラリのバックアップを作成するか、以前のバックアップから復元します。復元は現在のライブラリとマージされます。",
"Backup Library": "ライブラリをバックアップ",
"Restore Library": "ライブラリを復元",
"Creating backup...": "バックアップを作成中...",
"Restoring library...": "ライブラリを復元中...",
"{{current}} of {{total}} items": "{{current}} / {{total}} 件",
"Backup completed successfully!": "バックアップが完了しました!",
"Restore completed successfully!": "復元が完了しました!",
"Your library has been saved to the selected location.": "ライブラリが選択した場所に保存されました。",
"{{added}} books added, {{updated}} books updated.": "{{added}}冊追加、{{updated}}冊更新されました。",
"Operation failed": "操作に失敗しました",
"Loading library...": "ライブラリを読み込み中...",
"{{count}} books refreshed_other": "{{count}}冊のメタデータを更新しました",
"Failed to refresh metadata": "メタデータの更新に失敗しました",
"Refresh Metadata": "メタデータを更新"
}
@@ -106,7 +106,6 @@
"Wikipedia": "위키피디아",
"Writing Mode": "글쓰기 모드",
"Your Library": "내 서재",
"TTS not supported for PDF": "PDF에서 TTS가 지원되지 않음",
"Override Book Font": "책 글꼴 덮어쓰기",
"Apply to All Books": "모든 책에 적용",
"Apply to This Book": "이 책에 적용",
@@ -332,7 +331,7 @@
"Fit": "맞춤",
"Reset {{settings}}": "{{settings}} 재설정",
"Reset Settings": "설정 재설정",
"{{count}} pages left in chapter_other": "남은 페이지 {{count}}",
"{{count}} pages left in chapter_other": "<1>남은 페이지 </1><0>{{count}}</0><1>장</1>",
"Show Remaining Pages": "남은 페이지 보기",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -595,11 +594,10 @@
"Size": "크기",
"Cover": "표지",
"Contain": "포함",
"{{number}} pages left in chapter": "남은 페이지 {{number}}",
"{{number}} pages left in chapter": "<1>남은 페이지 </1><0>{{number}}</0><1>장</1>",
"Device": "장치",
"E-Ink Mode": "E-Ink 모드",
"Highlight Colors": "하이라이트 색상",
"Auto Screen Brightness": "자동 화면 밝기",
"Pagination": "페이지 매김",
"Disable Double Tap": "더블 탭 비활성화",
"Tap to Paginate": "탭하여 페이지 매김",
@@ -823,6 +821,9 @@
"Clear search": "검색 지우기",
"Clear search history": "검색 기록 지우기",
"Tap to Toggle Footer": "탭하여 바닥글 전환",
"Show Current Time": "현재 시간 표시",
"Use 24 Hour Clock": "24시간제 사용",
"Show Current Battery Status": "현재 배터리 상태 표시",
"Exported successfully": "내보내기 성공",
"Book exported successfully.": "책을 성공적으로 내보냈습니다.",
"Failed to export the book.": "책 내보내기에 실패했습니다.",
@@ -977,10 +978,8 @@
"Play (Space)": "재생 (스페이스바)",
"Skip forward 15 words": "15단어 앞으로",
"Forward 15 words (Shift+Right)": "15단어 앞으로 (Shift+오른쪽 화살표)",
"Pause:": "일시정지:",
"Decrease speed": "속도 줄이기",
"Slower (Left/Down)": "느리게 (왼쪽/아래 화살표)",
"Current speed": "현재 속도",
"Increase speed": "속도 높이기",
"Faster (Right/Up)": "빠르게 (오른쪽/위 화살표)",
"Start RSVP Reading": "RSVP 독서 시작",
@@ -1019,5 +1018,76 @@
"Books": "도서",
"Groups": "그룹",
"Back to TTS Location": "TTS 위치로 돌아가기",
"Metadata": "메타데이터"
"Metadata": "메타데이터",
"Image viewer": "이미지 뷰어",
"Previous Image": "이전 이미지",
"Next Image": "다음 이미지",
"Zoomed": "확대됨",
"Zoom level": "확대 수준",
"Table viewer": "테이블 뷰어",
"Unable to connect to Readwise. Please check your network connection.": "Readwise에 연결할 수 없습니다. 네트워크 연결을 확인해 주세요.",
"Invalid Readwise access token": "유효하지 않은 Readwise 액세스 토큰입니다",
"Disconnected from Readwise": "Readwise와 연결이 끊어졌습니다",
"Never": "안 함",
"Readwise Settings": "Readwise 설정",
"Connected to Readwise": "Readwise에 연결됨",
"Last synced: {{time}}": "마지막 동기화: {{time}}",
"Sync Enabled": "동기화 활성화됨",
"Disconnect": "연결 해제",
"Connect your Readwise account to sync highlights.": "하이라이트를 동기화하려면 Readwise 계정렬 연결하세요.",
"Get your access token at": "액세스 토큰 받기:",
"Access Token": "액세스 토큰",
"Paste your Readwise access token": "Readwise 액세스 토큰을 붙여넣으세요",
"Config": "구성",
"Readwise Sync": "Readwise 동기화",
"Push Highlights": "하이라이트 푸시",
"Highlights synced to Readwise": "하이라이트가 Readwise에 동기화되었습니다",
"Readwise sync failed: no internet connection": "Readwise 동기화 실패: 인터넷 연결 없음",
"Readwise sync failed: {{error}}": "Readwise 동기화 실패: {{error}}",
"System Screen Brightness": "시스템 화면 밝기",
"Page:": "페이지:",
"Page: {{number}}": "페이지: {{number}}",
"Annotation page number": "주석 페이지 번호",
"Translating...": "번역 중...",
"Show Battery Percentage": "배터리 백분율 표시",
"Hide Scrollbar": "스크롤바 숨기기",
"Skip to last reading position": "마지막 읽기 위치로 이동",
"Show context": "맥락 표시",
"Hide context": "맥락 숨기기",
"Decrease font size": "글꼴 크기 줄이기",
"Increase font size": "글꼴 크기 늘리기",
"Focus": "초점",
"Theme color": "테마 색상",
"Import failed": "가져오기 실패",
"Available Formatters:": "사용 가능한 포맷터:",
"Format date": "날짜 형식",
"Markdown block quote (> per line)": "Markdown 인용 블록 (줄마다 >)",
"Newlines to <br>": "줄바꿈을 <br>로 변환",
"Change case": "대소문자 변경",
"Trim whitespace": "공백 제거",
"Truncate to n characters": "n자로 자르기",
"Replace text": "텍스트 바꾸기",
"Fallback value": "기본값",
"Get length": "길이 가져오기",
"First/last element": "첫 번째/마지막 요소",
"Join array": "배열 합치기",
"Backup failed: {{error}}": "백업 실패: {{error}}",
"Select Backup": "백업 선택",
"Restore failed: {{error}}": "복원 실패: {{error}}",
"Backup & Restore": "백업 및 복원",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "라이브러리를 백업하거나 이전 백업에서 복원하세요. 복원 시 현재 라이브러리와 병합됩니다.",
"Backup Library": "라이브러리 백업",
"Restore Library": "라이브러리 복원",
"Creating backup...": "백업 생성 중...",
"Restoring library...": "라이브러리 복원 중...",
"{{current}} of {{total}} items": "{{current}} / {{total}} 항목",
"Backup completed successfully!": "백업이 완료되었습니다!",
"Restore completed successfully!": "복원이 완료되었습니다!",
"Your library has been saved to the selected location.": "라이브러리가 선택한 위치에 저장되었습니다.",
"{{added}} books added, {{updated}} books updated.": "{{added}}권 추가, {{updated}}권 업데이트되었습니다.",
"Operation failed": "작업 실패",
"Loading library...": "라이브러리 로딩 중...",
"{{count}} books refreshed_other": "{{count}}권 새로고침됨",
"Failed to refresh metadata": "메타데이터 새로고침 실패",
"Refresh Metadata": "메타데이터 새로고침"
}
@@ -248,8 +248,8 @@
"Add your notes here...": "Tambah nota anda di sini...",
"Search notes and excerpts...": "Cari nota dan petikan...",
"{{time}} min left in chapter": "{{time}} min lagi dalam bab",
"{{number}} pages left in chapter": "{{number}} halaman lagi dalam bab",
"{{count}} pages left in chapter_other": "{{count}} halaman lagi dalam bab",
"{{number}} pages left in chapter": "<0>{{number}}</0><1> halaman lagi dalam bab</1>",
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> halaman lagi dalam bab</1>",
"On {{current}} of {{total}} page": "Di halaman {{current}} daripada {{total}}",
"Unable to open book": "Tidak dapat membuka buku",
"Section Title": "Tajuk Seksyen",
@@ -293,7 +293,6 @@
"Next Paragraph": "Perenggan Seterusnya",
"Read Aloud": "Baca Dengan Kuat",
"Ready to read aloud": "Bersedia untuk baca dengan kuat",
"TTS not supported for PDF": "TTS tidak disokong untuk PDF",
"TTS not supported for this document": "TTS tidak disokong untuk dokumen ini",
"No Timeout": "Tiada Had Masa",
"{{value}} minute": "{{value}} minit",
@@ -517,7 +516,6 @@
"Paging Animation": "Animasi Halaman",
"Device": "Peranti",
"E-Ink Mode": "Mod E-Ink",
"Auto Screen Brightness": "Kecerahan Skrin Auto",
"Security": "Keselamatan",
"Allow JavaScript": "Benarkan JavaScript",
"Enable only if you trust the file.": "Aktifkan hanya jika anda mempercayai fail.",
@@ -823,6 +821,9 @@
"Clear search": "Kosongkan carian",
"Clear search history": "Kosongkan sejarah carian",
"Tap to Toggle Footer": "Ketik untuk togol pengaki",
"Show Current Time": "Tunjukkan Waktu Sekarang",
"Use 24 Hour Clock": "Gunakan Format 24 Jam",
"Show Current Battery Status": "Paparkan Status Bateri Semasa",
"Exported successfully": "Berjaya dieksport",
"Book exported successfully.": "Buku berjaya dieksport.",
"Failed to export the book.": "Gagal mengeksport buku.",
@@ -977,10 +978,8 @@
"Play (Space)": "Main (Ruang)",
"Skip forward 15 words": "Langkau depan 15 perkataan",
"Forward 15 words (Shift+Right)": "Depan 15 perkataan (Shift+Kanan)",
"Pause:": "Jeda:",
"Decrease speed": "Kurangkan kelajuan",
"Slower (Left/Down)": "Lebih perlahan (Kiri/Bawah)",
"Current speed": "Kelajuan semasa",
"Increase speed": "Tingkatkan kelajuan",
"Faster (Right/Up)": "Lebih cepat (Kanan/Atas)",
"Start RSVP Reading": "Mulakan Pembacaan RSVP",
@@ -1019,5 +1018,76 @@
"Books": "Buku",
"Groups": "Kumpulan",
"Back to TTS Location": "Kembali ke Lokasi TTS",
"Metadata": "Metadata"
"Metadata": "Metadata",
"Image viewer": "Penyorot imej",
"Previous Image": "Imej Sebelumnya",
"Next Image": "Imej Seterusnya",
"Zoomed": "Dizum",
"Zoom level": "Tahap zum",
"Table viewer": "Penyorot jadual",
"Unable to connect to Readwise. Please check your network connection.": "Tidak dapat menyambung ke Readwise. Sila periksa sambungan rangkaian anda.",
"Invalid Readwise access token": "Token akses Readwise tidak sah",
"Disconnected from Readwise": "Terputus sambungan daripada Readwise",
"Never": "Tidak pernah",
"Readwise Settings": "Tetapan Readwise",
"Connected to Readwise": "Disambungkan ke Readwise",
"Last synced: {{time}}": "Kali terakhir disinkronkan: {{time}}",
"Sync Enabled": "Penyinkronan Didayakan",
"Disconnect": "Putuskan sambungan",
"Connect your Readwise account to sync highlights.": "Sambungkan akaun Readwise anda untuk menyinkronkan sorotan.",
"Get your access token at": "Dapatkan token akses anda di",
"Access Token": "Token Akses",
"Paste your Readwise access token": "Tampal token akses Readwise anda",
"Config": "Konfigurasi",
"Readwise Sync": "Penyinkronan Readwise",
"Push Highlights": "Hantar Sorotan",
"Highlights synced to Readwise": "Sorotan disinkronkan ke Readwise",
"Readwise sync failed: no internet connection": "Penyinkronan Readwise gagal: tiada sambungan internet",
"Readwise sync failed: {{error}}": "Penyinkronan Readwise gagal: {{error}}",
"System Screen Brightness": "Kecerahan Skrin Sistem",
"Page:": "Halaman:",
"Page: {{number}}": "Halaman: {{number}}",
"Annotation page number": "Nombor halaman anotasi",
"Translating...": "Menterjemah...",
"Show Battery Percentage": "Tunjukkan Peratusan Bateri",
"Hide Scrollbar": "Sembunyikan Bar Tatal",
"Skip to last reading position": "Lompat ke posisi bacaan terakhir",
"Show context": "Tunjukkan konteks",
"Hide context": "Sembunyikan konteks",
"Decrease font size": "Kecilkan saiz fon",
"Increase font size": "Besarkan saiz fon",
"Focus": "Fokus",
"Theme color": "Warna tema",
"Import failed": "Import gagal",
"Available Formatters:": "Pemformat Tersedia:",
"Format date": "Format tarikh",
"Markdown block quote (> per line)": "Petikan blok Markdown (> setiap baris)",
"Newlines to <br>": "Baris baharu ke <br>",
"Change case": "Tukar huruf besar/kecil",
"Trim whitespace": "Pangkas ruang kosong",
"Truncate to n characters": "Potong kepada n aksara",
"Replace text": "Ganti teks",
"Fallback value": "Nilai lalai",
"Get length": "Dapatkan panjang",
"First/last element": "Elemen pertama/terakhir",
"Join array": "Gabung tatasusunan",
"Backup failed: {{error}}": "Sandaran gagal: {{error}}",
"Select Backup": "Pilih Sandaran",
"Restore failed: {{error}}": "Pemulihan gagal: {{error}}",
"Backup & Restore": "Sandaran & Pemulihan",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Buat sandaran perpustakaan anda atau pulihkan daripada sandaran sebelumnya. Pemulihan akan digabungkan dengan perpustakaan semasa anda.",
"Backup Library": "Sandarkan Perpustakaan",
"Restore Library": "Pulihkan Perpustakaan",
"Creating backup...": "Membuat sandaran...",
"Restoring library...": "Memulihkan perpustakaan...",
"{{current}} of {{total}} items": "{{current}} daripada {{total}} item",
"Backup completed successfully!": "Sandaran berjaya!",
"Restore completed successfully!": "Pemulihan berjaya!",
"Your library has been saved to the selected location.": "Perpustakaan anda telah disimpan di lokasi yang dipilih.",
"{{added}} books added, {{updated}} books updated.": "{{added}} buku ditambah, {{updated}} buku dikemas kini.",
"Operation failed": "Operasi gagal",
"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"
}
@@ -206,7 +206,6 @@
"TOC": "Inhoudsopgave",
"Table of Contents": "Inhoudsopgave",
"Sidebar": "Zijbalk",
"TTS not supported for PDF": "TTS niet ondersteund voor PDF",
"No Timeout": "Geen time-out",
"{{value}} minute": "{{value}} minuut",
"{{value}} minutes": "{{value}} minuten",
@@ -335,8 +334,8 @@
"Fit": "Passend maken",
"Reset {{settings}}": "Reset {{settings}}",
"Reset Settings": "Instellingen resetten",
"{{count}} pages left in chapter_one": "Nog {{count}} pagina in hoofdstuk",
"{{count}} pages left in chapter_other": "Nog {{count}} paginas in hoofdstuk",
"{{count}} pages left in chapter_one": "<1>Nog </1><0>{{count}}</0><1> pagina in hoofdstuk</1>",
"{{count}} pages left in chapter_other": "<1>Nog </1><0>{{count}}</0><1> paginas in hoofdstuk</1>",
"Show Remaining Pages": "Toon resterende paginas",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -599,11 +598,10 @@
"Size": "Grootte",
"Cover": "Omslag",
"Contain": "Bevatten",
"{{number}} pages left in chapter": "Nog {{number}} paginas in hoofdstuk",
"{{number}} pages left in chapter": "<1>Nog </1><0>{{number}}</0><1> paginas in hoofdstuk</1>",
"Device": "Apparaat",
"E-Ink Mode": "E-Ink Modus",
"Highlight Colors": "Markeerkleuren",
"Auto Screen Brightness": "Automatische Schermhelderheid",
"Pagination": "Paginering",
"Disable Double Tap": "Schakel Dubbel Tikken Uit",
"Tap to Paginate": "Tik om te Pagineren",
@@ -833,6 +831,9 @@
"Clear search": "Zoekopdracht wissen",
"Clear search history": "Zoekgeschiedenis wissen",
"Tap to Toggle Footer": "Tik om voettekst te wisselen",
"Show Current Time": "Huidige tijd weergeven",
"Use 24 Hour Clock": "24-uurs klok gebruike",
"Show Current Battery Status": "Toon huidige batterijstatus",
"Exported successfully": "Succesvol geëxporteerd",
"Book exported successfully.": "Boek succesvol geëxporteerd.",
"Failed to export the book.": "Exporteren van het boek mislukt.",
@@ -989,10 +990,8 @@
"Play (Space)": "Afspelen (Spatie)",
"Skip forward 15 words": "15 woorden vooruit",
"Forward 15 words (Shift+Right)": "15 woorden vooruit (Shift+Rechts)",
"Pause:": "Pauze:",
"Decrease speed": "Snelheid verlagen",
"Slower (Left/Down)": "Langzamer (Links/Omlaag)",
"Current speed": "Huidige snelheid",
"Increase speed": "Snelheid verhogen",
"Faster (Right/Up)": "Sneller (Rechts/Omhoog)",
"Start RSVP Reading": "Start RSVP-lezen",
@@ -1031,5 +1030,77 @@
"Books": "Boeken",
"Groups": "Groepen",
"Back to TTS Location": "Terug naar TTS-locatie",
"Metadata": "Metadata"
"Metadata": "Metadata",
"Image viewer": "Afbeeldingenviewer",
"Previous Image": "Vorige afbeelding",
"Next Image": "Volgende afbeelding",
"Zoomed": "Gezoomd",
"Zoom level": "Zoomniveau",
"Table viewer": "Tabelviewer",
"Unable to connect to Readwise. Please check your network connection.": "Kan geen verbinding maken met Readwise. Controleer uw netwerkverbinding.",
"Invalid Readwise access token": "Ongeldige Readwise-toegangstoken",
"Disconnected from Readwise": "Verbinding met Readwise verbroken",
"Never": "Nooit",
"Readwise Settings": "Readwise-instellingen",
"Connected to Readwise": "Verbonden met Readwise",
"Last synced: {{time}}": "Laatst gesynchroniseerd: {{time}}",
"Sync Enabled": "Synchronisatie ingeschakeld",
"Disconnect": "Verbinding verbreken",
"Connect your Readwise account to sync highlights.": "Verbind uw Readwise-account om markeringen te synchroniseren.",
"Get your access token at": "Verkrijg uw toegangstoken op",
"Access Token": "Toegangstoken",
"Paste your Readwise access token": "Plak uw Readwise-toegangstoken",
"Config": "Configuratie",
"Readwise Sync": "Readwise-synchronisatie",
"Push Highlights": "Markeringen verzenden",
"Highlights synced to Readwise": "Markeringen gesynchroniseerd met Readwise",
"Readwise sync failed: no internet connection": "Readwise-synchronisatie mislukt: geen internetverbinding",
"Readwise sync failed: {{error}}": "Readwise-synchronisatie mislukt: {{error}}",
"System Screen Brightness": "Systeemhelderheid van het scherm",
"Page:": "Pagina:",
"Page: {{number}}": "Pagina: {{number}}",
"Annotation page number": "Annotatiepaginanummer",
"Translating...": "Vertalen...",
"Show Battery Percentage": "Batterijpercentage weergeven",
"Hide Scrollbar": "Schuifbalk verbergen",
"Skip to last reading position": "Ga naar laatste leespositie",
"Show context": "Context tonen",
"Hide context": "Context verbergen",
"Decrease font size": "Lettergrootte verkleinen",
"Increase font size": "Lettergrootte vergroten",
"Focus": "Focus",
"Theme color": "Themakleur",
"Import failed": "Import mislukt",
"Available Formatters:": "Beschikbare opmaak:",
"Format date": "Datum opmaken",
"Markdown block quote (> per line)": "Markdown-blokcitaat (> per regel)",
"Newlines to <br>": "Regeleinden naar <br>",
"Change case": "Hoofdlettergebruik wijzigen",
"Trim whitespace": "Witruimte verwijderen",
"Truncate to n characters": "Inkorten tot n tekens",
"Replace text": "Tekst vervangen",
"Fallback value": "Terugvalwaarde",
"Get length": "Lengte ophalen",
"First/last element": "Eerste/laatste element",
"Join array": "Array samenvoegen",
"Backup failed: {{error}}": "Back-up mislukt: {{error}}",
"Select Backup": "Selecteer back-up",
"Restore failed: {{error}}": "Herstel mislukt: {{error}}",
"Backup & Restore": "Back-up & Herstel",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Maak een back-up van uw bibliotheek of herstel vanuit een eerdere back-up. Herstel wordt samengevoegd met uw huidige bibliotheek.",
"Backup Library": "Bibliotheek back-uppen",
"Restore Library": "Bibliotheek herstellen",
"Creating backup...": "Back-up maken...",
"Restoring library...": "Bibliotheek herstellen...",
"{{current}} of {{total}} items": "{{current}} van {{total}} items",
"Backup completed successfully!": "Back-up succesvol voltooid!",
"Restore completed successfully!": "Herstel succesvol voltooid!",
"Your library has been saved to the selected location.": "Uw bibliotheek is opgeslagen op de geselecteerde locatie.",
"{{added}} books added, {{updated}} books updated.": "{{added}} boeken toegevoegd, {{updated}} boeken bijgewerkt.",
"Operation failed": "Bewerking mislukt",
"Loading library...": "Bibliotheek laden...",
"{{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"
}
@@ -106,7 +106,6 @@
"Wikipedia": "Wikipedia",
"Writing Mode": "Tryb pisania",
"Your Library": "Twoja biblioteka",
"TTS not supported for PDF": "TTS nie jest obsługiwane dla plików PDF",
"Override Book Font": "Nadpisz czcionkę książki",
"Apply to All Books": "Zastosuj do wszystkich książek",
"Apply to This Book": "Zastosuj do tej książki",
@@ -341,10 +340,10 @@
"Fit": "Pasuje",
"Reset {{settings}}": "Zresetuj {{settings}}",
"Reset Settings": "Zresetuj ustawienia",
"{{count}} pages left in chapter_one": "Pozostała {{count}} strona w rozdziale",
"{{count}} pages left in chapter_few": "Pozostały {{count}} strony w rozdziale",
"{{count}} pages left in chapter_many": "Pozostało {{count}} stron w rozdziale",
"{{count}} pages left in chapter_other": "Pozostało {{count}} stron w rozdziale",
"{{count}} pages left in chapter_one": "<1>Pozostała </1><0>{{count}}</0><1> strona w rozdziale</1>",
"{{count}} pages left in chapter_few": "<1>Pozostały </1><0>{{count}}</0><1> strony w rozdziale</1>",
"{{count}} pages left in chapter_many": "<1>Pozostało </1><0>{{count}}</0><1> stron w rozdziale</1>",
"{{count}} pages left in chapter_other": "<1>Pozostało </1><0>{{count}}</0><1> stron w rozdziale</1>",
"Show Remaining Pages": "Pokaż pozostałe strony",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -607,11 +606,10 @@
"Size": "Rozmiar",
"Cover": "Okładka",
"Contain": "Zawierać",
"{{number}} pages left in chapter": "Pozostało {{number}} stron w rozdziale",
"{{number}} pages left in chapter": "<1>Pozostało </1><0>{{number}}</0><1> stron w rozdziale</1>",
"Device": "Urządzenie",
"E-Ink Mode": "Tryb E-Ink",
"Highlight Colors": "Kolory wyróżnień",
"Auto Screen Brightness": "Automatyczna jasność ekranu",
"Pagination": "Stronicowanie",
"Disable Double Tap": "Wyłącz podwójne dotknięcie",
"Tap to Paginate": "Dotknij, aby stronicować",
@@ -853,6 +851,9 @@
"Clear search": "Wyczyść wyszukiwanie",
"Clear search history": "Wyczyść historię wyszukiwania",
"Tap to Toggle Footer": "Dotknij, aby przełączyć stopkę",
"Show Current Time": "Pokaż aktualny czas",
"Use 24 Hour Clock": "Używaj formatu 24-godzinnego",
"Show Current Battery Status": "Pokaż aktualny stan baterii",
"Exported successfully": "Wyeksportowano pomyślnie",
"Book exported successfully.": "Książka została wyeksportowana pomyślnie.",
"Failed to export the book.": "Nie udało się wyeksportować książki.",
@@ -1013,10 +1014,8 @@
"Play (Space)": "Odtwarzaj (Spacja)",
"Skip forward 15 words": "Naprzód o 15 słów",
"Forward 15 words (Shift+Right)": "Naprzód o 15 słów (Shift+Prawo)",
"Pause:": "Pauza:",
"Decrease speed": "Zmniejsz prędkość",
"Slower (Left/Down)": "Wolniej (Lewo/Dół)",
"Current speed": "Aktualna prędkość",
"Increase speed": "Zwiększ prędkość",
"Faster (Right/Up)": "Szybciej (Prawo/Góra)",
"Start RSVP Reading": "Uruchom czytanie RSVP",
@@ -1055,5 +1054,79 @@
"Books": "Książki",
"Groups": "Grupy",
"Back to TTS Location": "Powrót do lokalizacji TTS",
"Metadata": "Metadane"
"Metadata": "Metadane",
"Image viewer": "Przeglądarka obrazów",
"Previous Image": "Poprzedni obraz",
"Next Image": "Następny obraz",
"Zoomed": "Powiększone",
"Zoom level": "Poziom powiększenia",
"Table viewer": "Przeglądarka tabel",
"Unable to connect to Readwise. Please check your network connection.": "Nie można połączyć się z Readwise. Sprawdź połączenie sieciowe.",
"Invalid Readwise access token": "Nieprawidłowy token dostępu Readwise",
"Disconnected from Readwise": "Rozłączono z Readwise",
"Never": "Nigdy",
"Readwise Settings": "Ustawienia Readwise",
"Connected to Readwise": "Połączono z Readwise",
"Last synced: {{time}}": "Ostatnia synchronizacja: {{time}}",
"Sync Enabled": "Synchronizacja włączona",
"Disconnect": "Rozłącz",
"Connect your Readwise account to sync highlights.": "Połącz swoje konto Readwise, aby synchronizować wyróżnienia.",
"Get your access token at": "Pobierz token dostępu pod adresem",
"Access Token": "Token dostępu",
"Paste your Readwise access token": "Wklej swój token dostępu Readwise",
"Config": "Konfiguracja",
"Readwise Sync": "Synchronizacja Readwise",
"Push Highlights": "Wyślij wyróżnienia",
"Highlights synced to Readwise": "Wyróżnienia zsynchronizowane z Readwise",
"Readwise sync failed: no internet connection": "Synchronizacja Readwise nie powiodła się: brak połączenia z Internetem",
"Readwise sync failed: {{error}}": "Synchronizacja Readwise nie powiodła się: {{error}}",
"System Screen Brightness": "Jasność ekranu systemowego",
"Page:": "Strona:",
"Page: {{number}}": "Strona: {{number}}",
"Annotation page number": "Numer strony adnotacji",
"Translating...": "Tłumaczenie...",
"Show Battery Percentage": "Pokaż procent baterii",
"Hide Scrollbar": "Ukryj pasek przewijania",
"Skip to last reading position": "Przejdź do ostatniej pozycji czytania",
"Show context": "Pokaż kontekst",
"Hide context": "Ukryj kontekst",
"Decrease font size": "Zmniejsz rozmiar czcionki",
"Increase font size": "Zwiększ rozmiar czcionki",
"Focus": "Fokus",
"Theme color": "Kolor motywu",
"Import failed": "Import nie powiódł się",
"Available Formatters:": "Dostępne formatery:",
"Format date": "Formatuj datę",
"Markdown block quote (> per line)": "Cytat blokowy Markdown (> na linię)",
"Newlines to <br>": "Nowe linie na <br>",
"Change case": "Zmień wielkość liter",
"Trim whitespace": "Przytnij spacje",
"Truncate to n characters": "Skróć do n znaków",
"Replace text": "Zamień tekst",
"Fallback value": "Wartość zastępcza",
"Get length": "Pobierz długość",
"First/last element": "Pierwszy/ostatni element",
"Join array": "Połącz tablicę",
"Backup failed: {{error}}": "Kopia zapasowa nie powiodła się: {{error}}",
"Select Backup": "Wybierz kopię zapasową",
"Restore failed: {{error}}": "Przywracanie nie powiodło się: {{error}}",
"Backup & Restore": "Kopia zapasowa i przywracanie",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Utwórz kopię zapasową biblioteki lub przywróć z poprzedniej kopii. Przywracanie zostanie połączone z bieżącą biblioteką.",
"Backup Library": "Kopia zapasowa biblioteki",
"Restore Library": "Przywróć bibliotekę",
"Creating backup...": "Tworzenie kopii zapasowej...",
"Restoring library...": "Przywracanie biblioteki...",
"{{current}} of {{total}} items": "{{current}} z {{total}} elementów",
"Backup completed successfully!": "Kopia zapasowa ukończona!",
"Restore completed successfully!": "Przywracanie ukończone!",
"Your library has been saved to the selected location.": "Biblioteka została zapisana w wybranej lokalizacji.",
"{{added}} books added, {{updated}} books updated.": "Dodano {{added}} książek, zaktualizowano {{updated}} książek.",
"Operation failed": "Operacja nie powiodła się",
"Loading library...": "Wczytywanie biblioteki...",
"{{count}} books refreshed_one": "Odświeżono {{count}} książkę",
"{{count}} books refreshed_few": "Odświeżono {{count}} książki",
"{{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"
}
@@ -106,7 +106,6 @@
"Wikipedia": "Wikipédia",
"Writing Mode": "Modo de Escrita",
"Your Library": "Sua Biblioteca",
"TTS not supported for PDF": "TTS não suportado para PDF",
"Override Book Font": "Sobrescrever Fonte do Livro",
"Apply to All Books": "Aplicar a todos os livros",
"Apply to This Book": "Aplicar a este livro",
@@ -338,9 +337,9 @@
"Fit": "Encaixar",
"Reset {{settings}}": "Redefinir {{settings}}",
"Reset Settings": "Redefinir Configurações",
"{{count}} pages left in chapter_one": "Falta {{count}} página neste capítulo",
"{{count}} pages left in chapter_many": "Faltam {{count}} páginas neste capítulo",
"{{count}} pages left in chapter_other": "Faltam {{count}} páginas neste capítulo",
"{{count}} pages left in chapter_one": "<1>Falta </1><0>{{count}}</0><1> página neste capítulo</1>",
"{{count}} pages left in chapter_many": "<1>Faltam </1><0>{{count}}</0><1> páginas neste capítulo</1>",
"{{count}} pages left in chapter_other": "<1>Faltam </1><0>{{count}}</0><1> páginas neste capítulo</1>",
"Show Remaining Pages": "Mostrar páginas restantes",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -603,11 +602,10 @@
"Size": "Tamanho",
"Cover": "Capa",
"Contain": "Contém",
"{{number}} pages left in chapter": "Faltam {{number}} páginas neste capítulo",
"{{number}} pages left in chapter": "<1>Faltam </1><0>{{number}}</0><1> páginas neste capítulo</1>",
"Device": "Dispositivo",
"E-Ink Mode": "Modo E-Ink",
"Highlight Colors": "Cores de Destaque",
"Auto Screen Brightness": "Brilho Automático da Tela",
"Pagination": "Paginação",
"Disable Double Tap": "Desativar Toque Duplo",
"Tap to Paginate": "Toque para Paginar",
@@ -843,6 +841,9 @@
"Clear search": "Limpar pesquisa",
"Clear search history": "Limpar histórico de pesquisa",
"Tap to Toggle Footer": "Toque para alternar rodapé",
"Show Current Time": "Mostrar hora atual",
"Use 24 Hour Clock": "Usar formato de 24 horas",
"Show Current Battery Status": "Mostrar o estado atual da bateria",
"Exported successfully": "Exportado com sucesso",
"Book exported successfully.": "Livro exportado com sucesso.",
"Failed to export the book.": "Falha ao exportar o livro.",
@@ -1001,10 +1002,8 @@
"Play (Space)": "Reproduzir (Espaço)",
"Skip forward 15 words": "Avançar 15 palavras",
"Forward 15 words (Shift+Right)": "Avançar 15 palavras (Shift+Direita)",
"Pause:": "Pausa:",
"Decrease speed": "Diminuir velocidade",
"Slower (Left/Down)": "Mais lento (Esquerda/Baixo)",
"Current speed": "Velocidade atual",
"Increase speed": "Aumentar velocidade",
"Faster (Right/Up)": "Mais rápido (Direita/Cima)",
"Start RSVP Reading": "Iniciar leitura RSVP",
@@ -1043,5 +1042,78 @@
"Books": "Livros",
"Groups": "Grupos",
"Back to TTS Location": "Voltar para a localização do TTS",
"Metadata": "Metadados"
"Metadata": "Metadados",
"Image viewer": "Visualizador de imagens",
"Previous Image": "Imagem anterior",
"Next Image": "Próxima imagem",
"Zoomed": "Com zoom",
"Zoom level": "Nível de zoom",
"Table viewer": "Visualizador de tabelas",
"Unable to connect to Readwise. Please check your network connection.": "Não foi possível conectar ao Readwise. Por favor, verifique sua conexão de rede.",
"Invalid Readwise access token": "Token de acesso do Readwise inválido",
"Disconnected from Readwise": "Desconectado do Readwise",
"Never": "Nunca",
"Readwise Settings": "Configurações do Readwise",
"Connected to Readwise": "Conectado ao Readwise",
"Last synced: {{time}}": "Última sincronização: {{time}}",
"Sync Enabled": "Sincronização ativada",
"Disconnect": "Desconectar",
"Connect your Readwise account to sync highlights.": "Conecte sua conta do Readwise para sincronizar os destaques.",
"Get your access token at": "Obtenha seu token de acesso em",
"Access Token": "Token de acesso",
"Paste your Readwise access token": "Cole seu token de acesso do Readwise",
"Config": "Configuração",
"Readwise Sync": "Sincronização com o Readwise",
"Push Highlights": "Enviar destaques",
"Highlights synced to Readwise": "Destaques sincronizados com o Readwise",
"Readwise sync failed: no internet connection": "Sincronização com o Readwise falhou: sem conexão com a internet",
"Readwise sync failed: {{error}}": "Sincronização com o Readwise falhou: {{error}}",
"System Screen Brightness": "Brilho da tela do sistema",
"Page:": "Página:",
"Page: {{number}}": "Página: {{number}}",
"Annotation page number": "Número de página da anotação",
"Translating...": "Traduzindo...",
"Show Battery Percentage": "Mostrar porcentagem da bateria",
"Hide Scrollbar": "Ocultar barra de rolagem",
"Skip to last reading position": "Ir para a última posição de leitura",
"Show context": "Mostrar contexto",
"Hide context": "Ocultar contexto",
"Decrease font size": "Diminuir tamanho da fonte",
"Increase font size": "Aumentar tamanho da fonte",
"Focus": "Foco",
"Theme color": "Cor do tema",
"Import failed": "Falha na importação",
"Available Formatters:": "Formatadores disponíveis:",
"Format date": "Formatar data",
"Markdown block quote (> per line)": "Citação Markdown (> por linha)",
"Newlines to <br>": "Quebras de linha para <br>",
"Change case": "Alterar maiúsculas/minúsculas",
"Trim whitespace": "Remover espaços",
"Truncate to n characters": "Truncar para n caracteres",
"Replace text": "Substituir texto",
"Fallback value": "Valor padrão",
"Get length": "Obter comprimento",
"First/last element": "Primeiro/último elemento",
"Join array": "Juntar array",
"Backup failed: {{error}}": "Falha no backup: {{error}}",
"Select Backup": "Selecionar backup",
"Restore failed: {{error}}": "Falha na restauração: {{error}}",
"Backup & Restore": "Backup e restauração",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Crie um backup da sua biblioteca ou restaure a partir de um backup anterior. A restauração será mesclada com sua biblioteca atual.",
"Backup Library": "Fazer backup da biblioteca",
"Restore Library": "Restaurar biblioteca",
"Creating backup...": "Criando backup...",
"Restoring library...": "Restaurando biblioteca...",
"{{current}} of {{total}} items": "{{current}} de {{total}} itens",
"Backup completed successfully!": "Backup concluído com sucesso!",
"Restore completed successfully!": "Restauração concluída com sucesso!",
"Your library has been saved to the selected location.": "Sua biblioteca foi salva no local selecionado.",
"{{added}} books added, {{updated}} books updated.": "{{added}} livros adicionados, {{updated}} livros atualizados.",
"Operation failed": "Operação falhou",
"Loading library...": "Carregando biblioteca...",
"{{count}} books refreshed_one": "{{count}} livro atualizado",
"{{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"
}
@@ -106,7 +106,6 @@
"Wikipedia": "Википедия",
"Writing Mode": "Режим записи",
"Your Library": "Ваша библиотека",
"TTS not supported for PDF": "TTS не поддерживается для PDF",
"Override Book Font": "Переопределить шрифт книги",
"Apply to All Books": "Применить ко всем книгам",
"Apply to This Book": "Применить к этой книге",
@@ -341,10 +340,10 @@
"Fit": "Подогнать",
"Reset {{settings}}": "Сбросить {{settings}}",
"Reset Settings": "Сбросить настройки",
"{{count}} pages left in chapter_one": "Осталась {{count}} страница в главе",
"{{count}} pages left in chapter_few": "Осталось {{count}} страницы в главе",
"{{count}} pages left in chapter_many": "Осталось {{count}} страниц в главе",
"{{count}} pages left in chapter_other": "Осталось {{count}} страниц в главе",
"{{count}} pages left in chapter_one": "<1>Осталась </1><0>{{count}}</0><1> страница в главе</1>",
"{{count}} pages left in chapter_few": "<1>Осталось </1><0>{{count}}</0><1> страницы в главе</1>",
"{{count}} pages left in chapter_many": "<1>Осталось </1><0>{{count}}</0><1> страниц в главе</1>",
"{{count}} pages left in chapter_other": "<1>Осталось </1><0>{{count}}</0><1> страниц в главе</1>",
"Show Remaining Pages": "Показать оставшиеся страницы",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -607,11 +606,10 @@
"Size": "Размер",
"Cover": "Обложка",
"Contain": "Содержать",
"{{number}} pages left in chapter": "Осталось {{number}} страниц в главе",
"{{number}} pages left in chapter": "<1>Осталось </1><0>{{number}}</0><1> страниц в главе</1>",
"Device": "Устройство",
"E-Ink Mode": "E-Ink режим",
"Highlight Colors": "Цвета выделения",
"Auto Screen Brightness": "Автояркость экрана",
"Pagination": "Пагинация",
"Disable Double Tap": "Отключить двойной тап",
"Tap to Paginate": "Тапните, чтобы разбить на страницы",
@@ -853,6 +851,9 @@
"Clear search": "Очистить поиск",
"Clear search history": "Очистить историю поиска",
"Tap to Toggle Footer": "Нажмите для переключения нижнего колонтитула",
"Show Current Time": "Показать текущее время",
"Use 24 Hour Clock": "Использовать 24-часовой формат",
"Show Current Battery Status": "Показать текущее состояние батареи",
"Exported successfully": "Успешно экспортировано",
"Book exported successfully.": "Книга успешно экспортирована.",
"Failed to export the book.": "Не удалось экспортировать книгу.",
@@ -1013,10 +1014,8 @@
"Play (Space)": "Воспроизведение (Пробел)",
"Skip forward 15 words": "Вперед на 15 слов",
"Forward 15 words (Shift+Right)": "Вперед на 15 слов (Shift+Вправо)",
"Pause:": "Пауза:",
"Decrease speed": "Уменьшить скорость",
"Slower (Left/Down)": "Медленнее (Влево/Вниз)",
"Current speed": "Текущая скорость",
"Increase speed": "Увеличить скорость",
"Faster (Right/Up)": "Быстрее (Вправо/Вверх)",
"Start RSVP Reading": "Начать чтение RSVP",
@@ -1055,5 +1054,79 @@
"Books": "Книги",
"Groups": "Группы",
"Back to TTS Location": "Вернуться к местоположению TTS",
"Metadata": "Метаданные"
"Metadata": "Метаданные",
"Image viewer": "Просмотрщик изображений",
"Previous Image": "Предыдущее изображение",
"Next Image": "Следующее изображение",
"Zoomed": "Увеличено",
"Zoom level": "Уровень масштаба",
"Table viewer": "Просмотрщик таблиц",
"Unable to connect to Readwise. Please check your network connection.": "Не удалось подключиться к Readwise. Пожалуйста, проверьте ваше сетевое соединение.",
"Invalid Readwise access token": "Недействительный токен доступа Readwise",
"Disconnected from Readwise": "Отключено от Readwise",
"Never": "Никогда",
"Readwise Settings": "Настройки Readwise",
"Connected to Readwise": "Подключено к Readwise",
"Last synced: {{time}}": "Последняя синхронизация: {{time}}",
"Sync Enabled": "Синхронизация включена",
"Disconnect": "Отключить",
"Connect your Readwise account to sync highlights.": "Подключите ваш аккаунт Readwise для синхронизации выделений.",
"Get your access token at": "Получите ваш токен доступа на",
"Access Token": "Токен доступа",
"Paste your Readwise access token": "Вставьте ваш токен доступа Readwise",
"Config": "Конфигурация",
"Readwise Sync": "Синхронизация Readwise",
"Push Highlights": "Отправить выделения",
"Highlights synced to Readwise": "Выделения синхронизированы с Readwise",
"Readwise sync failed: no internet connection": "Синхронизация Readwise не удалась: нет интернет-соединения",
"Readwise sync failed: {{error}}": "Синхронизация Readwise не удалась: {{error}}",
"System Screen Brightness": "Систেমная яркость экрана",
"Page:": "Страница:",
"Page: {{number}}": "Страница: {{number}}",
"Annotation page number": "Номер страницы аннотации",
"Translating...": "Перевод...",
"Show Battery Percentage": "Показывать процент заряда батареи",
"Hide Scrollbar": "Скрыть полосу прокрутки",
"Skip to last reading position": "Перейти к последней позиции чтения",
"Show context": "Показать контекст",
"Hide context": "Скрыть контекст",
"Decrease font size": "Уменьшить размер шрифта",
"Increase font size": "Увеличить размер шрифта",
"Focus": "Фокус",
"Theme color": "Цвет темы",
"Import failed": "Ошибка импорта",
"Available Formatters:": "Доступные форматеры:",
"Format date": "Форматировать дату",
"Markdown block quote (> per line)": "Цитата Markdown (> для каждой строки)",
"Newlines to <br>": "Переносы строк в <br>",
"Change case": "Изменить регистр",
"Trim whitespace": "Удалить пробелы",
"Truncate to n characters": "Обрезать до n символов",
"Replace text": "Заменить текст",
"Fallback value": "Значение по умолчанию",
"Get length": "Получить длину",
"First/last element": "Первый/последний элемент",
"Join array": "Объединить массив",
"Backup failed: {{error}}": "Ошибка резервного копирования: {{error}}",
"Select Backup": "Выбрать резервную копию",
"Restore failed: {{error}}": "Ошибка восстановления: {{error}}",
"Backup & Restore": "Резервное копирование и восстановление",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Создайте резервную копию библиотеки или восстановите из предыдущей копии. Восстановление будет объединено с текущей библиотекой.",
"Backup Library": "Резервная копия библиотеки",
"Restore Library": "Восстановить библиотеку",
"Creating backup...": "Создание резервной копии...",
"Restoring library...": "Восстановление библиотеки...",
"{{current}} of {{total}} items": "{{current}} из {{total}} элементов",
"Backup completed successfully!": "Резервное копирование завершено!",
"Restore completed successfully!": "Восстановление завершено!",
"Your library has been saved to the selected location.": "Ваша библиотека сохранена в выбранном месте.",
"{{added}} books added, {{updated}} books updated.": "Добавлено {{added}} книг, обновлено {{updated}} книг.",
"Operation failed": "Операция не удалась",
"Loading library...": "Загрузка библиотеки...",
"{{count}} books refreshed_one": "Обновлена {{count}} книга",
"{{count}} books refreshed_few": "Обновлено {{count}} книги",
"{{count}} books refreshed_many": "Обновлено {{count}} книг",
"{{count}} books refreshed_other": "Обновлено {{count}} книг",
"Failed to refresh metadata": "Не удалось обновить метаданные",
"Refresh Metadata": "Обновить метаданные"
}
@@ -186,8 +186,8 @@
"Add your notes here...": "මෙහි ඔබේ සටහන් එක් කරන්න...",
"Search notes and excerpts...": "සටහන් සහ උදාහරණ සොයන්න...",
"{{time}} min left in chapter": "පරිච්ඡේදයේ මිනිත්තු {{time}} ක් ඉතිරි",
"{{count}} pages left in chapter_one": "පරිච්ඡේදයේ පිටුව 1 ක් ඉතිරි",
"{{count}} pages left in chapter_other": "පරිච්ඡේදයේ පිටු {{count}} ක් ඉතිරි",
"{{count}} pages left in chapter_one": "<1>පරිච්ඡේදයේ පිටුව 1 ක් ඉතිරි</1>",
"{{count}} pages left in chapter_other": "<1>පරිච්ඡේදයේ පිටු </1><0>{{count}}</0><1> ක් ඉතිරි</1>",
"Theme Mode": "තේමා ආකාරය",
"Invert Image In Dark Mode": "අඳුරු ආකාරයේදී රූපය පරිවර්තනය කරන්න",
"Override Book Color": "පොතේ වර්ණය අභිබවන්න",
@@ -319,7 +319,6 @@
"Table of Contents": "සූචිය",
"Sidebar": "පැති තීරුව",
"Disable Translation": "පරිවර්තනය අබල කරන්න",
"TTS not supported for PDF": "PDF සඳහා TTS සහාය නොදක්වයි",
"TTS not supported for this document": "මෙම ලේඛනය සඳහා TTS සහාය නොදක්වයි",
"No Timeout": "කාල සීමාවක් නැත",
"{{value}} minute": "මිනිත්තු {{value}}",
@@ -599,11 +598,10 @@
"Size": "ප්‍රමාණය",
"Cover": "ආවරණය",
"Contain": "අඩංගු වන්න",
"{{number}} pages left in chapter": "පරිච්ඡේදයේ පිටු {{number}} ඉතිරිව ඇත",
"{{number}} pages left in chapter": "<1>පරිච්ඡේදයේ පිටු </1><0>{{number}}</0><1> ඉතිරිව ඇත</1>",
"Device": "උපකරණය",
"E-Ink Mode": "E-Ink ආකාරය",
"Highlight Colors": "ඉස්මතු වර්ණ",
"Auto Screen Brightness": "ස්වයංක්‍රීය තිර දිදුලනුම",
"Pagination": "පිටුගත කිරීම",
"Disable Double Tap": "දෙවරක් ටැප් කිරීම අබල කරන්න",
"Tap to Paginate": "පිටුගත කිරීමට ටැප් කරන්න",
@@ -833,6 +831,9 @@
"Clear search": "සෙවුම හිස් කරන්න",
"Clear search history": "සෙවුම් ඉතිහාසය හිස් කරන්න",
"Tap to Toggle Footer": "පාදකය ටොගල් කිරීමට තට්ටු කරන්න",
"Show Current Time": "වත්මන් වේලාව පෙන්වන්න",
"Use 24 Hour Clock": "පැය 24 ඔරලෝසුව භාවිතා කරන්න",
"Show Current Battery Status": "වත්මන් බැටරි තත්ත්වය පෙන්වන්න",
"Exported successfully": "සාර්ථකව අපනයනය කරන ලදී",
"Book exported successfully.": "පොත සාර්ථකව අපනයනය කරන ලදී.",
"Failed to export the book.": "පොත අපනයනය කිරීමට අසමත් විය.",
@@ -989,10 +990,8 @@
"Play (Space)": "ධාවනය (Space)",
"Skip forward 15 words": "වචන 15ක් ඉදිරියට",
"Forward 15 words (Shift+Right)": "වචන 15ක් ඉදිරියට (Shift+Right)",
"Pause:": "විරාමය:",
"Decrease speed": "වේගය අඩු කරන්න",
"Slower (Left/Down)": "මන්දගාමී (Left/Down)",
"Current speed": "වත්මන් වේගය",
"Increase speed": "වේගය වැඩි කරන්න",
"Faster (Right/Up)": "වේගවත් (Right/Up)",
"Start RSVP Reading": "RSVP කියවීම ආරම්භ කරන්න",
@@ -1031,5 +1030,77 @@
"Books": "පොත්",
"Groups": "කණ්ඩායම්",
"Back to TTS Location": "TTS ස්ථානයට ආපසු යන්න",
"Metadata": "මෙටාඩේටා"
"Metadata": "මෙටාඩේටා",
"Image viewer": "රූප නරඹන්නා",
"Previous Image": "පූර්ව රූපය",
"Next Image": "මීළඟ රූපය",
"Zoomed": "විශාලනය කරන ලදී",
"Zoom level": "විශාලන මට්ටම",
"Table viewer": "වගු නරඹන්නා",
"Unable to connect to Readwise. Please check your network connection.": "Readwise වෙත සම්බන්ධ වීමට නොහැක. කරුණාකර ඔබගේ ජාල සම්බන්ධතාවය පරීක්ෂා කරන්න.",
"Invalid Readwise access token": "අවලංගු Readwise ප්‍රවේශ ටෝකනය",
"Disconnected from Readwise": "Readwise වෙතින් විසන්ධි විය",
"Never": "කවදාවත් නැහැ",
"Readwise Settings": "Readwise සැකසුම්",
"Connected to Readwise": "Readwise වෙත සම්බන්ධ විය",
"Last synced: {{time}}": "අවසානයට සමමුහුර්ත කළේ: {{time}}",
"Sync Enabled": "සමමුහුර්ත කිරීම සබල කර ඇත",
"Disconnect": "විසන්ධි කරන්න",
"Connect your Readwise account to sync highlights.": "විශේෂ අවස්ථා සමමුහුර්ත කිරීමට ඔබගේ Readwise ගිණුම සම්බන්ධ කරන්න.",
"Get your access token at": "ඔබගේ ප්‍රවේශ ටෝකනය මෙතැනින් ලබා ගන්න",
"Access Token": "ප්‍රවේශ ටෝකනය",
"Paste your Readwise access token": "ඔබගේ Readwise ප්‍රවේශ ටෝකනය මෙහි අලවන්න",
"Config": "වින්‍යාසය",
"Readwise Sync": "Readwise සමමුහුර්තකරණය",
"Push Highlights": "විශේෂ අවස්ථා යොමු කරන්න",
"Highlights synced to Readwise": "විශේෂ අවස්ථා Readwise සමඟ සමමුහුර්ත විය",
"Readwise sync failed: no internet connection": "Readwise සමමුහුර්තකරණය අසාර්ථක විය: අන්තර්ජාල සම්බන්ධතාවයක් නොමැත",
"Readwise sync failed: {{error}}": "Readwise සමමුහුර්තකරණය අසාර්ථක විය: {{error}}",
"System Screen Brightness": "පද්ධති තිර දීප්තිය",
"Page:": "පිටුව:",
"Page: {{number}}": "පිටුව: {{number}}",
"Annotation page number": "විවරණ පිටු අංකය",
"Translating...": "පරිවර්තනය වෙමින් පවතී...",
"Show Battery Percentage": "බැටරි ප්‍රතිශතය පෙන්වන්න",
"Hide Scrollbar": "අනුචලන තීරුව සඟවන්න",
"Skip to last reading position": "අවසන් කියවීමේ ස්ථානයට යන්න",
"Show context": "සන්දර්භය පෙන්වන්න",
"Hide context": "සන්දර්භය සඟවන්න",
"Decrease font size": "අකුරු ප්‍රමාණය අඩු කරන්න",
"Increase font size": "අකුරු ප්‍රමාණය වැඩි කරන්න",
"Focus": "අවධානය",
"Theme color": "තේමා වර්ණය",
"Import failed": "ආයාත කිරීම අසාර්ථකයි",
"Available Formatters:": "ලබාගත හැකි ආකෘතිකරණ:",
"Format date": "දිනය ආකෘතිකරණය",
"Markdown block quote (> per line)": "Markdown බ්ලොක් උද්ධෘතය (පේළියකට >)",
"Newlines to <br>": "නව පේළි <br> වෙත",
"Change case": "අකුරු ප්‍රමාණය වෙනස් කරන්න",
"Trim whitespace": "හිස්තැන් කපන්න",
"Truncate to n characters": "n අක්ෂර දක්වා කෙටි කරන්න",
"Replace text": "පෙළ ප්‍රතිස්ථාපනය",
"Fallback value": "විකල්ප අගය",
"Get length": "දිග ලබාගන්න",
"First/last element": "පළමු/අවසාන මූලද්‍රව්‍යය",
"Join array": "අරාව සම්බන්ධ කරන්න",
"Backup failed: {{error}}": "උපස්ථ කිරීම අසාර්ථකයි: {{error}}",
"Select Backup": "උපස්ථයක් තෝරන්න",
"Restore failed: {{error}}": "ප්‍රතිසාධනය අසාර්ථකයි: {{error}}",
"Backup & Restore": "උපස්ථ සහ ප්‍රතිසාධනය",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "ඔබේ පුස්තකාලයේ උපස්ථයක් සාදන්න හෝ පෙර උපස්ථයකින් ප්‍රතිසාධනය කරන්න. ප්‍රතිසාධනය ඔබේ වත්මන් පුස්තකාලය සමග ඒකාබද්ධ වේ.",
"Backup Library": "පුස්තකාලය උපස්ථ කරන්න",
"Restore Library": "පුස්තකාලය ප්‍රතිසාධනය කරන්න",
"Creating backup...": "උපස්ථය සාදමින්...",
"Restoring library...": "පුස්තකාලය ප්‍රතිසාධනය කරමින්...",
"{{current}} of {{total}} items": "{{current}} / {{total}} අයිතම",
"Backup completed successfully!": "උපස්ථය සාර්ථකව සම්පූර්ණයි!",
"Restore completed successfully!": "ප්‍රතිසාධනය සාර්ථකව සම්පූර්ණයි!",
"Your library has been saved to the selected location.": "ඔබේ පුස්තකාලය තෝරාගත් ස්ථානයේ සුරැකිණි.",
"{{added}} books added, {{updated}} books updated.": "පොත් {{added}}ක් එකතු විය, {{updated}}ක් යාවත්කාලීන විය.",
"Operation failed": "මෙහෙයුම අසාර්ථකයි",
"Loading library...": "පුස්තකාලය පූරණය වෙමින්...",
"{{count}} books refreshed_one": "පොත් {{count}}ක් යාවත්කාලීන විය",
"{{count}} books refreshed_other": "පොත් {{count}}ක් යාවත්කාලීන විය",
"Failed to refresh metadata": "පාරදත්ත යාවත්කාලීන කිරීම අසාර්ථකයි",
"Refresh Metadata": "පාරදත්ත යාවත්කාලීන කරන්න"
}
File diff suppressed because it is too large Load Diff
@@ -234,8 +234,8 @@
"Add your notes here...": "Lägg till dina anteckningar här...",
"Search notes and excerpts...": "Sök anteckningar och utdrag...",
"{{time}} min left in chapter": "{{time}} min kvar i kapitlet",
"{{count}} pages left in chapter_one": "{{count}} sida kvar i kapitlet",
"{{count}} pages left in chapter_other": "{{count}} sidor kvar i kapitlet",
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> sida kvar i kapitlet</1>",
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> sidor kvar i kapitlet</1>",
"On {{current}} of {{total}} page": "På {{current}} av {{total}} sidor",
"Section Title": "Sektionstitel",
"More Info": "Mer info",
@@ -278,7 +278,6 @@
"Next Paragraph": "Nästa stycke",
"Read Aloud": "Läs upp",
"Ready to read aloud": "Redo att läsa upp",
"TTS not supported for PDF": "Uppläsning stöds inte för PDF",
"TTS not supported for this document": "Uppläsning stöds inte för detta dokument",
"No Timeout": "Ingen timeout",
"{{value}} minute": "{{value}} minut",
@@ -599,11 +598,10 @@
"Size": "Storlek",
"Cover": "Omslag",
"Contain": "Innehålla",
"{{number}} pages left in chapter": "{{number}} sidor kvar i kapitlet",
"{{number}} pages left in chapter": "<0>{{number}}</0><1> sidor kvar i kapitlet</1>",
"Device": "Enhet",
"E-Ink Mode": "E-Ink-läge",
"Highlight Colors": "Markeringsfärger",
"Auto Screen Brightness": "Auto-skärmens ljusstyrka",
"Pagination": "Pagination",
"Disable Double Tap": "Inaktivera dubbeltryck",
"Tap to Paginate": "Tryck för att bläddra",
@@ -833,6 +831,9 @@
"Clear search": "Rensa sökning",
"Clear search history": "Rensa sökhistorik",
"Tap to Toggle Footer": "Tryck för att växla sidfot",
"Show Current Time": "Visa aktuell tid",
"Use 24 Hour Clock": "Använd 24-timmarsklocka",
"Show Current Battery Status": "Visa aktuell batteristatus",
"Exported successfully": "Exporterad",
"Book exported successfully.": "Boken har exporterats.",
"Failed to export the book.": "Det gick inte att exportera boken.",
@@ -989,10 +990,8 @@
"Play (Space)": "Spela (Mellanslag)",
"Skip forward 15 words": "Hoppa framåt 15 ord",
"Forward 15 words (Shift+Right)": "Framåt 15 ord (Shift+Höger)",
"Pause:": "Paus:",
"Decrease speed": "Sänk hastigheten",
"Slower (Left/Down)": "Långsammare (Vänster/Nedåt)",
"Current speed": "Nuvarande hastighet",
"Increase speed": "Höj hastigheten",
"Faster (Right/Up)": "Snabbare (Höger/Uppåt)",
"Start RSVP Reading": "Starta RSVP-läsning",
@@ -1031,5 +1030,77 @@
"Books": "Böcker",
"Groups": "Grupper",
"Back to TTS Location": "Tillbaka till TTS-plats",
"Metadata": "Metadata"
"Metadata": "Metadata",
"Image viewer": "Bildvisare",
"Previous Image": "Föregående bild",
"Next Image": "Nästa bild",
"Zoomed": "Zoomad",
"Zoom level": "Zoomnivå",
"Table viewer": "Tabellvisare",
"Unable to connect to Readwise. Please check your network connection.": "Kunde inte ansluta till Readwise. Kontrollera din nätverksanslutning.",
"Invalid Readwise access token": "Ogiltig Readwise-åtkomsttoken",
"Disconnected from Readwise": "Frånkopplad från Readwise",
"Never": "Aldrig",
"Readwise Settings": "Readwise-inställningar",
"Connected to Readwise": "Ansluten till Readwise",
"Last synced: {{time}}": "Senast synkroniserad: {{time}}",
"Sync Enabled": "Synkronisering aktiverad",
"Disconnect": "Koppla från",
"Connect your Readwise account to sync highlights.": "Anslut ditt Readwise-konto för att synkronisera markeringar.",
"Get your access token at": "Hämta din åtkomsttoken på",
"Access Token": "Åtkomsttoken",
"Paste your Readwise access token": "Klistra in din Readwise-åtkomsttoken",
"Config": "Konfiguration",
"Readwise Sync": "Readwise-synkronisering",
"Push Highlights": "Skicka markeringar",
"Highlights synced to Readwise": "Markeringar synkroniserade till Readwise",
"Readwise sync failed: no internet connection": "Readwise-synkronisering misslyckades: ingen internetanslutning",
"Readwise sync failed: {{error}}": "Readwise-synkronisering misslyckades: {{error}}",
"System Screen Brightness": "Systemets skärmljusstyrka",
"Page:": "Sida:",
"Page: {{number}}": "Sida: {{number}}",
"Annotation page number": "Annoteringssidnummer",
"Translating...": "Översätter...",
"Show Battery Percentage": "Visa batteriprocent",
"Hide Scrollbar": "Dölj rullningslist",
"Skip to last reading position": "Hoppa till senaste läsposition",
"Show context": "Visa kontext",
"Hide context": "Dölj kontext",
"Decrease font size": "Minska teckenstorlek",
"Increase font size": "Öka teckenstorlek",
"Focus": "Fokus",
"Theme color": "Temafärg",
"Import failed": "Importen misslyckades",
"Available Formatters:": "Tillgängliga formaterare:",
"Format date": "Formatera datum",
"Markdown block quote (> per line)": "Markdown-blockcitat (> per rad)",
"Newlines to <br>": "Radbrytningar till <br>",
"Change case": "Ändra skiftläge",
"Trim whitespace": "Ta bort blanksteg",
"Truncate to n characters": "Korta till n tecken",
"Replace text": "Ersätt text",
"Fallback value": "Standardvärde",
"Get length": "Hämta längd",
"First/last element": "Första/sista elementet",
"Join array": "Sammanfoga array",
"Backup failed: {{error}}": "Säkerhetskopiering misslyckades: {{error}}",
"Select Backup": "Välj säkerhetskopia",
"Restore failed: {{error}}": "Återställning misslyckades: {{error}}",
"Backup & Restore": "Säkerhetskopiera & Återställ",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Skapa en säkerhetskopia av ditt bibliotek eller återställ från en tidigare kopia. Återställningen slås samman med ditt nuvarande bibliotek.",
"Backup Library": "Säkerhetskopiera bibliotek",
"Restore Library": "Återställ bibliotek",
"Creating backup...": "Skapar säkerhetskopia...",
"Restoring library...": "Återställer bibliotek...",
"{{current}} of {{total}} items": "{{current}} av {{total}} objekt",
"Backup completed successfully!": "Säkerhetskopieringen slutförd!",
"Restore completed successfully!": "Återställningen slutförd!",
"Your library has been saved to the selected location.": "Ditt bibliotek har sparats på den valda platsen.",
"{{added}} books added, {{updated}} books updated.": "{{added}} böcker tillagda, {{updated}} böcker uppdaterade.",
"Operation failed": "Åtgärden misslyckades",
"Loading library...": "Laddar bibliotek...",
"{{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"
}
@@ -186,8 +186,8 @@
"Add your notes here...": "உங்கள் குறிப்புகளை இங்கே சேர்க்கவும்...",
"Search notes and excerpts...": "குறிப்புகள் மற்றும் உதாரணங்களைத் தேடவும்...",
"{{time}} min left in chapter": "அத்தியாயத்தில் {{time}} நிமிடங்கள் மீதமுள்ளன",
"{{count}} pages left in chapter_one": "அத்தியாயத்தில் 1 பக்கம் மீதமுள்ளது",
"{{count}} pages left in chapter_other": "அத்தியாயத்தில் {{count}} பக்கங்கள் மீதமுள்ளன",
"{{count}} pages left in chapter_one": "<1>அத்தியாயத்தில் 1 பக்கம் மீதமுள்ளது</1>",
"{{count}} pages left in chapter_other": "<1>அத்தியாயத்தில் </1><0>{{count}}</0><1> பக்கங்கள் மீதமுள்ளன</1>",
"Theme Mode": "தீம் பயன்முறை",
"Invert Image In Dark Mode": "இருட்டு பயன்முறையில் படத்தை தலைகீழாக மாற்றவும்",
"Override Book Color": "புத்தக நிறத்தை மேலெழுதவும்",
@@ -319,7 +319,6 @@
"Table of Contents": "உள்ளடக்கம்",
"Sidebar": "பக்கப்பட்டி",
"Disable Translation": "மொழிபெயர்ப்பை முடக்கவும்",
"TTS not supported for PDF": "PDF க்கு TTS ஆதரிக்கப்படவில்லை",
"TTS not supported for this document": "இந்த ஆவணத்திற்கு TTS ஆதரிக்கப்படவில்லை",
"No Timeout": "நேர வரம்பு இல்லை",
"{{value}} minute": "{{value}} நிமிடம்",
@@ -599,11 +598,10 @@
"Size": "அளவு",
"Cover": "மூடு",
"Contain": "அடங்கும்",
"{{number}} pages left in chapter": "அத்தியாயத்தில் {{number}} பக்கங்கள் மீதமுள்ளன",
"{{number}} pages left in chapter": "<1>அத்தியாயத்தில் </1><0>{{number}}</0><1> பக்கங்கள் மீதமுள்ளன</1>",
"Device": "சாதனம்",
"E-Ink Mode": "E-Ink முறை",
"Highlight Colors": "முத்திரை நிறங்கள்",
"Auto Screen Brightness": "தானியங்கி திரை பிரகாசம்",
"Pagination": "பக்கமிடுதல்",
"Disable Double Tap": "இரட்டை தொடுதலை முடக்கு",
"Tap to Paginate": "பக்கம் மாற்ற தொடுங்கள்",
@@ -833,6 +831,9 @@
"Clear search": "தேடலை அழி",
"Clear search history": "தேடல் வரலாற்றை அழி",
"Tap to Toggle Footer": "அடிக்குறிப்பை மாற்ற தட்டவும்",
"Show Current Time": "தற்போதைய நேரத்தைக் காண்பி",
"Use 24 Hour Clock": "24 மணிநேர கடிகாரத்தைப் பயன்படுத்து",
"Show Current Battery Status": "தற்போதைய பேட்டரி நிலையைக் காண்பி",
"Exported successfully": "ஏற்றுமதி வெற்றி",
"Book exported successfully.": "புத்தகம் வெற்றிகரமாக ஏற்றுமதி செய்யப்பட்டது.",
"Failed to export the book.": "புத்தகத்தை ஏற்றுமதி செய்ய இயலவில்லை.",
@@ -989,10 +990,8 @@
"Play (Space)": "இயக்கு (Space)",
"Skip forward 15 words": "15 வார்த்தைகள் முன்னால் செல்",
"Forward 15 words (Shift+Right)": "15 வார்த்தைகள் முன்னால் செல் (Shift+Right)",
"Pause:": "நிறுத்து:",
"Decrease speed": "வேகத்தைக் குறை",
"Slower (Left/Down)": "மெதுவாக (Left/Down)",
"Current speed": "தற்போதைய வேகம்",
"Increase speed": "வேகத்தை அதிகரி",
"Faster (Right/Up)": "வேகமாக (Right/Up)",
"Start RSVP Reading": "RSVP வாசிப்பைத் தொடங்கு",
@@ -1031,5 +1030,77 @@
"Books": "புத்தகங்கள்",
"Groups": "குழுக்கள்",
"Back to TTS Location": "TTS இடத்திற்குத் திரும்பு",
"Metadata": "மெட்டாடேட்டா"
"Metadata": "மெட்டாடேட்டா",
"Image viewer": "படக் காட்சிப்பவர்",
"Previous Image": "முந்தைய படம்",
"Next Image": "அடுத்த படம்",
"Zoomed": "பெரிதாக்கப்பட்டது",
"Zoom level": "பெரிதாக்கும் நிலை",
"Table viewer": "அட்டவணை காட்சிப்பவர்",
"Unable to connect to Readwise. Please check your network connection.": "Readwise உடன் இணைக்க முடியவில்லை. உங்கள் நெட்வொர்க் இணைப்பைச் சரிபார்க்கவும்.",
"Invalid Readwise access token": "தவறான Readwise அணுகல் டோக்கன்",
"Disconnected from Readwise": "Readwise இலிருந்து துண்டிக்கப்பட்டது",
"Never": "ஒருபோதும் இல்லை",
"Readwise Settings": "Readwise அமைப்புகள்",
"Connected to Readwise": "Readwise உடன் இணைக்கப்பட்டது",
"Last synced: {{time}}": "கடைசியாக ஒத்திசைக்கப்பட்டது: {{time}}",
"Sync Enabled": "ஒத்திசைவு இயக்கப்பட்டது",
"Disconnect": "துண்டி",
"Connect your Readwise account to sync highlights.": "சிறப்பம்சங்களை ஒத்திசைக்க உங்கள் Readwise கணக்கை இணைக்கவும்.",
"Get your access token at": "உங்கள் அணுகல் டோக்கனை இங்கே பெறவும்",
"Access Token": "அணுகல் டோக்கன்",
"Paste your Readwise access token": "உங்கள் Readwise அணுகல் டோக்கனை ஒட்டவும்",
"Config": "கட்டமைப்பு",
"Readwise Sync": "Readwise ஒத்திசைவு",
"Push Highlights": "சிறப்பம்சங்களை அனுப்பு",
"Highlights synced to Readwise": "சிறப்பம்சங்கள் Readwise உடன் ஒத்திசைக்கப்பட்டன",
"Readwise sync failed: no internet connection": "Readwise ஒத்திசைவு தோல்வியடைந்தது: இணைய இணைப்பு இல்லை",
"Readwise sync failed: {{error}}": "Readwise ஒத்திசைவு தோல்வியடைந்தது: {{error}}",
"System Screen Brightness": "கணினித் திரை பிரகாசம்",
"Page:": "பக்கம்:",
"Page: {{number}}": "பக்கம்: {{number}}",
"Annotation page number": "சிறுகுறிப்புப் பக்க எண்",
"Translating...": "மொழிபெயர்க்கிறது...",
"Show Battery Percentage": "பேட்டரி சதவீதத்தைக் காண்பி",
"Hide Scrollbar": "உருள்பட்டையை மறை",
"Skip to last reading position": "கடைசி வாசிப்பு நிலைக்குச் செல்",
"Show context": "சூழலைக் காட்டு",
"Hide context": "சூழலை மறை",
"Decrease font size": "எழுத்துரு அளவைக் குறை",
"Increase font size": "எழுத்துரு அளவை அதிகரி",
"Focus": "குவியம்",
"Theme color": "தீம் நிறம்",
"Import failed": "இறக்குமதி தோல்வி",
"Available Formatters:": "கிடைக்கும் வடிவமைப்பிகள்:",
"Format date": "தேதி வடிவமைப்பு",
"Markdown block quote (> per line)": "Markdown மேற்கோள் தொகுதி (ஒவ்வொரு வரிக்கும் >)",
"Newlines to <br>": "புதிய வரிகளை <br> ஆக",
"Change case": "எழுத்து வகை மாற்றம்",
"Trim whitespace": "வெற்றிடங்களை நீக்கு",
"Truncate to n characters": "n எழுத்துகளுக்கு சுருக்கு",
"Replace text": "உரையை மாற்று",
"Fallback value": "இயல்புநிலை மதிப்பு",
"Get length": "நீளத்தைப் பெறு",
"First/last element": "முதல்/கடைசி உறுப்பு",
"Join array": "அணியை இணை",
"Backup failed: {{error}}": "காப்புப்பிரதி தோல்வி: {{error}}",
"Select Backup": "காப்புப்பிரதியைத் தேர்ந்தெடுக்கவும்",
"Restore failed: {{error}}": "மீட்டமைப்பு தோல்வி: {{error}}",
"Backup & Restore": "காப்புப்பிரதி & மீட்டமைப்பு",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "உங்கள் நூலகத்தின் காப்புப்பிரதியை உருவாக்கவும் அல்லது முந்தைய காப்புப்பிரதியிலிருந்து மீட்டமைக்கவும். மீட்டமைப்பு உங்கள் தற்போதைய நூலகத்துடன் இணைக்கப்படும்.",
"Backup Library": "நூலகத்தை காப்புப்பிரதி எடு",
"Restore Library": "நூலகத்தை மீட்டமை",
"Creating backup...": "காப்புப்பிரதி உருவாக்கப்படுகிறது...",
"Restoring library...": "நூலகம் மீட்டமைக்கப்படுகிறது...",
"{{current}} of {{total}} items": "{{current}} / {{total}} உருப்படிகள்",
"Backup completed successfully!": "காப்புப்பிரதி வெற்றிகரமாக முடிந்தது!",
"Restore completed successfully!": "மீட்டமைப்பு வெற்றிகரமாக முடிந்தது!",
"Your library has been saved to the selected location.": "உங்கள் நூலகம் தேர்ந்தெடுக்கப்பட்ட இடத்தில் சேமிக்கப்பட்டது.",
"{{added}} books added, {{updated}} books updated.": "{{added}} புத்தகங்கள் சேர்க்கப்பட்டன, {{updated}} புத்தகங்கள் புதுப்பிக்கப்பட்டன.",
"Operation failed": "செயல்பாடு தோல்வி",
"Loading library...": "நூலகம் ஏற்றப்படுகிறது...",
"{{count}} books refreshed_one": "{{count}} புத்தகம் புதுப்பிக்கப்பட்டது",
"{{count}} books refreshed_other": "{{count}} புத்தகங்கள் புதுப்பிக்கப்பட்டன",
"Failed to refresh metadata": "மெட்டாடேட்டா புதுப்பிப்பு தோல்வி",
"Refresh Metadata": "மெட்டாடேட்டா புதுப்பிக்கவும்"
}
@@ -153,7 +153,7 @@
"Add your notes here...": "เพิ่มบันทึกที่นี่...",
"Search notes and excerpts...": "ค้นหาบันทึกและข้อความ...",
"{{time}} min left in chapter": "เหลือ {{time}} นาทีในบท",
"{{count}} pages left in chapter_other": "เหลือ {{count}} หน้าในบท",
"{{count}} pages left in chapter_other": "<1>เหลือ </1><0>{{count}}</0><1> หน้าในบท</1>",
"Theme Mode": "โหมดธีม",
"Invert Image In Dark Mode": "กลับสีรูปในโหมดมืด",
"Override Book Color": "เขียนทับสีหนังสือ",
@@ -269,7 +269,6 @@
"Table of Contents": "สารบัญ",
"Sidebar": "แถบข้าง",
"Disable Translation": "ปิดการแปล",
"TTS not supported for PDF": "ไม่รองรับ TTS สำหรับ PDF",
"No Timeout": "ไม่จำกัดเวลา",
"{{value}} minute": "{{value}} นาที",
"{{value}} minutes": "{{value}} นาที",
@@ -595,11 +594,10 @@
"Size": "ขนาด",
"Cover": "ปก",
"Contain": "บรรจุ",
"{{number}} pages left in chapter": "เหลือ {{number}} หน้าในบท",
"{{number}} pages left in chapter": "<1>เหลือ </1><0>{{number}}</0><1> หน้าในบท</1>",
"Device": "อุปกรณ์",
"E-Ink Mode": "โหมด E-Ink",
"Highlight Colors": "สีไฮไลต์",
"Auto Screen Brightness": "ความสว่างหน้าจออัตโนมัติ",
"Pagination": "การแบ่งหน้า",
"Disable Double Tap": "ปิดการแตะสองครั้ง",
"Tap to Paginate": "แตะเพื่อแบ่งหน้า",
@@ -823,6 +821,9 @@
"Clear search": "ล้างการค้นหา",
"Clear search history": "ล้างประวัติการค้นหา",
"Tap to Toggle Footer": "แตะเพื่อสลับส่วนท้าย",
"Show Current Time": "แสดงเวลาปัจจุบัน",
"Use 24 Hour Clock": "ใช้รูปแบบเวลา 24 ชั่วโมง",
"Show Current Battery Status": "แสดงสถานะแบตเตอรี่ปัจจุบัน",
"Exported successfully": "ส่งออกสำเร็จ",
"Book exported successfully.": "ส่งออกหนังสือสำเร็จ",
"Failed to export the book.": "ส่งออกหนังสือล้มเหลว",
@@ -977,10 +978,8 @@
"Play (Space)": "เล่น (Space)",
"Skip forward 15 words": "ไปข้างหน้า 15 คำ",
"Forward 15 words (Shift+Right)": "ไปข้างหน้า 15 คำ (Shift+ขวา)",
"Pause:": "หยุดชั่วคราว:",
"Decrease speed": "ลดความเร็ว",
"Slower (Left/Down)": "ช้าลง (ซ้าย/ลง)",
"Current speed": "ความเร็วปัจจุบัน",
"Increase speed": "เพิ่มความเร็ว",
"Faster (Right/Up)": "เร็วขึ้น (ขวา/ขึ้น)",
"Start RSVP Reading": "เริ่มการอ่านแบบ RSVP",
@@ -1019,5 +1018,76 @@
"Books": "หนังสือ",
"Groups": "กลุ่ม",
"Back to TTS Location": "กลับไปยังตำแหน่ง TTS",
"Metadata": "เมทาดาตา"
"Metadata": "เมทาดาตา",
"Image viewer": "ตัวดูรูปภาพ",
"Previous Image": "รูปภาพก่อนหน้า",
"Next Image": "รูปภาพถัดไป",
"Zoomed": "ซูมแล้ว",
"Zoom level": "ระดับการซูม",
"Table viewer": "ตัวดูตาราง",
"Unable to connect to Readwise. Please check your network connection.": "ไม่สามารถเชื่อมต่อกับ Readwise ได้ โปรดตรวจสอบการเชื่อมต่ออินเทอร์เน็ตของคุณ",
"Invalid Readwise access token": "โทเคนการเข้าถึง Readwise ไม่ถูกต้อง",
"Disconnected from Readwise": "ยกเลิกการเชื่อมต่อกับ Readwise แล้ว",
"Never": "ไม่เคย",
"Readwise Settings": "การตั้งค่า Readwise",
"Connected to Readwise": "เชื่อมต่อกับ Readwise แล้ว",
"Last synced: {{time}}": "ซิงค์ล่าสุดเมื่อ: {{time}}",
"Sync Enabled": "เปิดใช้งานการซิงค์",
"Disconnect": "ยกเลิกการเชื่อมต่อ",
"Connect your Readwise account to sync highlights.": "เชื่อมต่อบัญชี Readwise ของคุณเพื่อซิงค์ไฮไลต์",
"Get your access token at": "รับโทเคนการเข้าถึงของคุณได้ที่",
"Access Token": "โทเคนการเข้าถึง",
"Paste your Readwise access token": "วางโทเคนการเข้าถึง Readwise ของคุณ",
"Config": "การกำหนดค่า",
"Readwise Sync": "การซิงค์ Readwise",
"Push Highlights": "พุชไฮไลต์",
"Highlights synced to Readwise": "ซิงค์ไฮไลต์ไปยัง Readwise แล้ว",
"Readwise sync failed: no internet connection": "การซิงค์ Readwise ล้มเหลว: ไม่มีการเชื่อมต่ออินเทอร์เน็ต",
"Readwise sync failed: {{error}}": "การซิงค์ Readwise ล้มเหลว: {{error}}",
"System Screen Brightness": "ความสว่างหน้าจอระบบ",
"Page:": "หน้า:",
"Page: {{number}}": "หน้า: {{number}}",
"Annotation page number": "หมายเลขหน้าคำอธิบายประกอบ",
"Translating...": "กำลังแปล...",
"Show Battery Percentage": "แสดงเปอร์เซ็นต์แบตเตอรี่",
"Hide Scrollbar": "ซ่อนแถบเลื่อน",
"Skip to last reading position": "ข้ามไปตำแหน่งอ่านล่าสุด",
"Show context": "แสดงบริบท",
"Hide context": "ซ่อนบริบท",
"Decrease font size": "ลดขนาดตัวอักษร",
"Increase font size": "เพิ่มขนาดตัวอักษร",
"Focus": "โฟกัส",
"Theme color": "สีธีม",
"Import failed": "นำเข้าล้มเหลว",
"Available Formatters:": "ตัวจัดรูปแบบที่ใช้ได้:",
"Format date": "จัดรูปแบบวันที่",
"Markdown block quote (> per line)": "บล็อกอ้างอิง Markdown (> ต่อบรรทัด)",
"Newlines to <br>": "ขึ้นบรรทัดใหม่เป็น <br>",
"Change case": "เปลี่ยนตัวพิมพ์",
"Trim whitespace": "ตัดช่องว่าง",
"Truncate to n characters": "ตัดให้เหลือ n ตัวอักษร",
"Replace text": "แทนที่ข้อความ",
"Fallback value": "ค่าสำรอง",
"Get length": "ดึงความยาว",
"First/last element": "องค์ประกอบแรก/สุดท้าย",
"Join array": "รวมอาร์เรย์",
"Backup failed: {{error}}": "การสำรองข้อมูลล้มเหลว: {{error}}",
"Select Backup": "เลือกข้อมูลสำรอง",
"Restore failed: {{error}}": "การกู้คืนล้มเหลว: {{error}}",
"Backup & Restore": "สำรองและกู้คืน",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "สร้างข้อมูลสำรองของห้องสมุดหรือกู้คืนจากข้อมูลสำรองก่อนหน้า การกู้คืนจะรวมกับห้องสมุดปัจจุบันของคุณ",
"Backup Library": "สำรองห้องสมุด",
"Restore Library": "กู้คืนห้องสมุด",
"Creating backup...": "กำลังสร้างข้อมูลสำรอง...",
"Restoring library...": "กำลังกู้คืนห้องสมุด...",
"{{current}} of {{total}} items": "{{current}} จาก {{total}} รายการ",
"Backup completed successfully!": "สำรองข้อมูลสำเร็จ!",
"Restore completed successfully!": "กู้คืนสำเร็จ!",
"Your library has been saved to the selected location.": "ห้องสมุดของคุณถูกบันทึกไปยังตำแหน่งที่เลือก",
"{{added}} books added, {{updated}} books updated.": "เพิ่ม {{added}} เล่ม, อัปเดต {{updated}} เล่ม",
"Operation failed": "การดำเนินการล้มเหลว",
"Loading library...": "กำลังโหลดห้องสมุด...",
"{{count}} books refreshed_other": "รีเฟรชแล้ว {{count}} เล่ม",
"Failed to refresh metadata": "รีเฟรชข้อมูลเมตาล้มเหลว",
"Refresh Metadata": "รีเฟรชข้อมูลเมตา"
}
@@ -106,7 +106,6 @@
"Wikipedia": "Vikipedi",
"Writing Mode": "Yazma Modu",
"Your Library": "Kütüphaneniz",
"TTS not supported for PDF": "PDF için TTS desteklenmiyor",
"Override Book Font": "Kitap Yazı Tipini Geçersiz Kıl",
"Apply to All Books": "Tüm kitaplara uygula",
"Apply to This Book": "Bu kitaba uygula",
@@ -335,8 +334,8 @@
"Fit": "Uygun",
"Reset {{settings}}": "{{settings}}'i Sıfırla",
"Reset Settings": "Ayarları Sıfırla",
"{{count}} pages left in chapter_one": "Bu bölümde {{count}} sayfa kaldı",
"{{count}} pages left in chapter_other": "Bu bölümde {{count}} sayfa kaldı",
"{{count}} pages left in chapter_one": "<1>Bu bölümde </1><0>{{count}}</0><1> sayfa kaldı</1>",
"{{count}} pages left in chapter_other": "<1>Bu bölümde </1><0>{{count}}</0><1> sayfa kaldı</1>",
"Show Remaining Pages": "Kalan sayfaları göster",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -599,11 +598,10 @@
"Size": "Boyut",
"Cover": "Kapak",
"Contain": "İçerir",
"{{number}} pages left in chapter": "Bu bölümde {{number}} sayfa kaldı",
"{{number}} pages left in chapter": "<1>Bu bölümde </1><0>{{number}}</0><1> sayfa kaldı</1>",
"Device": "Cihaz",
"E-Ink Mode": "E-Ink Modu",
"Highlight Colors": "Vurgu Renkleri",
"Auto Screen Brightness": "Otomatik Ekran Parlaklığı",
"Pagination": "Sayfalama",
"Disable Double Tap": "Çift Dokunmayı Devre Dışı Bırak",
"Tap to Paginate": "Sayfalamak için Dokunun",
@@ -833,6 +831,9 @@
"Clear search": "Aramayı temizle",
"Clear search history": "Arama geçmişini temizle",
"Tap to Toggle Footer": "Altbilgiyi değiştirmek için dokunun",
"Show Current Time": "Şu anki saati göster",
"Use 24 Hour Clock": "24 Saatlik Saati Kullan",
"Show Current Battery Status": "Mevcut Pil Durumunu Göster",
"Exported successfully": "Başarıyla dışa aktarıldı",
"Book exported successfully.": "Kitap başarıyla dışa aktarıldı.",
"Failed to export the book.": "Kitap dışa aktarılamadı.",
@@ -989,10 +990,8 @@
"Play (Space)": "Oynat (Boşluk)",
"Skip forward 15 words": "15 kelime ileri atla",
"Forward 15 words (Shift+Right)": "15 kelime ileri (Shift+Sağ)",
"Pause:": "Duraklat:",
"Decrease speed": "Hızı azalt",
"Slower (Left/Down)": "Daha yavaş (Sol/Aşağı)",
"Current speed": "Mevcut hız",
"Increase speed": "Hızı artır",
"Faster (Right/Up)": "Daha hızlı (Sağ/Yukarı)",
"Start RSVP Reading": "RSVP Okumasını Başlat",
@@ -1031,5 +1030,77 @@
"Books": "Kitaplar",
"Groups": "Gruplar",
"Back to TTS Location": "TTS Konumuna Geri Dön",
"Metadata": "Meta veriler"
"Metadata": "Meta veriler",
"Image viewer": "Resim görüntüleyici",
"Previous Image": "Önceki Resim",
"Next Image": "Sonraki Resim",
"Zoomed": "Yakınlaştırıldı",
"Zoom level": "Yakınlaştırma düzeyi",
"Table viewer": "Tablo görüntüleyici",
"Unable to connect to Readwise. Please check your network connection.": "Readwise'a bağlanılamadı. Lütfen ağ bağlantınızı kontrol edin.",
"Invalid Readwise access token": "Geçersiz Readwise erişim kodu",
"Disconnected from Readwise": "Readwise bağlantısı kesildi",
"Never": "Asla",
"Readwise Settings": "Readwise Ayarları",
"Connected to Readwise": "Readwise'a Bağlandı",
"Last synced: {{time}}": "Son senkronizasyon: {{time}}",
"Sync Enabled": "Senkronizasyon Etkin",
"Disconnect": "Bağlantıyı Kes",
"Connect your Readwise account to sync highlights.": "Vurguları senkronize etmek için Readwise hesabınızı bağlayın.",
"Get your access token at": "Erişim kodunuzu şuradan alın:",
"Access Token": "Erişim Kodu",
"Paste your Readwise access token": "Readwise erişim kodunuzu yapıştırın",
"Config": "Yapılandırma",
"Readwise Sync": "Readwise Senkronizasyonu",
"Push Highlights": "Vurguları Gönder",
"Highlights synced to Readwise": "Vurgular Readwise ile senkronize edildi",
"Readwise sync failed: no internet connection": "Readwise senkronizasyonu başarısız: internet bağlantısı yok",
"Readwise sync failed: {{error}}": "Readwise senkronizasyonu başarısız: {{error}}",
"System Screen Brightness": "Sistem Ekran Parlaklığı",
"Page:": "Sayfa:",
"Page: {{number}}": "Sayfa: {{number}}",
"Annotation page number": "Açıklama sayfa numarası",
"Translating...": "Çevriliyor...",
"Show Battery Percentage": "Pil Yüzdesini Göster",
"Hide Scrollbar": "Kaydırma çubuğunu gizle",
"Skip to last reading position": "Son okuma konumuna atla",
"Show context": "Bağlamı göster",
"Hide context": "Bağlamı gizle",
"Decrease font size": "Yazı tipi boyutunu küçült",
"Increase font size": "Yazı tipi boyutunu büyüt",
"Focus": "Odak",
"Theme color": "Tema rengi",
"Import failed": "İçe aktarma başarısız",
"Available Formatters:": "Kullanılabilir Biçimlendiriciler:",
"Format date": "Tarih biçimlendir",
"Markdown block quote (> per line)": "Markdown blok alıntı (satır başına >)",
"Newlines to <br>": "Satır sonlarını <br> yap",
"Change case": "Büyük/küçük harf değiştir",
"Trim whitespace": "Boşlukları kırp",
"Truncate to n characters": "n karaktere kısalt",
"Replace text": "Metni değiştir",
"Fallback value": "Yedek değer",
"Get length": "Uzunluk al",
"First/last element": "İlk/son öğe",
"Join array": "Diziyi birleştir",
"Backup failed: {{error}}": "Yedekleme başarısız: {{error}}",
"Select Backup": "Yedek Seç",
"Restore failed: {{error}}": "Geri yükleme başarısız: {{error}}",
"Backup & Restore": "Yedekleme & Geri Yükleme",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Kütüphanenizin yedeğini oluşturun veya önceki bir yedekten geri yükleyin. Geri yükleme mevcut kütüphanenizle birleştirilecektir.",
"Backup Library": "Kütüphaneyi Yedekle",
"Restore Library": "Kütüphaneyi Geri Yükle",
"Creating backup...": "Yedek oluşturuluyor...",
"Restoring library...": "Kütüphane geri yükleniyor...",
"{{current}} of {{total}} items": "{{current}} / {{total}} öğe",
"Backup completed successfully!": "Yedekleme başarıyla tamamlandı!",
"Restore completed successfully!": "Geri yükleme başarıyla tamamlandı!",
"Your library has been saved to the selected location.": "Kütüphaneniz seçilen konuma kaydedildi.",
"{{added}} books added, {{updated}} books updated.": "{{added}} kitap eklendi, {{updated}} kitap güncellendi.",
"Operation failed": "İşlem başarısız",
"Loading library...": "Kütüphane yükleniyor...",
"{{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"
}
@@ -106,7 +106,6 @@
"Wikipedia": "Вікіпедія",
"Writing Mode": "Режим письма",
"Your Library": "Ваша бібліотека",
"TTS not supported for PDF": "TTS не підтримується для PDF",
"Override Book Font": "Перевизначити шрифт книги",
"Apply to All Books": "Застосувати до всіх книг",
"Apply to This Book": "Застосувати лише до цієї книги",
@@ -341,10 +340,10 @@
"Fit": "Припасувати",
"Reset {{settings}}": "Скинути {{settings}}",
"Reset Settings": "Скинути налаштування",
"{{count}} pages left in chapter_one": "Залишилася {{count}} сторінка у розділі",
"{{count}} pages left in chapter_few": "Залишилось {{count}} сторінки у розділі",
"{{count}} pages left in chapter_many": "Залишилось {{count}} сторінок у розділі",
"{{count}} pages left in chapter_other": "Залишилось {{count}} сторінок у розділі",
"{{count}} pages left in chapter_one": "<1>Залишилася </1><0>{{count}}</0><1> сторінка у розділі</1>",
"{{count}} pages left in chapter_few": "<1>Залишилось </1><0>{{count}}</0><1> сторінки у розділі</1>",
"{{count}} pages left in chapter_many": "<1>Залишилось </1><0>{{count}}</0><1> сторінок у розділі</1>",
"{{count}} pages left in chapter_other": "<1>Залишилось </1><0>{{count}}</0><1> сторінок у розділі</1>",
"Show Remaining Pages": "Показати залишок сторінок",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -565,14 +564,14 @@
"Minimize": "Згорнути",
"Maximize or Restore": "Збільшити або відновити",
"Exit Parallel Read": "Вийти з паралельного читання",
"Enter Parallel Read": "Войти в параллельное чтение",
"Enter Parallel Read": "Ввійти в паралельне читання",
"Zoom Level": "Рівень масштабування",
"Zoom Out": "Зменшити масштаб",
"Reset Zoom": "Скинути масштаб",
"Zoom In": "Збільшити масштаб",
"Zoom Mode": "Режим масштабування",
"Single Page": "Одна сторінка",
"Auto Spread": "Автоматичне розповсюдження",
"Auto Spread": "Автоматичне розтягування",
"Fit Page": "Підганяти під сторінку",
"Fit Width": "Підганяти під ширину",
"Failed to select directory": "Не вдалося вибрати каталог",
@@ -589,36 +588,35 @@
"Copying: {{file}}": "Копіювання: {{file}}",
"{{current}} of {{total}} files": "{{current}} з {{total}} файлів",
"Migration completed successfully!": "Переміщення завершено успішно!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "Ваші дані були переміщені до нового розташування. Будь ласка, перезапустіть програму, щоб завершити процес.",
"Your data has been moved to the new location. Please restart the application to complete the process.": "Ваші дані були переміщені до нового розташування. Будь ласка, перезапустіть застосунок, щоб завершити процес.",
"Migration failed": "Переміщення не вдалося",
"Important Notice": "Важливе повідомлення",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Це перемістить всі ваші дані програми до нового розташування. Переконайтеся, що в призначенні достатньо вільного місця.",
"Restart App": "Перезапустити програму",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Це перемістить всі ваші дані до нового розташування. Переконайтеся, що у вибраному розташуванні достатньо вільного місця.",
"Restart App": "Перезапустити застосунок",
"Start Migration": "Почати переміщення",
"Advanced Settings": "Розширені налаштування",
"File count: {{size}}": "Кількість файлів: {{size}}",
"Background Read Aloud": "Озвучування у фоновому режимі",
"Ready to read aloud": "Готовий до читання вголос",
"Ready to read aloud": "Готовність до читання вголос",
"Read Aloud": "Читати вголос",
"Screen Brightness": "Яскравість екрану",
"Background Image": "Фонове зображення",
"Import Image": "Імпортувати зображення",
"Opacity": "Непрозорість",
"Size": "Розмір",
"Cover": окрити",
"Cover": ерекрити",
"Contain": "Втиснути",
"{{number}} pages left in chapter": "Залишилось {{number}} сторінок у розділі",
"{{number}} pages left in chapter": "<1>Залишилось </1><0>{{number}}</0><1> сторінок у розділі</1>",
"Device": "Пристрій",
"E-Ink Mode": "E-Ink режим",
"Highlight Colors": "Кольори виділення",
"Auto Screen Brightness": "Автоматична яскравість екрану",
"Pagination": "Розбиття на сторінки",
"Disable Double Tap": "Вимкнути подвійне натискання",
"Tap to Paginate": "Натисніть, щоб розбити на сторінки",
"Click to Paginate": "Клікніть, щоб розбити на сторінки",
"Tap Both Sides": "Натисніть обидві сторони",
"Click Both Sides": "Клікніть обидві сторони",
"Swap Tap Sides": "Змінити сторони дотику",
"Tap Both Sides": "Натискати на обидві сторони",
"Click Both Sides": "Клікати на обидві сторони",
"Swap Tap Sides": "Змінити сторони натискання",
"Swap Click Sides": "Змінити сторони кліку",
"Source and Translated": "Джерело та переклад",
"Translated Only": "Тільки переклад",
@@ -637,7 +635,7 @@
"Unlock All Customization Options": "Відкрити всі параметри налаштування",
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Відкрийте додаткові теми, шрифти, параметри макета та функції читання вголос, перекладачі, послуги хмарного зберігання.",
"Purchase Successful!": "Покупка успішна!",
"lifetime": "довічний",
"lifetime": "на все життя",
"Thank you for your purchase! Your payment has been processed successfully.": "Дякуємо за вашу покупку! Ваш платіж успішно оброблено.",
"Order ID:": "ID замовлення:",
"TTS Highlighting": "Підсвічування TTS",
@@ -718,8 +716,8 @@
"Sample": "Зразок",
"Download": "Завантажити",
"Open & Read": "Відкрити та читати",
"Tags": "Теги",
"Tag": "Тег",
"Tags": "Теґи",
"Tag": "Теґ",
"First": "Перша",
"Previous": "Попередня",
"Next": "Наступна",
@@ -728,7 +726,7 @@
"An error occurred": "Сталася помилка",
"Online Library": "Онлайн бібліотека",
"URL must start with http:// or https://": "URL повинен починатися з http:// або https://",
"Title, Author, Tag, etc...": "Назва, автор, тег тощо...",
"Title, Author, Tag, etc...": "Назва, автор, теґ тощо...",
"Query": "Запит",
"Subject": "Тема",
"Enter {{terms}}": "Введіть {{terms}}",
@@ -756,8 +754,8 @@
"Oldest First": "Спершу старіші",
"Largest First": "Спершу найбільші",
"Smallest First": "Спершу найменші",
"Name A-Z": "Ім'я A-Z",
"Name Z-A": "Ім'я Z-A",
"Name A-Z": "Ім'я А",
"Name Z-A": "Ім'я А",
"{{count}} selected_one": "Вибрано {{count}} файл",
"{{count}} selected_few": "Вибрано {{count}} файли",
"{{count}} selected_many": "Вибрано {{count}} файлів",
@@ -777,21 +775,21 @@
"From Directory": "З каталогу",
"Successfully imported {{count}} book(s)_one": "Успішно імпортовано 1 книгу",
"Successfully imported {{count}} book(s)_few": "Успішно імпортовано {{count}} книги",
"Successfully imported {{count}} book(s)_many": "Успішно імпортовано {{count}} книг",
"Successfully imported {{count}} book(s)_other": "Успішно імпортовано {{count}} книг",
"Successfully imported {{count}} book(s)_many": "Успішно імпортовано {{count}} книги",
"Successfully imported {{count}} book(s)_other": "Успішно імпортовано {{count}} книги",
"Count": "Кількість",
"Start Page": "Початкова сторінка",
"Search in OPDS Catalog...": "Пошук у каталозі OPDS...",
"Please log in to use advanced TTS features": "Будь ласка, увійдіть, щоб використовувати розширені функції TTS",
"Word limit of 30 words exceeded.": "Перевищено ліміт у 30 слів.",
"Proofread": "Вичитування",
"Proofread": "Вичитка",
"Current selection": "Поточне виділення",
"All occurrences in this book": "Усі входження в цій книзі",
"All occurrences in your library": "Усі входження у вашій бібліотеці",
"All occurrences in this book": "Усі випадки в цій книзі",
"All occurrences in your library": "Усі випадки у вашій бібліотеці",
"Selected text:": "Виділений текст:",
"Replace with:": "Замінити на:",
"Enter text...": "Введіть текст…",
"Case sensitive:": "З урахуванням регістру:",
"Case sensitive:": "З урахуванням реґістру:",
"Scope:": "Область застосування:",
"Selection": "Виділення",
"Library": "Бібліотека",
@@ -804,7 +802,7 @@
"No book-level replacement rules": "Немає правил заміни на рівні книги",
"Disable Quick Action": "Вимкнути швидку дію",
"Enable Quick Action on Selection": "Увімкнути швидку дію при виборі",
"None": "Жоден",
"None": "Нема",
"Annotation Tools": "Інструменти анотацій",
"Enable Quick Actions": "Увімкнути швидкі дії",
"Quick Action": "Швидка дія",
@@ -852,7 +850,10 @@
"Show Results": "Показати результати",
"Clear search": "Очистити пошук",
"Clear search history": "Очистити історію пошуку",
"Tap to Toggle Footer": "Торкніться, щоб перемкнути нижній колонтитул",
"Tap to Toggle Footer": "Нижній колонтитул",
"Show Current Time": "Показати поточний час",
"Use 24 Hour Clock": "Використовувати 24-годинний формат",
"Show Current Battery Status": "Показати поточний стан батареї",
"Exported successfully": "Успішно експортовано",
"Book exported successfully.": "Книгу успішно експортовано.",
"Failed to export the book.": "Не вдалося експортувати книгу.",
@@ -909,8 +910,8 @@
"Toggle Sticky Bottom TTS Bar": "Перемкнути закріплену панель TTS",
"Display what I'm reading on Discord": "Показувати книгу в Discord",
"Show on Discord": "Показати в Discord",
"Instant {{action}}": "Миттєва {{action}}",
"Instant {{action}} Disabled": "Миттєву {{action}} вимкнено",
"Instant {{action}}": "Швидка дія: {{action}}",
"Instant {{action}} Disabled": "Миттєву дію {{action}} вимкнено",
"Annotation": "Анотація",
"Reset Template": "Скинути шаблон",
"Annotation style": "Стиль анотації",
@@ -933,7 +934,7 @@
"Please enter a model ID": "Будь ласка, введіть ID моделі",
"Model not available or invalid": "Модель недоступна або недійсна",
"Failed to validate model": "Не вдалося перевірити модель",
"Couldn't connect to Ollama. Is it running?": "Не вдалося підключитися до Ollama. Чи вона запущена?",
"Couldn't connect to Ollama. Is it running?": "Не вдалося підключитися до Ollama. Вона запущена?",
"Invalid API key or connection failed": "Недійсний API-ключ або помилка підключення",
"Connection failed": "Помилка підключення",
"AI Assistant": "ШІ-асистент",
@@ -946,7 +947,7 @@
"AI Model": "ШІ-модель",
"No models detected": "Моделі не знайдено",
"AI Gateway Configuration": "Налаштування ШІ-шлюзу",
"Choose from a selection of high-quality, economical AI models. You can also bring your own model by selecting \"Custom Model\" below.": "Оберіть з високоякісних, економічних ШІ-моделей. Ви також можете використати власну модель, обравши \"Користувацька модель\" нижче.",
"Choose from a selection of high-quality, economical AI models. You can also bring your own model by selecting \"Custom Model\" below.": "Оберіть з високоякісних або економних ШІ-моделей. Ви також можете використати власну модель, обравши \"Користувацька модель\" нижче.",
"API Key": "API-ключ",
"Get Key": "Отримати ключ",
"Model": "Модель",
@@ -962,7 +963,7 @@
"Reading Ruler": "Лінійка для читання",
"Enable Reading Ruler": "Увімкнути лінійку для читання",
"Lines to Highlight": "Рядки для виділення",
"Ruler Color": "Коліර් лінійки",
"Ruler Color": "Колір лінійки",
"Command Palette": "Палітра команд",
"Search settings and actions...": "Пошук налаштувань та дій...",
"No results found for": "Результатів не знайдено для",
@@ -976,7 +977,7 @@
"AI Provider": "Провайдер ШІ",
"Ollama URL": "URL Ollama",
"Ollama Model": "Модель Ollama",
"AI Gateway Model": "Модель AI Gateway",
"AI Gateway Model": "Модель ШІ-шлюзу",
"Actions": "Дії",
"Navigation": "Навігація",
"Set status for {{count}} book(s)_one": "Встановити статус для {{count}} книги",
@@ -1008,17 +1009,15 @@
"Reading progress": "Прогрес читання",
"Click to seek": "Натисніть для пошуку",
"Skip back 15 words": "Назад на 15 слів",
"Back 15 words (Shift+Left)": "Назад на 15 слів (Shift+Вліво)",
"Back 15 words (Shift+Left)": "Назад на 15 слів (Shift+Стрілка вліво)",
"Pause (Space)": "Пауза (Пробіл)",
"Play (Space)": "Відтворити (Пробіл)",
"Skip forward 15 words": "Вперед на 15 слів",
"Forward 15 words (Shift+Right)": "Вперед на 15 слів (Shift+Вправо)",
"Pause:": "Пауза:",
"Forward 15 words (Shift+Right)": "Вперед на 15 слів (Shift+Стрілка вправо)",
"Decrease speed": "Зменшити швидкість",
"Slower (Left/Down)": "Повільніше (Вліво/Вниз)",
"Current speed": "Поточна швидкість",
"Slower (Left/Down)": "Повільніше (стрілки Вліво/Вниз)",
"Increase speed": "Збільшити швидкість",
"Faster (Right/Up)": "Швидше (Вправо/Вгору)",
"Faster (Right/Up)": "Швидше (стрілки Вправо/Вгору)",
"Start RSVP Reading": "Почати читання RSVP",
"Choose where to start reading": "Виберіть, де почати читання",
"From Chapter Start": "З початку розділу",
@@ -1055,5 +1054,79 @@
"Books": "Книги",
"Groups": "Групи",
"Back to TTS Location": "Повернутися до розташування TTS",
"Metadata": "Метадані"
"Metadata": "Метадані",
"Image viewer": "Переглядач зображень",
"Previous Image": "Попереднє зображення",
"Next Image": "Наступне зображення",
"Zoomed": "Збільшено",
"Zoom level": "Рівень масштабу",
"Table viewer": "Переглядач таблиць",
"Unable to connect to Readwise. Please check your network connection.": "Не вдалося підключитися до Readwise. Будь ласка, перевірте мережеве з'єднання.",
"Invalid Readwise access token": "Недійсний токен доступу Readwise",
"Disconnected from Readwise": "Відключено від Readwise",
"Never": "Ніколи",
"Readwise Settings": "Налаштування Readwise",
"Connected to Readwise": "Підключено до Readwise",
"Last synced: {{time}}": "Остання синхронізація: {{time}}",
"Sync Enabled": "Синхронізацію увімкнено",
"Disconnect": "Відключити",
"Connect your Readwise account to sync highlights.": "Підключіть свій обліковий запис Readwise, щоб синхронізувати виділення.",
"Get your access token at": "Отримайте токен доступу за адресою",
"Access Token": "Токен доступу",
"Paste your Readwise access token": "Вставте ваш токен доступу Readwise",
"Config": "Конфігурація",
"Readwise Sync": "Синхронізація Readwise",
"Push Highlights": "Надіслати виділення",
"Highlights synced to Readwise": "Виділення синхронізовано з Readwise",
"Readwise sync failed: no internet connection": "Синхронізація Readwise не вдалася: немає інтернет-з'єднання",
"Readwise sync failed: {{error}}": "Синхронізація Readwise не вдалася: {{error}}",
"System Screen Brightness": "Системна яскравість екрана",
"Page:": "Сторінка:",
"Page: {{number}}": "Сторінка: {{number}}",
"Annotation page number": "Номер сторінки анотації",
"Translating...": "Переклад...",
"Show Battery Percentage": "Показувати відсоток заряду батареї",
"Hide Scrollbar": "Сховати смугу прокрутки",
"Skip to last reading position": "Перейти до останньої позиції читання",
"Show context": "Показати контекст",
"Hide context": "Сховати контекст",
"Decrease font size": "Зменшити розмір шрифту",
"Increase font size": "Збільшити розмір шрифту",
"Focus": "Фокус",
"Theme color": "Колір теми",
"Import failed": "Помилка імпорту",
"Available Formatters:": "Доступні форматери:",
"Format date": "Форматувати дату",
"Markdown block quote (> per line)": "Цитата Markdown (> для кожного рядка)",
"Newlines to <br>": "Переноси рядків у <br>",
"Change case": "Змінити регістр",
"Trim whitespace": "Видалити пробіли",
"Truncate to n characters": "Обрізати до n символів",
"Replace text": "Замінити текст",
"Fallback value": "Значення за замовчуванням",
"Get length": "Отримати довжину",
"First/last element": "Перший/останній елемент",
"Join array": "Об'єднати масив",
"Backup failed: {{error}}": "Помилка резервного копіювання: {{error}}",
"Select Backup": "Обрати резервну копію",
"Restore failed: {{error}}": "Помилка відновлення: {{error}}",
"Backup & Restore": "Резервне копіювання та відновлення",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Створіть резервну копію бібліотеки або відновіть з попередньої копії. Відновлення буде об'єднано з поточною бібліотекою.",
"Backup Library": "Резервна копія бібліотеки",
"Restore Library": "Відновити бібліотеку",
"Creating backup...": "Створення резервної копії...",
"Restoring library...": "Відновлення бібліотеки...",
"{{current}} of {{total}} items": "{{current}} з {{total}} елементів",
"Backup completed successfully!": "Резервне копіювання завершено!",
"Restore completed successfully!": "Відновлення завершено!",
"Your library has been saved to the selected location.": "Вашу бібліотеку збережено у вибраному місці.",
"{{added}} books added, {{updated}} books updated.": "Додано {{added}} книг, оновлено {{updated}} книг.",
"Operation failed": "Операція не вдалася",
"Loading library...": "Завантаження бібліотеки...",
"{{count}} books refreshed_one": "Оновлено {{count}} книгу",
"{{count}} books refreshed_few": "Оновлено {{count}} книги",
"{{count}} books refreshed_many": "Оновлено {{count}} книг",
"{{count}} books refreshed_other": "Оновлено {{count}} книг",
"Failed to refresh metadata": "Не вдалося оновити метадані",
"Refresh Metadata": "Оновити метадані"
}
@@ -106,7 +106,6 @@
"Wikipedia": "Wikipedia",
"Writing Mode": "Chế độ viết",
"Your Library": "Thư viện của bạn",
"TTS not supported for PDF": "TTS không được hỗ trợ cho PDF",
"Override Book Font": "Ghi đè phông chữ sách",
"Apply to All Books": "Áp dụng cho tất cả sách",
"Apply to This Book": "Áp dụng cho sách này",
@@ -332,7 +331,7 @@
"Fit": "Vừa vặn",
"Reset {{settings}}": "Đặt lại {{settings}}",
"Reset Settings": "Đặt lại cài đặt",
"{{count}} pages left in chapter_other": "Còn {{count}} trang trong chương",
"{{count}} pages left in chapter_other": "<1>Còn </1><0>{{count}}</0><1> trang trong chương</1>",
"Show Remaining Pages": "Hiển thị trang còn lại",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
@@ -595,11 +594,10 @@
"Size": "Kích thước",
"Cover": "Bìa",
"Contain": "Chứa",
"{{number}} pages left in chapter": "Còn {{number}} trang trong chương",
"{{number}} pages left in chapter": "<1>Còn </1><0>{{number}}</0><1> trang trong chương</1>",
"Device": "Thiết bị",
"E-Ink Mode": "Chế độ E-Ink",
"Highlight Colors": "Màu nổi bật",
"Auto Screen Brightness": "Độ sáng màn hình tự động",
"Pagination": "Phân trang",
"Disable Double Tap": "Vô hiệu hóa chạm hai lần",
"Tap to Paginate": "Chạm để phân trang",
@@ -823,6 +821,9 @@
"Clear search": "Xóa tìm kiếm",
"Clear search history": "Xóa lịch sử tìm kiếm",
"Tap to Toggle Footer": "Nhấn để bật/tắt chân trang",
"Show Current Time": "Hiển thị thời gian hiện tại",
"Use 24 Hour Clock": "Sử dụng định dạng 24 giờ",
"Show Current Battery Status": "Hiển thị trạng thái pin hiện tại",
"Exported successfully": "Xuất thành công",
"Book exported successfully.": "Sách đã được xuất thành công.",
"Failed to export the book.": "Xuất sách thất bại.",
@@ -977,10 +978,8 @@
"Play (Space)": "Phát (Khoảng trắng)",
"Skip forward 15 words": "Tiến tới 15 từ",
"Forward 15 words (Shift+Right)": "Tiến tới 15 từ (Shift+Phải)",
"Pause:": "Tạm dừng:",
"Decrease speed": "Giảm tốc độ",
"Slower (Left/Down)": "Chậm hơn (Trái/Xuống)",
"Current speed": "Tốc độ hiện tại",
"Increase speed": "Tăng tốc độ",
"Faster (Right/Up)": "Nhanh hơn (Phải/Lên)",
"Start RSVP Reading": "Bắt đầu đọc RSVP",
@@ -1019,5 +1018,76 @@
"Books": "Sách",
"Groups": "Nhóm",
"Back to TTS Location": "Quay lại vị trí TTS",
"Metadata": "Siêu dữ liệu"
"Metadata": "Siêu dữ liệu",
"Image viewer": "Trình xem ảnh",
"Previous Image": "Ảnh trước",
"Next Image": "Ảnh sau",
"Zoomed": "Đã thu phóng",
"Zoom level": "Mức thu phóng",
"Table viewer": "Trình xem bảng",
"Unable to connect to Readwise. Please check your network connection.": "Không thể kết nối với Readwise. Vui lòng kiểm tra kết nối mạng của bạn.",
"Invalid Readwise access token": "Mã xác thực Readwise không hợp lệ",
"Disconnected from Readwise": "Đã ngắt kết nối với Readwise",
"Never": "Không bao giờ",
"Readwise Settings": "Cài đặt Readwise",
"Connected to Readwise": "Đã kết nối với Readwise",
"Last synced: {{time}}": "Lần đồng bộ cuối: {{time}}",
"Sync Enabled": "Đã bật đồng bộ",
"Disconnect": "Ngắt kết nối",
"Connect your Readwise account to sync highlights.": "Kết nối tài khoản Readwise của bạn để đồng bộ các phần đánh dấu.",
"Get your access token at": "Lấy mã xác thực tại",
"Access Token": "Mã xác thực",
"Paste your Readwise access token": "Dán mã xác thực Readwise của bạn vào đây",
"Config": "Cấu hình",
"Readwise Sync": "Đồng bộ Readwise",
"Push Highlights": "Đẩy các phần đánh dấu",
"Highlights synced to Readwise": "Các phần đánh dấu đã được đồng bộ với Readwise",
"Readwise sync failed: no internet connection": "Đồng bộ Readwise thất bại: không có kết nối internet",
"Readwise sync failed: {{error}}": "Đồng bộ Readwise thất bại: {{error}}",
"System Screen Brightness": "Độ sáng màn hình hệ thống",
"Page:": "Trang:",
"Page: {{number}}": "Trang: {{number}}",
"Annotation page number": "Số trang chú thích",
"Translating...": "Đang dịch...",
"Show Battery Percentage": "Hiển thị phần trăm pin",
"Hide Scrollbar": "Ẩn thanh cuộn",
"Skip to last reading position": "Chuyển đến vị trí đọc cuối",
"Show context": "Hiển thị ngữ cảnh",
"Hide context": "Ẩn ngữ cảnh",
"Decrease font size": "Giảm cỡ chữ",
"Increase font size": "Tăng cỡ chữ",
"Focus": "Tiêu điểm",
"Theme color": "Màu chủ đề",
"Import failed": "Nhập thất bại",
"Available Formatters:": "Bộ định dạng có sẵn:",
"Format date": "Định dạng ngày",
"Markdown block quote (> per line)": "Trích dẫn khối Markdown (> mỗi dòng)",
"Newlines to <br>": "Xuống dòng thành <br>",
"Change case": "Đổi chữ hoa/thường",
"Trim whitespace": "Cắt khoảng trắng",
"Truncate to n characters": "Cắt ngắn còn n ký tự",
"Replace text": "Thay thế văn bản",
"Fallback value": "Giá trị dự phòng",
"Get length": "Lấy độ dài",
"First/last element": "Phần tử đầu/cuối",
"Join array": "Nối mảng",
"Backup failed: {{error}}": "Sao lưu thất bại: {{error}}",
"Select Backup": "Chọn bản sao lưu",
"Restore failed: {{error}}": "Khôi phục thất bại: {{error}}",
"Backup & Restore": "Sao lưu & Khôi phục",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Tạo bản sao lưu thư viện hoặc khôi phục từ bản sao lưu trước đó. Khôi phục sẽ hợp nhất với thư viện hiện tại của bạn.",
"Backup Library": "Sao lưu thư viện",
"Restore Library": "Khôi phục thư viện",
"Creating backup...": "Đang tạo bản sao lưu...",
"Restoring library...": "Đang khôi phục thư viện...",
"{{current}} of {{total}} items": "{{current}} / {{total}} mục",
"Backup completed successfully!": "Sao lưu thành công!",
"Restore completed successfully!": "Khôi phục thành công!",
"Your library has been saved to the selected location.": "Thư viện của bạn đã được lưu tại vị trí đã chọn.",
"{{added}} books added, {{updated}} books updated.": "Đã thêm {{added}} sách, cập nhật {{updated}} sách.",
"Operation failed": "Thao tác thất bại",
"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"
}
@@ -76,6 +76,7 @@
"Sans-Serif Font": "无衬线字体",
"Save": "保存",
"Scrolled Mode": "滚动模式",
"Hide Scrollbar": "隐藏滚动条",
"Search": "搜索",
"Search Books...": "搜索书籍...",
"Search...": "搜索...",
@@ -106,7 +107,6 @@
"Wikipedia": "维基百科",
"Writing Mode": "排版模式",
"Your Library": "书库",
"TTS not supported for PDF": "PDF 文档暂不支持 TTS",
"Override Book Font": "覆盖书籍字体",
"Apply to All Books": "应用于所有书籍",
"Apply to This Book": "应用于此书籍",
@@ -332,7 +332,7 @@
"Fit": "适应",
"Reset {{settings}}": "重置{{settings}}",
"Reset Settings": "重置设置",
"{{count}} pages left in chapter_other": "本章剩余 {{count}}",
"{{count}} pages left in chapter_other": "<1>本章剩余 </1><0>{{count}}</0><1> 页</1>",
"Show Remaining Pages": "显示剩余页数",
"Source Han Serif CN": "思源宋体",
"Huiwen-MinchoGBK": "汇文明朝体",
@@ -595,11 +595,10 @@
"Size": "大小",
"Cover": "覆盖",
"Contain": "适应",
"{{number}} pages left in chapter": "本章剩余{{number}}",
"{{number}} pages left in chapter": "<1>本章剩余</1><0>{{number}}</0><1>页</1>",
"Device": "设备",
"E-Ink Mode": "E-Ink 模式",
"Highlight Colors": "高亮颜色",
"Auto Screen Brightness": "自动屏幕亮度",
"Pagination": "翻页",
"Disable Double Tap": "禁用双击",
"Tap to Paginate": "点击翻页",
@@ -823,6 +822,9 @@
"Clear search": "清除搜索",
"Clear search history": "清除搜索历史",
"Tap to Toggle Footer": "点击切换页脚",
"Show Current Time": "显示当前时间",
"Use 24 Hour Clock": "使用 24 小时制",
"Show Current Battery Status": "显示当前电量",
"Exported successfully": "导出成功",
"Book exported successfully.": "书籍导出成功。",
"Failed to export the book.": "书籍导出失败。",
@@ -977,10 +979,8 @@
"Play (Space)": "播放 (空格)",
"Skip forward 15 words": "前进 15 词",
"Forward 15 words (Shift+Right)": "前进 15 词 (Shift+Right)",
"Pause:": "暂停:",
"Decrease speed": "减速",
"Slower (Left/Down)": "较慢 (左/下)",
"Current speed": "当前速度",
"Increase speed": "加速",
"Faster (Right/Up)": "较快 (右/上)",
"Start RSVP Reading": "开始 RSVP 阅读",
@@ -1019,5 +1019,75 @@
"Books": "书籍",
"Groups": "分组",
"Back to TTS Location": "回到朗读位置",
"Metadata": "元数据"
"Metadata": "元数据",
"Image viewer": "图片查看器",
"Previous Image": "上一张图片",
"Next Image": "下一张图片",
"Zoomed": "已缩放",
"Zoom level": "缩放级别",
"Table viewer": "表格查看器",
"Unable to connect to Readwise. Please check your network connection.": "无法连接到 Readwise。请检查您的网络连接。",
"Invalid Readwise access token": "无效的 Readwise 访问令牌",
"Disconnected from Readwise": "已断开与 Readwise 的连接",
"Never": "从不",
"Readwise Settings": "Readwise 设置",
"Connected to Readwise": "已连接到 Readwise",
"Last synced: {{time}}": "上次同步时间:{{time}}",
"Sync Enabled": "同步已启用",
"Disconnect": "断开连接",
"Connect your Readwise account to sync highlights.": "连接您的 Readwise 账户以同步高亮内容。",
"Get your access token at": "获取您的访问令牌:",
"Access Token": "访问令牌",
"Paste your Readwise access token": "粘贴您的 Readwise 访问令牌",
"Config": "配置",
"Readwise Sync": "Readwise 同步",
"Push Highlights": "推送高亮内容",
"Highlights synced to Readwise": "高亮内容已同步到 Readwise",
"Readwise sync failed: no internet connection": "Readwise 同步失败:无网络连接",
"Readwise sync failed: {{error}}": "Readwise 同步失败:{{error}}",
"System Screen Brightness": "系统屏幕亮度",
"Page:": "页面:",
"Page: {{number}}": "页面: {{number}}",
"Annotation page number": "批注页码",
"Translating...": "翻译中...",
"Show Battery Percentage": "显示电量百分比",
"Skip to last reading position": "跳转到上次阅读位置",
"Show context": "显示上下文",
"Hide context": "隐藏上下文",
"Decrease font size": "缩小字号",
"Increase font size": "放大字号",
"Focus": "焦点",
"Theme color": "主题颜色",
"Import failed": "导入失败",
"Available Formatters:": "可用格式化器:",
"Format date": "格式化日期",
"Markdown block quote (> per line)": "Markdown 引用块(每行 >",
"Newlines to <br>": "换行转为 <br>",
"Change case": "更改大小写",
"Trim whitespace": "去除空格",
"Truncate to n characters": "截断为 n 个字符",
"Replace text": "替换文本",
"Fallback value": "默认值",
"Get length": "获取长度",
"First/last element": "第一个/最后一个元素",
"Join array": "合并数组",
"Backup failed: {{error}}": "备份失败:{{error}}",
"Select Backup": "选择备份",
"Restore failed: {{error}}": "恢复失败:{{error}}",
"Backup & Restore": "备份与恢复",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "创建书库备份或从之前的备份恢复。恢复将与当前书库合并。",
"Backup Library": "备份书库",
"Restore Library": "恢复书库",
"Creating backup...": "正在创建备份...",
"Restoring library...": "正在恢复书库...",
"{{current}} of {{total}} items": "{{current}} / {{total}} 项",
"Backup completed successfully!": "备份成功!",
"Restore completed successfully!": "恢复成功!",
"Your library has been saved to the selected location.": "您的书库已保存到所选位置。",
"{{added}} books added, {{updated}} books updated.": "新增 {{added}} 本书,更新 {{updated}} 本书。",
"Operation failed": "操作失败",
"Loading library...": "正在加载书库...",
"{{count}} books refreshed_other": "已刷新 {{count}} 本书",
"Failed to refresh metadata": "刷新元数据失败",
"Refresh Metadata": "刷新元数据"
}
@@ -106,7 +106,6 @@
"Wikipedia": "維基百科",
"Writing Mode": "排版模式",
"Your Library": "書庫",
"TTS not supported for PDF": "PDF 文檔暫不支持 TTS",
"Override Book Font": "覆蓋書籍字體",
"Apply to All Books": "應用於所有書籍",
"Apply to This Book": "應用於此書籍",
@@ -332,7 +331,7 @@
"Fit": "適應",
"Reset {{settings}}": "重置{{settings}}",
"Reset Settings": "重置設置",
"{{count}} pages left in chapter_other": "本章剩餘 {{count}}",
"{{count}} pages left in chapter_other": "<1>本章剩餘 </1><0>{{count}}</0><1> 頁</1>",
"Show Remaining Pages": "顯示剩餘頁數",
"Source Han Serif CN": "思源宋體",
"Huiwen-MinchoGBK": "匯文明朝體",
@@ -595,11 +594,10 @@
"Size": "大小",
"Cover": "覆蓋",
"Contain": "適應",
"{{number}} pages left in chapter": "本章剩餘{{number}}",
"{{number}} pages left in chapter": "<1>本章剩餘</1><0>{{number}}</0><1>頁</1>",
"Device": "設備",
"E-Ink Mode": "E-Ink 模式",
"Highlight Colors": "高亮顏色",
"Auto Screen Brightness": "自動螢幕亮度",
"Pagination": "翻頁",
"Disable Double Tap": "禁用雙擊",
"Tap to Paginate": "點擊翻頁",
@@ -823,6 +821,9 @@
"Clear search": "清除搜尋",
"Clear search history": "清除搜尋歷史",
"Tap to Toggle Footer": "點擊切換頁尾",
"Show Current Time": "顯示目前時間",
"Use 24 Hour Clock": "使用 24 小時制",
"Show Current Battery Status": "顯示當前電池狀態",
"Exported successfully": "匯出成功",
"Book exported successfully.": "書籍匯出成功。",
"Failed to export the book.": "書籍匯出失敗。",
@@ -977,10 +978,8 @@
"Play (Space)": "播放 (空格)",
"Skip forward 15 words": "前進 15 詞",
"Forward 15 words (Shift+Right)": "前進 15 詞 (Shift+Right)",
"Pause:": "暫停:",
"Decrease speed": "減速",
"Slower (Left/Down)": "較慢 (左/下)",
"Current speed": "當前速度",
"Increase speed": "加速",
"Faster (Right/Up)": "較快 (右/上)",
"Start RSVP Reading": "開始 RSVP 閱讀",
@@ -1019,5 +1018,76 @@
"Books": "書籍",
"Groups": "分組",
"Back to TTS Location": "回到朗讀位置",
"Metadata": "元數據"
"Metadata": "元數據",
"Image viewer": "圖片檢視器",
"Previous Image": "上一張圖片",
"Next Image": "下一張圖片",
"Zoomed": "已縮放",
"Zoom level": "縮放級別",
"Table viewer": "表格檢視器",
"Unable to connect to Readwise. Please check your network connection.": "無法連接到 Readwise。請檢查您的網路連線。",
"Invalid Readwise access token": "無效的 Readwise 存取權杖",
"Disconnected from Readwise": "已斷開與 Readwise 的連線",
"Never": "從不",
"Readwise Settings": "Readwise 設定",
"Connected to Readwise": "已連接到 Readwise",
"Last synced: {{time}}": "上次同步時間:{{time}}",
"Sync Enabled": "同步已啟用",
"Disconnect": "斷開連線",
"Connect your Readwise account to sync highlights.": "連接您的 Readwise 帳戶以同步高亮內容。",
"Get your access token at": "獲取您的存取權杖:",
"Access Token": "存取權杖",
"Paste your Readwise access token": "貼上您的 Readwise 存取權杖",
"Config": "配置",
"Readwise Sync": "Readwise 同步",
"Push Highlights": "推送高亮內容",
"Highlights synced to Readwise": "高亮內容已同步到 Readwise",
"Readwise sync failed: no internet connection": "Readwise 同步失敗:無網路連線",
"Readwise sync failed: {{error}}": "Readwise 同步失敗:{{error}}",
"System Screen Brightness": "系統螢幕亮度",
"Page:": "頁面:",
"Page: {{number}}": "頁面: {{number}}",
"Annotation page number": "註解頁碼",
"Translating...": "翻譯中...",
"Show Battery Percentage": "顯示電池百分比",
"Hide Scrollbar": "隱藏滾動條",
"Skip to last reading position": "跳至上次閱讀位置",
"Show context": "顯示上下文",
"Hide context": "隱藏上下文",
"Decrease font size": "縮小字型",
"Increase font size": "放大字型",
"Focus": "焦點",
"Theme color": "主題顏色",
"Import failed": "匯入失敗",
"Available Formatters:": "可用格式化器:",
"Format date": "格式化日期",
"Markdown block quote (> per line)": "Markdown 引用區塊(每行 >",
"Newlines to <br>": "換行轉為 <br>",
"Change case": "變更大小寫",
"Trim whitespace": "去除空格",
"Truncate to n characters": "截斷為 n 個字元",
"Replace text": "取代文字",
"Fallback value": "預設值",
"Get length": "取得長度",
"First/last element": "第一個/最後一個元素",
"Join array": "合併陣列",
"Backup failed: {{error}}": "備份失敗:{{error}}",
"Select Backup": "選擇備份",
"Restore failed: {{error}}": "還原失敗:{{error}}",
"Backup & Restore": "備份與還原",
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "建立書庫備份或從先前的備份還原。還原將與目前的書庫合併。",
"Backup Library": "備份書庫",
"Restore Library": "還原書庫",
"Creating backup...": "正在建立備份...",
"Restoring library...": "正在還原書庫...",
"{{current}} of {{total}} items": "{{current}} / {{total}} 項",
"Backup completed successfully!": "備份成功!",
"Restore completed successfully!": "還原成功!",
"Your library has been saved to the selected location.": "您的書庫已儲存至所選位置。",
"{{added}} books added, {{updated}} books updated.": "新增 {{added}} 本書,更新 {{updated}} 本書。",
"Operation failed": "操作失敗",
"Loading library...": "正在載入書庫...",
"{{count}} books refreshed_other": "已重新整理 {{count}} 本書",
"Failed to refresh metadata": "重新整理中繼資料失敗",
"Refresh Metadata": "重新整理中繼資料"
}
+14
View File
@@ -1,5 +1,19 @@
{
"releases": {
"0.9.101": {
"date": "2026-02-28",
"notes": [
"Text-to-Speech: Fixed an issue where TTS could not continue to the next chapter",
"Reading: Fixed an issue importing very large TXT files on iOS",
"Reading: Fixed the layout of the reading ruler in scrolled mode",
"Reading: Smoother page-turn animations for a more fluid experience",
"Reading: Added a gallery mode to zoom and browse images within books",
"Annotations: Page numbers are now displayed and exported with highlights and notes",
"Annotations: Added support for exporting highlights to Readwise",
"OPDS: Fixed missing book descriptions in OPDS feeds",
"Android: Improved compatibility with older WebView versions on Android"
]
},
"0.9.100": {
"date": "2026-02-13",
"notes": [
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
#
# Starts a Next.js dev server, launches the Tauri app with webdriver
# (no file watcher, no built-in dev server), waits for the WebDriver
# server on port 4445, runs tests, then tears down everything cleanly.
#
set -euo pipefail
DEV_PORT=3000
WEBDRIVER_PORT=4445
POLL_INTERVAL=3
TIMEOUT=300
cleanup() {
if [[ -n "${TAURI_PID:-}" ]]; then
pkill -P "$TAURI_PID" 2>/dev/null || true
kill "$TAURI_PID" 2>/dev/null || true
wait "$TAURI_PID" 2>/dev/null || true
fi
if [[ -n "${DEV_PID:-}" ]]; then
pkill -P "$DEV_PID" 2>/dev/null || true
kill "$DEV_PID" 2>/dev/null || true
wait "$DEV_PID" 2>/dev/null || true
fi
lsof -ti :"$WEBDRIVER_PORT" 2>/dev/null | xargs kill 2>/dev/null || true
lsof -ti :"$DEV_PORT" 2>/dev/null | xargs kill 2>/dev/null || true
}
trap cleanup EXIT INT TERM
echo "Starting Next.js dev server..."
dotenv -e .env.tauri -- next dev &
DEV_PID=$!
echo "Waiting for dev server on port $DEV_PORT..."
elapsed=0
while ! curl -sf "http://localhost:${DEV_PORT}" >/dev/null 2>&1; do
if ! kill -0 "$DEV_PID" 2>/dev/null; then
echo "ERROR: Dev server exited unexpectedly."
exit 1
fi
if (( elapsed >= TIMEOUT )); then
echo "ERROR: Timed out waiting for dev server on port $DEV_PORT."
exit 1
fi
sleep "$POLL_INTERVAL"
(( elapsed += POLL_INTERVAL ))
done
echo "Starting Tauri app with webdriver (no-watch, skip beforeDevCommand)..."
dotenv -e .env.tauri -- tauri dev --features webdriver --no-watch \
--config '{"build":{"beforeDevCommand":""}}' &
TAURI_PID=$!
echo "Waiting for WebDriver server on port $WEBDRIVER_PORT (timeout ${TIMEOUT}s)..."
elapsed=0
while ! curl -sf "http://127.0.0.1:${WEBDRIVER_PORT}/status" >/dev/null 2>&1; do
if ! kill -0 "$TAURI_PID" 2>/dev/null; then
echo "ERROR: Tauri app exited before WebDriver became ready."
exit 1
fi
if (( elapsed >= TIMEOUT )); then
echo "ERROR: Timed out waiting for WebDriver on port $WEBDRIVER_PORT."
exit 1
fi
sleep "$POLL_INTERVAL"
(( elapsed += POLL_INTERVAL ))
done
echo "WebDriver is ready. Running Tauri tests..."
pnpm vitest --config vitest.tauri.config.mts --watch=false
+5
View File
@@ -17,6 +17,8 @@ crate-type = ["staticlib", "cdylib", "lib"]
[features]
# Internal feature to suppress warnings from old objc crate
cargo-clippy = []
# Enable WebDriver plugin for E2E testing (use with `tauri build --debug --features webdriver`)
webdriver = ["tauri-plugin-webdriver"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
@@ -55,6 +57,9 @@ tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
tauri-plugin-native-tts = { path = "./plugins/tauri-plugin-native-tts" }
tauri-plugin-websocket = "2"
tauri-plugin-sharekit = "0.3"
tauri-plugin-device-info = "1.0.1"
tauri-plugin-turso = { path = "./plugins/tauri-plugin-turso" }
tauri-plugin-webdriver = { version = "0.2", optional = true }
[target."cfg(target_os = \"macos\")".dependencies]
rand = "0.8"
@@ -0,0 +1,36 @@
{
"identifier": "webdriver-testing",
"description": "Grants plugin permissions to remote URLs for Vitest browser-mode tests. Only loaded at runtime when the webdriver feature is enabled.",
"remote": {
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
},
"local": false,
"windows": ["main"],
"permissions": [
"core:default",
"fs:default",
"fs:read-all",
"fs:write-all",
"fs:scope-appconfig-recursive",
"fs:scope-appdata-recursive",
{
"identifier": "fs:scope",
"allow": [
{ "path": "**/.readest-test-sandbox-tauri" },
{ "path": "**/.readest-test-sandbox-tauri/**" },
{ "path": "**/Readest" },
{ "path": "**/Readest/**" }
]
},
"os:default",
"dialog:default",
"core:window:default",
"core:path:allow-resolve-directory",
"log:default",
"shell:default",
"process:default",
"turso:default",
"native-tts:default",
"native-bridge:default"
]
}
@@ -6,6 +6,16 @@
"permissions": [
"core:default",
"fs:default",
"fs:read-meta",
"fs:allow-open",
"fs:allow-write",
"fs:allow-read",
"fs:allow-rename",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-stat",
"fs:allow-fstat",
"fs:allow-lstat",
{
"identifier": "fs:scope-appconfig-recursive",
"allow": [
@@ -70,6 +80,9 @@
{
"path": "**/Readest/**/*"
},
{
"path": "**/com.github.johnfactotum.Foliate/**/*"
},
{
"path": "**/last-book-cover.png"
}
@@ -178,6 +191,8 @@
"haptics:allow-selection-feedback",
"deep-link:default",
"sharekit:default",
"device-info:default",
"turso:default",
"native-tts:default",
"native-bridge:default"
]
@@ -285,7 +285,7 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
if (visible) {
controller.show(WindowInsets.Type.statusBars())
controller.hide(WindowInsets.Type.navigationBars())
controller.show(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.hide(WindowInsetsCompat.Type.navigationBars())
it.show(WindowInsetsCompat.Type.navigationBars())
} else {
it.hide(WindowInsetsCompat.Type.systemBars())
}
@@ -836,8 +836,18 @@ class NativeBridgePlugin: Plugin {
return invoke.reject("URI and destination path must be provided")
}
guard let uri = URL(string: uriString) else {
return invoke.reject("Invalid URI")
let uri: URL
if uriString.hasPrefix("file://") {
let path = String(uriString.dropFirst("file://".count))
guard let decodedPath = path.removingPercentEncoding else {
return invoke.reject("Invalid URI encoding")
}
uri = URL(fileURLWithPath: decodedPath)
} else {
guard let parsed = URL(string: uriString) else {
return invoke.reject("Invalid URI")
}
uri = parsed
}
let fileManager = FileManager.default
@@ -889,6 +899,28 @@ class NativeBridgePlugin: Plugin {
}
}
}
@objc public func get_safe_area_insets(_ invoke: Invoke) {
DispatchQueue.main.async {
if let window = UIApplication.shared.windows.first {
let insets = window.safeAreaInsets
invoke.resolve([
"top": insets.top,
"left": insets.left,
"bottom": insets.bottom,
"right": insets.right
])
} else {
invoke.resolve([
"error": "No window found",
"top": 0,
"left": 0,
"bottom": 0,
"right": 0
])
}
}
}
}
@_cdecl("init_plugin_native_bridge")
@@ -19,7 +19,7 @@ pub fn read_dir(
let scope = app.fs_scope();
let path_buf = std::path::PathBuf::from(&path);
if !scope.is_allowed(&path_buf) {
if !scope.is_allowed(&path_buf) && !path_buf.to_string_lossy().contains("Readest") {
return Err("Permission denied: Path not in filesystem scope".to_string());
}
+32 -12
View File
@@ -153,6 +153,8 @@ pub fn run() {
.plugin(
tauri_plugin_log::Builder::new()
.level(log::LevelFilter::Info)
.level_for("tracing", log::LevelFilter::Warn)
.level_for("tantivy", log::LevelFilter::Warn)
.build(),
)
.plugin(tauri_plugin_websocket::init())
@@ -184,22 +186,29 @@ pub fn run() {
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_sharekit::init())
.plugin(tauri_plugin_device_info::init())
.plugin(tauri_plugin_turso::init())
.plugin(tauri_plugin_native_bridge::init())
.plugin(tauri_plugin_native_tts::init());
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
let _ = app
.get_webview_window("main")
.expect("no main window")
.set_focus();
let files = get_files_from_argv(argv.clone());
if !files.is_empty() {
allow_file_in_scopes(app, files.clone());
}
app.emit("single-instance", SingleInstancePayload { args: argv, cwd })
.unwrap();
}));
let builder = builder.plugin(
tauri_plugin_single_instance::Builder::new()
.callback(move |app, argv, cwd| {
let _ = app
.get_webview_window("main")
.expect("no main window")
.set_focus();
let files = get_files_from_argv(argv.clone());
if !files.is_empty() {
allow_file_in_scopes(app, files.clone());
}
app.emit("single-instance", SingleInstancePayload { args: argv, cwd })
.unwrap();
})
.dbus_id("com.bilingify.readest".to_owned())
.build(),
);
let builder = builder.plugin(tauri_plugin_deep_link::init());
@@ -221,8 +230,19 @@ pub fn run() {
#[cfg(any(target_os = "ios", target_os = "android"))]
let builder = builder.plugin(tauri_plugin_haptics::init());
#[cfg(feature = "webdriver")]
let builder = builder.plugin(tauri_plugin_webdriver::init());
builder
.setup(|#[allow(unused_variables)] app| {
// When running with the webdriver feature (E2E/integration tests),
// grant all default permissions to remote URLs (http://127.0.0.1:*)
// so that Vitest browser-mode tests can call plugin commands.
#[cfg(feature = "webdriver")]
{
use tauri::Manager;
app.add_capability(include_str!("../capabilities-extra/webdriver.json"))?;
}
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
use std::sync::{Arc, Mutex};
+1 -1
View File
@@ -20,7 +20,7 @@
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://cdnjs.cloudflare.com https://storage.readest.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://cdnjs.cloudflare.com https://storage.readest.com",
"frame-src": "'self' blob: asset: http://asset.localhost https://*.stripe.com",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com"
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' data: blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com"
},
"assetProtocol": {
"enable": true,
@@ -41,6 +41,7 @@ describe('ProofreadPopup Component', () => {
key: 'test-book',
text: 'test word',
cfi: 'epubcfi(/6/2[chapter1]!/4/1:0)',
index: 0,
range: {
deleteContents: vi.fn(),
insertNode: vi.fn(),
@@ -49,7 +50,7 @@ describe('ProofreadPopup Component', () => {
startOffset: 5,
endOffset: 9,
} as unknown as Range,
index: 0,
page: 1,
},
position: { point: { x: 100, y: 100 } },
trianglePosition: { point: { x: 100, y: 100 } },
@@ -68,6 +68,7 @@ vi.mock('@/services/environment', async (importOriginal) => {
import { EnvProvider } from '@/context/EnvContext';
import { AuthProvider } from '@/context/AuthContext';
import { DEFAULT_SYSTEM_SETTINGS } from '@/services/constants';
function renderWithProviders(ui: React.ReactNode) {
return render(
@@ -81,12 +82,7 @@ describe('ProofreadRulesManager', () => {
beforeEach(() => {
// Reset stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { proofreadRules: [] },
kosync: {
enabled: false,
},
},
settings: DEFAULT_SYSTEM_SETTINGS,
});
(useReaderStore.setState as unknown as (state: unknown) => void)({ viewStates: {} });
useSidebarStore.setState({ sideBarBookKey: null });
@@ -101,6 +97,7 @@ describe('ProofreadRulesManager', () => {
// Arrange: populate stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
...DEFAULT_SYSTEM_SETTINGS,
globalViewSettings: {
proofreadRules: [
{
@@ -127,7 +124,6 @@ describe('ProofreadRulesManager', () => {
},
],
},
kosync: { enabled: false },
},
});
@@ -164,8 +160,8 @@ describe('ProofreadRulesManager', () => {
// Arrange: populate stores with a selection rule persisted in book config
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
...DEFAULT_SYSTEM_SETTINGS,
globalViewSettings: { proofreadRules: [] },
kosync: { enabled: false },
},
});
@@ -284,10 +280,10 @@ describe('ProofreadRulesManager', () => {
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
...DEFAULT_SYSTEM_SETTINGS,
globalViewSettings: {
proofreadRules: [libraryRule],
},
kosync: { enabled: false },
},
});
@@ -364,8 +360,8 @@ describe('ProofreadRulesManager', () => {
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
...DEFAULT_SYSTEM_SETTINGS,
globalViewSettings: { proofreadRules: [] },
kosync: { enabled: false },
},
});
@@ -418,8 +414,8 @@ describe('ProofreadRulesManager', () => {
// Arrange stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
...DEFAULT_SYSTEM_SETTINGS,
globalViewSettings: { proofreadRules: [] },
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
@@ -453,8 +449,8 @@ describe('ProofreadRulesManager', () => {
it('shows empty state messages when no rules exist', async () => {
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
...DEFAULT_SYSTEM_SETTINGS,
globalViewSettings: { proofreadRules: [] },
kosync: { enabled: false },
},
});
@@ -509,10 +505,10 @@ describe('ProofreadRulesManager', () => {
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
...DEFAULT_SYSTEM_SETTINGS,
globalViewSettings: {
proofreadRules: [libraryRule],
},
kosync: { enabled: false },
},
});
@@ -0,0 +1,164 @@
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';
vi.mock('@/utils/misc', () => ({
getContentMd5: (s: string) => s,
}));
const makeLeafItem = (overrides?: Partial<TOCItem>): TOCItem => ({
id: 1,
label: 'Chapter 1',
href: 'chapter1.html',
index: 0,
...overrides,
});
const makeParentItem = (overrides?: Partial<TOCItem>): TOCItem => ({
id: 1,
label: 'Part One',
href: 'part1.html',
index: 0,
subitems: [{ id: 2, label: 'Chapter 1', href: 'chapter1.html', index: 1 }],
...overrides,
});
const defaultProps = {
bookKey: 'book1',
activeHref: null,
onToggleExpand: vi.fn(),
onItemClick: vi.fn(),
};
afterEach(() => cleanup());
describe('TOCItem accessibility', () => {
it('treeitem has aria-label containing the chapter title', () => {
const item = makeLeafItem({ label: 'Introduction' });
render(<StaticListRow {...defaultProps} flatItem={{ item, depth: 0, index: 0 }} />);
const treeitem = screen.getByRole('treeitem');
expect(treeitem.getAttribute('aria-label')).toContain('Introduction');
});
it('treeitem aria-label includes page number when location is available', () => {
const item = makeLeafItem({
label: 'Chapter 2',
location: { current: 4, total: 100 } as TOCItem['location'],
});
render(<StaticListRow {...defaultProps} flatItem={{ item, depth: 0, index: 0 }} />);
const treeitem = screen.getByRole('treeitem');
// aria-label should include chapter title and page number (5 = current+1)
expect(treeitem.getAttribute('aria-label')).toContain('Chapter 2');
expect(treeitem.getAttribute('aria-label')).toContain('5');
});
it('leaf treeitem does NOT have aria-expanded', () => {
const item = makeLeafItem();
render(<StaticListRow {...defaultProps} flatItem={{ item, depth: 0, index: 0 }} />);
const treeitem = screen.getByRole('treeitem');
expect(treeitem.hasAttribute('aria-expanded')).toBe(false);
});
it('parent treeitem HAS aria-expanded set to false when collapsed', () => {
const item = makeParentItem();
render(
<StaticListRow
{...defaultProps}
flatItem={{ item, depth: 0, index: 0, isExpanded: false }}
/>,
);
const treeitem = screen.getByRole('treeitem');
expect(treeitem.getAttribute('aria-expanded')).toBe('false');
});
it('parent treeitem HAS aria-expanded set to true when expanded', () => {
const item = makeParentItem();
render(
<StaticListRow {...defaultProps} flatItem={{ item, depth: 0, index: 0, isExpanded: true }} />,
);
const treeitem = screen.getByRole('treeitem');
expect(treeitem.getAttribute('aria-expanded')).toBe('true');
});
it('expand button has descriptive aria-label', () => {
const item = makeParentItem();
render(
<StaticListRow
{...defaultProps}
flatItem={{ item, depth: 0, index: 0, isExpanded: false }}
/>,
);
const button = screen.getByRole('button');
expect(button.getAttribute('aria-label')).toBeTruthy();
});
it('page number element is aria-hidden', () => {
const item = makeLeafItem({ index: 2 });
const { container } = render(
<StaticListRow {...defaultProps} flatItem={{ item, depth: 0, index: 0 }} />,
);
// The page number div should be aria-hidden since it's included in aria-label
const pageDiv = container.querySelector('[aria-hidden="true"]');
expect(pageDiv).toBeTruthy();
});
});
describe('aria-current on active treeitem', () => {
it('active treeitem has aria-current="page"', () => {
const item = makeLeafItem({ href: 'chapter1.html' });
render(
<StaticListRow
{...defaultProps}
activeHref='chapter1.html'
flatItem={{ item, depth: 0, index: 0 }}
/>,
);
const treeitem = screen.getByRole('treeitem');
expect(treeitem.getAttribute('aria-current')).toBe('page');
});
it('inactive treeitem does NOT have aria-current', () => {
const item = makeLeafItem({ href: 'chapter1.html' });
render(
<StaticListRow
{...defaultProps}
activeHref='chapter2.html'
flatItem={{ item, depth: 0, index: 0 }}
/>,
);
const treeitem = screen.getByRole('treeitem');
expect(treeitem.hasAttribute('aria-current')).toBe(false);
});
it('active treeitem is focusable (tabIndex=0)', () => {
const item = makeLeafItem({ href: 'chapter1.html' });
render(
<StaticListRow
{...defaultProps}
activeHref='chapter1.html'
flatItem={{ item, depth: 0, index: 0 }}
/>,
);
const treeitem = screen.getByRole('treeitem');
expect(treeitem.getAttribute('tabindex')).toBe('0');
});
});
describe('TOCView tree role', () => {
it('static list container has role="tree"', async () => {
// This is tested indirectly via TOCView, but we verify the container role
// by checking the structure rendered by StaticListRow's wrapper
const item = makeLeafItem();
render(
<div role='tree'>
<StaticListRow {...defaultProps} flatItem={{ item, depth: 0, index: 0 }} />
</div>,
);
const tree = screen.getByRole('tree');
expect(tree).toBeTruthy();
expect(tree.querySelector('[role="treeitem"]')).toBeTruthy();
});
});
@@ -0,0 +1,197 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { migrate, MigrationEntry } from '@/services/database/migrate';
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
/**
* In-memory DatabaseService for testing the migration runner.
* Tracks tables, rows, and PRAGMA user_version.
*/
function createMockDb(): DatabaseService & {
userVersion: number;
tables: Map<string, DatabaseRow[]>;
} {
const tables = new Map<string, DatabaseRow[]>();
let userVersion = 0;
const db: DatabaseService & { userVersion: number; tables: Map<string, DatabaseRow[]> } = {
userVersion: 0,
tables,
async execute(sql: string, params: unknown[] = []): Promise<DatabaseExecResult> {
const trimmed = sql.trim();
if (/^CREATE TABLE/i.test(trimmed)) {
const tableName = trimmed.match(/CREATE TABLE\s+(?:IF NOT EXISTS\s+)?(\w+)/i)?.[1];
if (tableName && !tables.has(tableName)) {
tables.set(tableName, []);
}
return { rowsAffected: 0, lastInsertId: 0 };
}
if (/^INSERT INTO/i.test(trimmed)) {
const tableName = trimmed.match(/INTO\s+(\w+)/i)?.[1] ?? '_default';
const existing = tables.get(tableName) ?? [];
const id = existing.length + 1;
// Parse VALUES for the migration tracking table
const valuesMatch = trimmed.match(/VALUES\s*\(([^)]+)\)/i);
if (valuesMatch) {
const rawVal =
params.length > 0 ? params[0] : valuesMatch[1]!.trim().replace(/^'|'$/g, '');
existing.push({ id, name: rawVal });
} else {
existing.push({ id });
}
tables.set(tableName, existing);
return { rowsAffected: 1, lastInsertId: id };
}
return { rowsAffected: 0, lastInsertId: 0 };
},
async select<T extends DatabaseRow = DatabaseRow>(sql: string): Promise<T[]> {
const trimmed = sql.trim();
if (/^PRAGMA user_version/i.test(trimmed)) {
return [{ user_version: userVersion } as unknown as T];
}
const tableName = trimmed.match(/FROM\s+(\w+)/i)?.[1] ?? '_default';
return (tables.get(tableName) ?? []) as T[];
},
async batch(statements: string[]): Promise<void> {
for (const stmt of statements) {
const trimmed = stmt.trim();
if (/^PRAGMA user_version\s*=/i.test(trimmed)) {
const val = parseInt(trimmed.match(/=\s*(\d+)/)?.[1] ?? '0', 10);
userVersion = val;
db.userVersion = val;
} else {
await db.execute(trimmed);
}
}
},
async close(): Promise<void> {
tables.clear();
},
};
return db;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('migrate()', () => {
let db: ReturnType<typeof createMockDb>;
const migrations: MigrationEntry[] = [
{
name: '2026030601_create_books',
sql: `
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL
);
`,
},
{
name: '2026030602_create_annotations',
sql: `
CREATE TABLE IF NOT EXISTS annotations (
id INTEGER PRIMARY KEY,
book_id INTEGER NOT NULL,
text TEXT
);
`,
},
];
beforeEach(() => {
db = createMockDb();
});
it('applies all migrations on a fresh database', async () => {
await migrate(db, migrations);
expect(db.tables.has('books')).toBe(true);
expect(db.tables.has('annotations')).toBe(true);
expect(db.tables.has('__migrations')).toBe(true);
expect(db.userVersion).toBe(2);
});
it('records migration names in the tracking table', async () => {
await migrate(db, migrations);
const tracked = db.tables.get('__migrations') ?? [];
const names = tracked.map((r) => r['name']);
expect(names).toContain('2026030601_create_books');
expect(names).toContain('2026030602_create_annotations');
});
it('skips already-applied migrations (idempotent)', async () => {
await migrate(db, migrations);
const firstRunTracked = (db.tables.get('__migrations') ?? []).length;
// Run again — should be a no-op via PRAGMA user_version fast-path
await migrate(db, migrations);
const secondRunTracked = (db.tables.get('__migrations') ?? []).length;
expect(secondRunTracked).toBe(firstRunTracked);
expect(db.userVersion).toBe(2);
});
it('applies only new migrations when schema grows', async () => {
// Apply first migration only
await migrate(db, [migrations[0]!]);
expect(db.userVersion).toBe(1);
expect(db.tables.has('books')).toBe(true);
expect(db.tables.has('annotations')).toBe(false);
// Now apply both — only second should run
await migrate(db, migrations);
expect(db.userVersion).toBe(2);
expect(db.tables.has('annotations')).toBe(true);
const tracked = db.tables.get('__migrations') ?? [];
expect(tracked).toHaveLength(2);
});
it('does nothing when migrations array is empty', async () => {
await migrate(db, []);
expect(db.tables.size).toBe(0);
expect(db.userVersion).toBe(0);
});
it('uses custom tracking table name', async () => {
await migrate(db, migrations, { table: '__custom_migrations' });
expect(db.tables.has('__custom_migrations')).toBe(true);
expect(db.tables.has('__migrations')).toBe(false);
});
it('handles migration names with single quotes', async () => {
const trickyMigrations: MigrationEntry[] = [
{
name: "2026030601_it's_tricky",
sql: 'CREATE TABLE IF NOT EXISTS tricky (id INTEGER PRIMARY KEY);',
},
];
await migrate(db, trickyMigrations);
expect(db.tables.has('tricky')).toBe(true);
expect(db.userVersion).toBe(1);
});
});
describe('getMigrations()', () => {
it('returns an array for defined schema types', async () => {
const { getMigrations } = await import('@/services/database/migrations');
const result = getMigrations('nonexistent_schema');
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(0);
});
});
@@ -0,0 +1,281 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
// ---------------------------------------------------------------------------
// Mock: NativeDatabaseService
// ---------------------------------------------------------------------------
vi.mock('tauri-plugin-turso', () => {
const rows = new Map<string, DatabaseRow[]>();
const mockDb = {
execute: vi.fn(async (sql: string, params: unknown[] = []) => {
const insertTable = sql.match(/INTO\s+(\w+)/i)?.[1];
const fromTable = sql.match(/FROM\s+(\w+)/i)?.[1];
const table = insertTable ?? fromTable ?? '_default';
if (/^INSERT/i.test(sql.trim())) {
const existing = rows.get(table) ?? [];
const id = existing.length + 1;
existing.push({ id, value: params[0] ?? null });
rows.set(table, existing);
return { rowsAffected: 1, lastInsertId: id };
}
if (/^DELETE/i.test(sql.trim())) {
const existing = rows.get(table) ?? [];
rows.set(table, []);
return { rowsAffected: existing.length, lastInsertId: 0 };
}
return { rowsAffected: 0, lastInsertId: 0 };
}),
select: vi.fn(async (sql: string) => {
const table = sql.match(/FROM\s+(\w+)/i)?.[1] ?? '_default';
return rows.get(table) ?? [];
}),
batch: vi.fn(async () => {}),
close: vi.fn(async () => {
rows.clear();
return true;
}),
};
return {
Database: {
load: vi.fn(async () => mockDb),
},
__mockDb: mockDb,
__rows: rows,
};
});
// ---------------------------------------------------------------------------
// Mock: @tursodatabase/database-wasm
// ---------------------------------------------------------------------------
vi.mock('@tursodatabase/database-wasm', () => {
const rows = new Map<string, DatabaseRow[]>();
const mockDb = {
prepare: vi.fn((sql: string) => ({
run: vi.fn((...params: unknown[]) => {
const table = sql.match(/INTO\s+(\w+)/i)?.[1] ?? '_default';
if (/^INSERT/i.test(sql.trim())) {
const existing = rows.get(table) ?? [];
const id = existing.length + 1;
existing.push({ id, value: params[0] ?? null });
rows.set(table, existing);
return { changes: 1, lastInsertRowid: id };
}
if (/^DELETE/i.test(sql.trim())) {
const existing = rows.get(table) ?? [];
rows.set(table, []);
return { changes: existing.length, lastInsertRowid: 0 };
}
return { changes: 0, lastInsertRowid: 0 };
}),
all: vi.fn(() => {
const table = sql.match(/FROM\s+(\w+)/i)?.[1] ?? '_default';
return rows.get(table) ?? [];
}),
})),
exec: vi.fn(),
close: vi.fn(() => {
rows.clear();
}),
};
return {
connect: vi.fn(() => mockDb),
__mockDb: mockDb,
__rows: rows,
};
});
// ---------------------------------------------------------------------------
// Tests: NativeDatabaseService
// ---------------------------------------------------------------------------
describe('NativeDatabaseService', () => {
let db: DatabaseService;
beforeEach(async () => {
vi.clearAllMocks();
const mod = await import('tauri-plugin-turso');
(mod as unknown as { __rows: Map<string, DatabaseRow[]> }).__rows.clear();
const { NativeDatabaseService } = await import('@/services/database/nativeDatabaseService');
db = await NativeDatabaseService.open('sqlite:test.db');
});
it('execute() returns DatabaseExecResult for INSERT', async () => {
const result: DatabaseExecResult = await db.execute('INSERT INTO items (value) VALUES (?)', [
'hello',
]);
expect(result.rowsAffected).toBe(1);
expect(result.lastInsertId).toBeGreaterThan(0);
});
it('execute() returns DatabaseExecResult for DELETE', async () => {
await db.execute('INSERT INTO items (value) VALUES (?)', ['a']);
const result = await db.execute('DELETE FROM items');
expect(result.rowsAffected).toBe(1);
});
it('select() returns typed row arrays', async () => {
await db.execute('INSERT INTO items (value) VALUES (?)', ['alpha']);
await db.execute('INSERT INTO items (value) VALUES (?)', ['beta']);
const rows = await db.select<{ id: number; value: string }>('SELECT * FROM items');
expect(rows).toHaveLength(2);
expect(rows[0]!.id).toBe(1);
expect(rows[0]!.value).toBe('alpha');
expect(rows[1]!.id).toBe(2);
expect(rows[1]!.value).toBe('beta');
});
it('select() returns empty array when no rows', async () => {
const rows = await db.select('SELECT * FROM empty_table');
expect(rows).toEqual([]);
});
it('batch() delegates to underlying db.batch()', async () => {
await db.batch(['CREATE TABLE t (id INTEGER)', 'INSERT INTO t VALUES (1)']);
const mod = await import('tauri-plugin-turso');
const mockDb = (mod as unknown as { __mockDb: { batch: ReturnType<typeof vi.fn> } }).__mockDb;
expect(mockDb.batch).toHaveBeenCalledWith([
'CREATE TABLE t (id INTEGER)',
'INSERT INTO t VALUES (1)',
]);
});
it('close() delegates to underlying db.close()', async () => {
await db.close();
const mod = await import('tauri-plugin-turso');
const mockDb = (mod as unknown as { __mockDb: { close: ReturnType<typeof vi.fn> } }).__mockDb;
expect(mockDb.close).toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Tests: WebDatabaseService
// ---------------------------------------------------------------------------
describe('WebDatabaseService', () => {
let db: DatabaseService;
beforeEach(async () => {
vi.clearAllMocks();
const mod = await import('@tursodatabase/database-wasm');
(mod as unknown as { __rows: Map<string, DatabaseRow[]> }).__rows.clear();
const { WebDatabaseService } = await import('@/services/database/webDatabaseService');
db = await WebDatabaseService.open('test.db');
});
it('execute() maps changes/lastInsertRowid to DatabaseExecResult', async () => {
const result: DatabaseExecResult = await db.execute('INSERT INTO items (value) VALUES (?)', [
'hello',
]);
expect(result.rowsAffected).toBe(1);
expect(result.lastInsertId).toBeGreaterThan(0);
});
it('select() returns row objects from prepare().all()', async () => {
await db.execute('INSERT INTO items (value) VALUES (?)', ['alpha']);
await db.execute('INSERT INTO items (value) VALUES (?)', ['beta']);
const rows = await db.select<{ id: number; value: string }>('SELECT * FROM items');
expect(rows).toHaveLength(2);
expect(rows[0]!.id).toBe(1);
expect(rows[0]!.value).toBe('alpha');
});
it('select() returns empty array when no rows', async () => {
const rows = await db.select('SELECT * FROM empty_table');
expect(rows).toEqual([]);
});
it('batch() wraps in BEGIN/COMMIT transaction', async () => {
await db.batch(['CREATE TABLE t (id INTEGER)', 'INSERT INTO t VALUES (1)']);
const mod = await import('@tursodatabase/database-wasm');
const mockDb = (mod as unknown as { __mockDb: { exec: ReturnType<typeof vi.fn> } }).__mockDb;
expect(mockDb.exec).toHaveBeenCalledWith('BEGIN');
expect(mockDb.exec).toHaveBeenCalledWith('CREATE TABLE t (id INTEGER)');
expect(mockDb.exec).toHaveBeenCalledWith('INSERT INTO t VALUES (1)');
expect(mockDb.exec).toHaveBeenCalledWith('COMMIT');
});
it('batch() rolls back on error', async () => {
const mod = await import('@tursodatabase/database-wasm');
const mockDb = (mod as unknown as { __mockDb: { exec: ReturnType<typeof vi.fn> } }).__mockDb;
let callCount = 0;
mockDb.exec.mockImplementation((_sql: string) => {
callCount++;
// Fail on the second exec (first real statement after BEGIN)
if (callCount === 2) throw new Error('SQL error');
});
await expect(db.batch(['BAD SQL', 'GOOD SQL'])).rejects.toThrow('SQL error');
expect(mockDb.exec).toHaveBeenCalledWith('ROLLBACK');
});
it('close() delegates to underlying db.close()', async () => {
await db.close();
const mod = await import('@tursodatabase/database-wasm');
const mockDb = (mod as unknown as { __mockDb: { close: ReturnType<typeof vi.fn> } }).__mockDb;
expect(mockDb.close).toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Tests: DatabaseExecResult shape
// ---------------------------------------------------------------------------
describe('DatabaseExecResult type contract', () => {
it('has rowsAffected and lastInsertId properties', () => {
const result: DatabaseExecResult = {
rowsAffected: 5,
lastInsertId: 42,
};
expect(result.rowsAffected).toBe(5);
expect(result.lastInsertId).toBe(42);
});
});
// ---------------------------------------------------------------------------
// Tests: Unified API consistency
// ---------------------------------------------------------------------------
describe('API consistency between Native and Web implementations', () => {
it('both implementations satisfy the DatabaseService interface', async () => {
const { NativeDatabaseService } = await import('@/services/database/nativeDatabaseService');
const nativeDb: DatabaseService = await NativeDatabaseService.open('sqlite:test.db');
expect(nativeDb.execute).toBeDefined();
expect(nativeDb.select).toBeDefined();
expect(nativeDb.batch).toBeDefined();
expect(nativeDb.close).toBeDefined();
const { WebDatabaseService } = await import('@/services/database/webDatabaseService');
const webDb: DatabaseService = await WebDatabaseService.open('test.db');
expect(webDb.execute).toBeDefined();
expect(webDb.select).toBeDefined();
expect(webDb.batch).toBeDefined();
expect(webDb.close).toBeDefined();
});
it('both return same shape from execute()', async () => {
const { NativeDatabaseService } = await import('@/services/database/nativeDatabaseService');
const nativeDb = await NativeDatabaseService.open('sqlite:test.db');
const nativeResult = await nativeDb.execute('INSERT INTO t (value) VALUES (?)', ['x']);
const { WebDatabaseService } = await import('@/services/database/webDatabaseService');
const webDb = await WebDatabaseService.open('test.db');
const webResult = await webDb.execute('INSERT INTO t (value) VALUES (?)', ['x']);
expect(Object.keys(nativeResult).sort()).toEqual(['lastInsertId', 'rowsAffected']);
expect(Object.keys(webResult).sort()).toEqual(['lastInsertId', 'rowsAffected']);
expect(typeof nativeResult.rowsAffected).toBe('number');
expect(typeof webResult.rowsAffected).toBe('number');
expect(typeof nativeResult.lastInsertId).toBe('number');
expect(typeof webResult.lastInsertId).toBe('number');
});
});
@@ -0,0 +1,175 @@
import { it, expect } from 'vitest';
import { DatabaseService } from '@/types/database';
/**
* Shared base database operation tests exercised against any DatabaseService.
* Covers CRUD, batch, data types, error handling, and result contract.
*
* Call inside a describe() block:
* describe('base ops', () => { baseTests(() => db); });
*/
export function baseTests(getDb: () => DatabaseService) {
// ---------------------------------------------------------------------------
// Schema & basic operations
// ---------------------------------------------------------------------------
it('creates a table and inserts a row', async () => {
const db = getDb();
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
const result = await db.execute('INSERT INTO items (name) VALUES (?)', ['apple']);
expect(result.rowsAffected).toBe(1);
expect(result.lastInsertId).toBe(1);
});
it('inserts multiple rows with auto-incrementing ids', async () => {
const db = getDb();
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
const r1 = await db.execute('INSERT INTO items (name) VALUES (?)', ['a']);
const r2 = await db.execute('INSERT INTO items (name) VALUES (?)', ['b']);
const r3 = await db.execute('INSERT INTO items (name) VALUES (?)', ['c']);
expect(r1.lastInsertId).toBe(1);
expect(r2.lastInsertId).toBe(2);
expect(r3.lastInsertId).toBe(3);
});
// ---------------------------------------------------------------------------
// SELECT queries
// ---------------------------------------------------------------------------
it('select returns typed rows', async () => {
const db = getDb();
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, qty INTEGER)');
await db.execute('INSERT INTO items (name, qty) VALUES (?, ?)', ['apple', 10]);
await db.execute('INSERT INTO items (name, qty) VALUES (?, ?)', ['banana', 20]);
const rows = await db.select<{ id: number; name: string; qty: number }>(
'SELECT * FROM items ORDER BY id',
);
expect(rows).toHaveLength(2);
expect(rows[0]!.name).toBe('apple');
expect(rows[0]!.qty).toBe(10);
expect(rows[1]!.name).toBe('banana');
expect(rows[1]!.qty).toBe(20);
});
it('select with WHERE and params', async () => {
const db = getDb();
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
await db.execute('INSERT INTO items (name) VALUES (?)', ['apple']);
await db.execute('INSERT INTO items (name) VALUES (?)', ['banana']);
await db.execute('INSERT INTO items (name) VALUES (?)', ['cherry']);
const rows = await db.select<{ id: number; name: string }>(
'SELECT * FROM items WHERE name = ?',
['banana'],
);
expect(rows).toHaveLength(1);
expect(rows[0]!.name).toBe('banana');
});
it('select returns empty array for no matching rows', async () => {
const db = getDb();
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
const rows = await db.select('SELECT * FROM items');
expect(rows).toEqual([]);
});
// ---------------------------------------------------------------------------
// UPDATE & DELETE
// ---------------------------------------------------------------------------
it('UPDATE returns rowsAffected', async () => {
const db = getDb();
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
await db.execute('INSERT INTO items (name) VALUES (?)', ['old']);
await db.execute('INSERT INTO items (name) VALUES (?)', ['old']);
const result = await db.execute('UPDATE items SET name = ? WHERE name = ?', ['new', 'old']);
expect(result.rowsAffected).toBe(2);
});
it('DELETE returns rowsAffected', async () => {
const db = getDb();
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
await db.execute('INSERT INTO items (name) VALUES (?)', ['a']);
await db.execute('INSERT INTO items (name) VALUES (?)', ['b']);
await db.execute('INSERT INTO items (name) VALUES (?)', ['c']);
const result = await db.execute('DELETE FROM items WHERE name IN (?, ?)', ['a', 'c']);
expect(result.rowsAffected).toBe(2);
const remaining = await db.select<{ name: string }>('SELECT name FROM items');
expect(remaining).toHaveLength(1);
expect(remaining[0]!.name).toBe('b');
});
// ---------------------------------------------------------------------------
// Batch execution
// ---------------------------------------------------------------------------
it('batch executes multiple statements atomically', async () => {
const db = getDb();
await db.batch([
'CREATE TABLE t1 (id INTEGER PRIMARY KEY, val TEXT)',
"INSERT INTO t1 (val) VALUES ('one')",
"INSERT INTO t1 (val) VALUES ('two')",
"INSERT INTO t1 (val) VALUES ('three')",
]);
const rows = await db.select<{ val: string }>('SELECT val FROM t1 ORDER BY id');
expect(rows).toHaveLength(3);
expect(rows.map((r) => r.val)).toEqual(['one', 'two', 'three']);
});
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
it('handles NULL values correctly', async () => {
const db = getDb();
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)');
await db.execute('INSERT INTO t (val) VALUES (?)', [null]);
const rows = await db.select<{ id: number; val: string | null }>('SELECT * FROM t');
expect(rows).toHaveLength(1);
expect(rows[0]!.val).toBeNull();
});
it('handles integer and real types', async () => {
const db = getDb();
await db.execute('CREATE TABLE nums (i INTEGER, r REAL)');
await db.execute('INSERT INTO nums (i, r) VALUES (?, ?)', [42, 3.14]);
const rows = await db.select<{ i: number; r: number }>('SELECT * FROM nums');
expect(rows[0]!.i).toBe(42);
expect(rows[0]!.r).toBeCloseTo(3.14);
});
// ---------------------------------------------------------------------------
// Error handling
// ---------------------------------------------------------------------------
it('throws on invalid SQL', async () => {
const db = getDb();
await expect(db.execute('INVALID SQL STATEMENT')).rejects.toThrow();
});
it('throws on constraint violation', async () => {
const db = getDb();
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT UNIQUE)');
await db.execute('INSERT INTO t (val) VALUES (?)', ['unique_val']);
await expect(db.execute('INSERT INTO t (val) VALUES (?)', ['unique_val'])).rejects.toThrow();
});
// ---------------------------------------------------------------------------
// Result contract
// ---------------------------------------------------------------------------
it('execute result always has rowsAffected and lastInsertId as numbers', async () => {
const db = getDb();
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY)');
const result = await db.execute('INSERT INTO t DEFAULT VALUES');
expect(typeof result.rowsAffected).toBe('number');
expect(typeof result.lastInsertId).toBe('number');
});
}
@@ -0,0 +1,329 @@
import { it, expect } from 'vitest';
import { DatabaseService } from '@/types/database';
/**
* Shared full-text search test cases for Turso's native FTS (Tantivy-based).
* Call this inside a describe() block after setting up a DatabaseService instance
* opened with { experimental: ['index_method'] }.
*
* API reference (turso v0.5):
* - CREATE INDEX ... USING fts (columns) [WITH (tokenizer=..., weights=...)]
* - fts_match(col1, col2, ..., query) boolean filter
* - fts_score(col1, col2, ..., query) BM25 relevance score
* - fts_highlight(col, open, close, query) wrap matched terms
* - OPTIMIZE INDEX idx_name
*
* Reference: https://turso.tech/blog/beyond-fts5
*/
export function ftsTests(getDb: () => DatabaseService) {
let ftsProbed = false;
let ftsSupported = false;
/**
* Wrapper around it() that probes FTS support on first run (lazily,
* after beforeEach has created the db) and skips when unavailable.
*/
function ftsIt(name: string, fn: () => Promise<void>) {
it(name, async ({ skip }) => {
if (!ftsProbed) {
ftsProbed = true;
const db = getDb();
try {
await db.execute('CREATE TABLE _fts_probe (id INTEGER PRIMARY KEY, t TEXT)');
await db.execute('CREATE INDEX _fts_probe_idx ON _fts_probe USING fts (t)');
await db.execute('DROP INDEX _fts_probe_idx');
await db.execute('DROP TABLE _fts_probe');
ftsSupported = true;
} catch {
try {
await db.execute('DROP TABLE IF EXISTS _fts_probe');
} catch {
/* ignore cleanup errors */
}
}
}
if (!ftsSupported) {
skip();
return;
}
await fn();
});
}
async function seedArticles(db: DatabaseService) {
await db.execute('CREATE TABLE articles (id INTEGER PRIMARY KEY, title TEXT, body TEXT)');
await db.execute('INSERT INTO articles (title, body) VALUES (?, ?)', [
'Introduction to SQLite',
'SQLite is a lightweight relational database engine widely used in embedded systems.',
]);
await db.execute('INSERT INTO articles (title, body) VALUES (?, ?)', [
'Understanding WAL Mode',
'Write-Ahead Logging improves concurrency in SQLite database operations.',
]);
await db.execute('INSERT INTO articles (title, body) VALUES (?, ?)', [
'Full-Text Search Basics',
'Full-text search allows efficient querying of large text datasets.',
]);
await db.execute('INSERT INTO articles (title, body) VALUES (?, ?)', [
'Rust and WebAssembly',
'Rust compiles to WebAssembly for high-performance browser applications.',
]);
await db.execute('CREATE INDEX idx_articles_fts ON articles USING fts (title, body)');
}
// ---------------------------------------------------------------------------
// FTS index creation
// ---------------------------------------------------------------------------
ftsIt('creates an FTS index on a table', async () => {
const db = getDb();
await db.execute('CREATE TABLE docs (id INTEGER PRIMARY KEY, content TEXT)');
await db.execute('CREATE INDEX idx_docs_fts ON docs USING fts (content)');
await db.execute('INSERT INTO docs (content) VALUES (?)', ['hello world']);
const rows = await db.select<{ id: number; content: string }>(
"SELECT * FROM docs WHERE fts_match(content, 'hello')",
);
expect(rows).toHaveLength(1);
expect(rows[0]!.content).toBe('hello world');
});
// ---------------------------------------------------------------------------
// fts_match() — full-text filtering
// ---------------------------------------------------------------------------
ftsIt('fts_match() returns relevant rows', async () => {
const db = getDb();
await seedArticles(db);
const rows = await db.select<{ id: number; title: string }>(
"SELECT id, title FROM articles WHERE fts_match(title, body, 'SQLite')",
);
expect(rows.length).toBeGreaterThanOrEqual(1);
const titles = rows.map((r) => r.title);
expect(titles).toContain('Introduction to SQLite');
});
ftsIt('fts_match() returns empty result for non-matching query', async () => {
const db = getDb();
await seedArticles(db);
const rows = await db.select<{ id: number }>(
"SELECT id FROM articles WHERE fts_match(title, body, 'blockchain')",
);
expect(rows).toHaveLength(0);
});
ftsIt('fts_match() works with parameterized queries', async () => {
const db = getDb();
await seedArticles(db);
const rows = await db.select<{ id: number; title: string }>(
'SELECT id, title FROM articles WHERE fts_match(title, body, ?)',
['WebAssembly'],
);
expect(rows).toHaveLength(1);
expect(rows[0]!.title).toBe('Rust and WebAssembly');
});
ftsIt('fts_match() on single indexed column', async () => {
const db = getDb();
await db.execute('CREATE TABLE tags (id INTEGER PRIMARY KEY, label TEXT)');
await db.execute('INSERT INTO tags (label) VALUES (?)', ['typescript']);
await db.execute('INSERT INTO tags (label) VALUES (?)', ['javascript']);
await db.execute('INSERT INTO tags (label) VALUES (?)', ['python']);
await db.execute('CREATE INDEX idx_tags_fts ON tags USING fts (label)');
const rows = await db.select<{ id: number; label: string }>(
"SELECT id, label FROM tags WHERE fts_match(label, 'typescript')",
);
expect(rows).toHaveLength(1);
expect(rows[0]!.label).toBe('typescript');
});
// ---------------------------------------------------------------------------
// fts_score() — BM25 ranking
// ---------------------------------------------------------------------------
ftsIt('fts_score() returns relevance scores ordered by rank', async () => {
const db = getDb();
await seedArticles(db);
const rows = await db.select<{ score: number; title: string }>(
"SELECT fts_score(title, body, 'database') AS score, title FROM articles WHERE fts_match(title, body, 'database') ORDER BY score DESC",
);
expect(rows.length).toBeGreaterThanOrEqual(1);
for (const row of rows) {
expect(row.score).toBeGreaterThan(0);
}
for (let i = 1; i < rows.length; i++) {
expect(rows[i - 1]!.score).toBeGreaterThanOrEqual(rows[i]!.score);
}
});
ftsIt('fts_score() with multi-term query', async () => {
const db = getDb();
await seedArticles(db);
const rows = await db.select<{ score: number; title: string }>(
"SELECT fts_score(title, body, 'SQLite database') AS score, title FROM articles WHERE fts_match(title, body, 'SQLite database') ORDER BY score DESC",
);
expect(rows.length).toBeGreaterThanOrEqual(1);
for (const row of rows) {
expect(row.score).toBeGreaterThan(0);
}
});
// ---------------------------------------------------------------------------
// fts_highlight() — result highlighting
// ---------------------------------------------------------------------------
ftsIt('fts_highlight() wraps matched terms with markers', async () => {
const db = getDb();
await seedArticles(db);
const rows = await db.select<{ highlighted: string }>(
"SELECT fts_highlight(title, '<b>', '</b>', 'SQLite') AS highlighted FROM articles WHERE fts_match(title, body, 'SQLite') LIMIT 1",
);
expect(rows).toHaveLength(1);
expect(rows[0]!.highlighted).toContain('<b>');
expect(rows[0]!.highlighted).toContain('</b>');
});
// ---------------------------------------------------------------------------
// Column weights
// ---------------------------------------------------------------------------
ftsIt('FTS index with column weights affects ranking', async () => {
const db = getDb();
await db.execute('CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT, body TEXT)');
// "database" appears only in title of post 1, only in body of post 2
await db.execute('INSERT INTO posts (title, body) VALUES (?, ?)', [
'Database Internals',
'This book covers storage engines and distributed systems.',
]);
await db.execute('INSERT INTO posts (title, body) VALUES (?, ?)', [
'Systems Programming',
'Learn about database drivers, networking, and concurrency.',
]);
await db.execute(
"CREATE INDEX idx_posts_fts ON posts USING fts (title, body) WITH (weights = 'title=5.0,body=1.0')",
);
const rows = await db.select<{ score: number; title: string }>(
"SELECT fts_score(title, body, 'database') AS score, title FROM posts WHERE fts_match(title, body, 'database') ORDER BY score DESC",
);
expect(rows.length).toBe(2);
// Post with "database" in the heavily-weighted title should rank higher
expect(rows[0]!.title).toBe('Database Internals');
});
// ---------------------------------------------------------------------------
// Tokenizers
// ---------------------------------------------------------------------------
ftsIt('ngram tokenizer enables substring matching', async () => {
const db = getDb();
await db.execute('CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT)');
await db.execute('INSERT INTO products (name) VALUES (?)', ['JavaScript']);
await db.execute('INSERT INTO products (name) VALUES (?)', ['TypeScript']);
await db.execute('INSERT INTO products (name) VALUES (?)', ['Python']);
await db.execute(
"CREATE INDEX idx_products_fts ON products USING fts (name) WITH (tokenizer = 'ngram')",
);
const rows = await db.select<{ name: string }>(
"SELECT name FROM products WHERE fts_match(name, 'Script') ORDER BY name",
);
expect(rows.length).toBe(2);
const names = rows.map((r) => r.name);
expect(names).toContain('JavaScript');
expect(names).toContain('TypeScript');
});
ftsIt('raw tokenizer performs exact matching', async () => {
const db = getDb();
await db.execute('CREATE TABLE tags (id INTEGER PRIMARY KEY, tag TEXT)');
await db.execute('INSERT INTO tags (tag) VALUES (?)', ['v1.0.0']);
await db.execute('INSERT INTO tags (tag) VALUES (?)', ['v1.0.1']);
await db.execute('INSERT INTO tags (tag) VALUES (?)', ['v2.0.0']);
await db.execute("CREATE INDEX idx_tags_raw ON tags USING fts (tag) WITH (tokenizer = 'raw')");
const rows = await db.select<{ tag: string }>(
"SELECT tag FROM tags WHERE fts_match(tag, 'v1.0.0')",
);
expect(rows).toHaveLength(1);
expect(rows[0]!.tag).toBe('v1.0.0');
});
// ---------------------------------------------------------------------------
// FTS with data mutations
// ---------------------------------------------------------------------------
ftsIt('FTS index reflects newly inserted rows', async () => {
const db = getDb();
await db.execute('CREATE TABLE notes (id INTEGER PRIMARY KEY, text TEXT)');
await db.execute('CREATE INDEX idx_notes_fts ON notes USING fts (text)');
await db.execute('INSERT INTO notes (text) VALUES (?)', ['first note about testing']);
let rows = await db.select<{ id: number }>(
"SELECT id FROM notes WHERE fts_match(text, 'testing')",
);
expect(rows).toHaveLength(1);
await db.execute('INSERT INTO notes (text) VALUES (?)', [
'second note about testing strategies',
]);
rows = await db.select<{ id: number }>("SELECT id FROM notes WHERE fts_match(text, 'testing')");
expect(rows).toHaveLength(2);
});
ftsIt('FTS index reflects deleted rows', async () => {
const db = getDb();
await db.execute('CREATE TABLE notes (id INTEGER PRIMARY KEY, text TEXT)');
await db.execute('CREATE INDEX idx_notes_fts ON notes USING fts (text)');
await db.execute('INSERT INTO notes (text) VALUES (?)', ['important meeting notes']);
await db.execute('INSERT INTO notes (text) VALUES (?)', ['grocery list items']);
await db.execute('DELETE FROM notes WHERE id = 2');
const rows = await db.select<{ text: string }>(
"SELECT text FROM notes WHERE fts_match(text, 'meeting')",
);
expect(rows).toHaveLength(1);
expect(rows[0]!.text).toContain('meeting');
const all = await db.select('SELECT * FROM notes');
expect(all).toHaveLength(1);
});
ftsIt('FTS index reflects updated rows', async () => {
const db = getDb();
await db.execute('CREATE TABLE notes (id INTEGER PRIMARY KEY, text TEXT)');
await db.execute('CREATE INDEX idx_notes_fts ON notes USING fts (text)');
await db.execute('INSERT INTO notes (text) VALUES (?)', ['old content about cats']);
await db.execute('UPDATE notes SET text = ? WHERE id = ?', ['new content about dogs', 1]);
const catRows = await db.select<{ id: number }>(
"SELECT id FROM notes WHERE fts_match(text, 'cats')",
);
expect(catRows).toHaveLength(0);
const dogRows = await db.select<{ id: number }>(
"SELECT id FROM notes WHERE fts_match(text, 'dogs')",
);
expect(dogRows).toHaveLength(1);
});
// ---------------------------------------------------------------------------
// OPTIMIZE INDEX
// ---------------------------------------------------------------------------
ftsIt('OPTIMIZE INDEX runs without error', async () => {
const db = getDb();
await seedArticles(db);
await db.execute('OPTIMIZE INDEX idx_articles_fts');
});
}
@@ -0,0 +1,265 @@
import { it, expect } from 'vitest';
import { DatabaseService } from '@/types/database';
import { migrate, MigrationEntry } from '@/services/database/migrate';
/**
* Shared migration tests exercised against any real DatabaseService.
* Verifies the migration runner works correctly with actual SQLite
* across all three turso backends (native, node, wasm).
*
* Call inside a describe() block:
* describe('Migrations', () => { migrationTests(() => db); });
*/
export function migrationTests(getDb: () => DatabaseService) {
const migrationV1: MigrationEntry[] = [
{
name: '2026030601_create_books',
sql: `
CREATE TABLE books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT
);
`,
},
];
const migrationV2: MigrationEntry[] = [
...migrationV1,
{
name: '2026030602_create_annotations',
sql: `
CREATE TABLE annotations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id INTEGER NOT NULL REFERENCES books(id),
cfi TEXT NOT NULL,
text TEXT
);
`,
},
];
const migrationV3: MigrationEntry[] = [
...migrationV2,
{
name: '2026031501_add_bookmarks',
sql: `
CREATE TABLE bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id INTEGER NOT NULL REFERENCES books(id),
cfi TEXT NOT NULL,
label TEXT
);
CREATE INDEX idx_bookmarks_book ON bookmarks(book_id);
`,
},
];
// ---------------------------------------------------------------------------
// Basic migration
// ---------------------------------------------------------------------------
it('applies migrations and creates tables', async () => {
const db = getDb();
await migrate(db, migrationV1);
// Table should exist and be usable
const result = await db.execute('INSERT INTO books (title, author) VALUES (?, ?)', [
'Test Book',
'Author',
]);
expect(result.rowsAffected).toBe(1);
expect(result.lastInsertId).toBe(1);
const rows = await db.select<{ id: number; title: string; author: string }>(
'SELECT * FROM books',
);
expect(rows).toHaveLength(1);
expect(rows[0]!.title).toBe('Test Book');
});
it('creates the __migrations tracking table', async () => {
const db = getDb();
await migrate(db, migrationV1);
const tracked = await db.select<{ id: number; name: string; applied_at: string }>(
'SELECT * FROM __migrations ORDER BY id',
);
expect(tracked).toHaveLength(1);
expect(tracked[0]!.name).toBe('2026030601_create_books');
expect(tracked[0]!.applied_at).toBeTruthy();
});
it('sets PRAGMA user_version to migration count', async () => {
const db = getDb();
await migrate(db, migrationV2);
const rows = await db.select<{ user_version: number }>('PRAGMA user_version');
expect(rows[0]!.user_version).toBe(2);
});
// ---------------------------------------------------------------------------
// Idempotency
// ---------------------------------------------------------------------------
it('is idempotent — running twice has no effect', async () => {
const db = getDb();
await migrate(db, migrationV2);
// Insert data
await db.execute('INSERT INTO books (title) VALUES (?)', ['Book 1']);
// Run again — should not duplicate tables or data
await migrate(db, migrationV2);
const books = await db.select<{ title: string }>('SELECT * FROM books');
expect(books).toHaveLength(1);
const tracked = await db.select('SELECT * FROM __migrations');
expect(tracked).toHaveLength(2);
const rows = await db.select<{ user_version: number }>('PRAGMA user_version');
expect(rows[0]!.user_version).toBe(2);
});
// ---------------------------------------------------------------------------
// Incremental migration
// ---------------------------------------------------------------------------
it('applies only new migrations incrementally', async () => {
const db = getDb();
// First: apply V1
await migrate(db, migrationV1);
let version = await db.select<{ user_version: number }>('PRAGMA user_version');
expect(version[0]!.user_version).toBe(1);
// Second: apply V2 (adds annotations)
await migrate(db, migrationV2);
version = await db.select<{ user_version: number }>('PRAGMA user_version');
expect(version[0]!.user_version).toBe(2);
// Verify annotations table works
await db.execute('INSERT INTO books (title) VALUES (?)', ['B1']);
await db.execute('INSERT INTO annotations (book_id, cfi, text) VALUES (?, ?, ?)', [
1,
'/2/4',
'A note',
]);
const annotations = await db.select<{ text: string }>('SELECT text FROM annotations');
expect(annotations).toHaveLength(1);
expect(annotations[0]!.text).toBe('A note');
// Third: apply V3 (adds bookmarks + index)
await migrate(db, migrationV3);
version = await db.select<{ user_version: number }>('PRAGMA user_version');
expect(version[0]!.user_version).toBe(3);
// Verify bookmarks table works
await db.execute('INSERT INTO bookmarks (book_id, cfi, label) VALUES (?, ?, ?)', [
1,
'/2/8',
'Chapter 3',
]);
const bookmarks = await db.select<{ label: string }>('SELECT label FROM bookmarks');
expect(bookmarks).toHaveLength(1);
expect(bookmarks[0]!.label).toBe('Chapter 3');
});
// ---------------------------------------------------------------------------
// Multi-statement migration
// ---------------------------------------------------------------------------
it('handles migrations with multiple SQL statements', async () => {
const db = getDb();
await migrate(db, migrationV3);
// V3 migration creates both a table and an index
// Verify the index exists via sqlite_master
const indexes = await db.select<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_bookmarks_book'",
);
expect(indexes).toHaveLength(1);
expect(indexes[0]!.name).toBe('idx_bookmarks_book');
});
// ---------------------------------------------------------------------------
// Empty migrations
// ---------------------------------------------------------------------------
it('does nothing for empty migration list', async () => {
const db = getDb();
await migrate(db, []);
// No tracking table should be created
const tables = await db.select<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = '__migrations'",
);
expect(tables).toHaveLength(0);
});
// ---------------------------------------------------------------------------
// Custom tracking table
// ---------------------------------------------------------------------------
it('supports custom tracking table name', async () => {
const db = getDb();
await migrate(db, migrationV1, { table: '__schema_history' });
const tracked = await db.select<{ name: string }>('SELECT name FROM __schema_history');
expect(tracked).toHaveLength(1);
expect(tracked[0]!.name).toBe('2026030601_create_books');
// Default table should not exist
const defaultTable = await db.select(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = '__migrations'",
);
expect(defaultTable).toHaveLength(0);
});
// ---------------------------------------------------------------------------
// Fast-path verification
// ---------------------------------------------------------------------------
it('fast-path skips when user_version is current', async () => {
const db = getDb();
await migrate(db, migrationV2);
// Manually verify user_version is set
const before = await db.select<{ user_version: number }>('PRAGMA user_version');
expect(before[0]!.user_version).toBe(2);
// Running same migrations again should hit the fast-path
// and not touch the tracking table
await migrate(db, migrationV2);
const after = await db.select<{ user_version: number }>('PRAGMA user_version');
expect(after[0]!.user_version).toBe(2);
const tracked = await db.select('SELECT * FROM __migrations');
expect(tracked).toHaveLength(2);
});
// ---------------------------------------------------------------------------
// Data survives migration
// ---------------------------------------------------------------------------
it('existing data survives new migrations', async () => {
const db = getDb();
// Apply V1 and insert data
await migrate(db, migrationV1);
await db.execute('INSERT INTO books (title, author) VALUES (?, ?)', ['Book A', 'Author A']);
await db.execute('INSERT INTO books (title, author) VALUES (?, ?)', ['Book B', 'Author B']);
// Apply V2 — books data should survive
await migrate(db, migrationV2);
const books = await db.select<{ title: string; author: string }>(
'SELECT title, author FROM books ORDER BY id',
);
expect(books).toHaveLength(2);
expect(books[0]!.title).toBe('Book A');
expect(books[1]!.title).toBe('Book B');
});
}
@@ -0,0 +1,236 @@
import { it, expect } from 'vitest';
import { DatabaseService } from '@/types/database';
/**
* Shared vector search test cases for Turso's built-in vector functions.
* Call this inside a describe() block after setting up a DatabaseService instance.
*
* Vector support is available in both the native (Node.js) and WASM backends.
*
* API reference:
* - vector32 / vector / vector64 / vector8 / vector1bit constructors
* - vector_distance_cos(a, b) cosine distance
* - vector_distance_l2(a, b) Euclidean distance
* - vector_distance_dot(a, b) negative dot product
* - vector_extract(blob) BLOB JSON text
* - vector_concat(a, b) merge vectors
* - vector_slice(blob, s, e) extract sub-vector
*
* Reference: https://docs.turso.tech/sql-reference/functions/vector
*/
export function vectorTests(getDb: () => DatabaseService) {
// ---------------------------------------------------------------------------
// Vector creation & storage
// ---------------------------------------------------------------------------
it('stores and retrieves vector32 embeddings', async () => {
const db = getDb();
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, embedding BLOB)');
await db.execute('INSERT INTO items (embedding) VALUES (vector32(?))', [
'[1.0, 2.0, 3.0, 4.0]',
]);
const rows = await db.select<{ id: number; json: string }>(
'SELECT id, vector_extract(embedding) AS json FROM items',
);
expect(rows).toHaveLength(1);
const parsed: number[] = JSON.parse(rows[0]!.json);
expect(parsed).toHaveLength(4);
expect(parsed[0]).toBeCloseTo(1.0);
expect(parsed[3]).toBeCloseTo(4.0);
});
it('vector() is an alias for vector32()', async () => {
const db = getDb();
const rows = await db.select<{ eq: number }>(
"SELECT vector_extract(vector('[1,2,3]')) = vector_extract(vector32('[1,2,3]')) AS eq",
);
expect(rows[0]!.eq).toBe(1);
});
it('stores and retrieves vector64 embeddings', async () => {
const db = getDb();
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, embedding BLOB)');
await db.execute('INSERT INTO items (embedding) VALUES (vector64(?))', ['[1.0, 2.0, 3.0]']);
const rows = await db.select<{ json: string }>(
'SELECT vector_extract(embedding) AS json FROM items',
);
const parsed: number[] = JSON.parse(rows[0]!.json);
expect(parsed).toHaveLength(3);
expect(parsed[0]).toBeCloseTo(1.0);
});
// ---------------------------------------------------------------------------
// Distance functions
// ---------------------------------------------------------------------------
it('vector_distance_cos() returns 0 for identical vectors', async () => {
const db = getDb();
const rows = await db.select<{ d: number }>(
"SELECT vector_distance_cos(vector32('[1,2,3]'), vector32('[1,2,3]')) AS d",
);
expect(rows[0]!.d).toBeCloseTo(0, 5);
});
it('vector_distance_cos() returns higher distance for dissimilar vectors', async () => {
const db = getDb();
const rows = await db.select<{ d_similar: number; d_different: number }>(
`SELECT
vector_distance_cos(vector32('[1,0,0]'), vector32('[0.9,0.1,0]')) AS d_similar,
vector_distance_cos(vector32('[1,0,0]'), vector32('[0,0,1]')) AS d_different`,
);
expect(rows[0]!.d_similar).toBeLessThan(rows[0]!.d_different);
});
it('vector_distance_l2() returns 0 for identical vectors', async () => {
const db = getDb();
const rows = await db.select<{ d: number }>(
"SELECT vector_distance_l2(vector32('[3,4]'), vector32('[3,4]')) AS d",
);
expect(rows[0]!.d).toBeCloseTo(0, 5);
});
it('vector_distance_l2() computes correct Euclidean distance', async () => {
const db = getDb();
// distance between [0,0] and [3,4] should be 5
const rows = await db.select<{ d: number }>(
"SELECT vector_distance_l2(vector32('[0,0]'), vector32('[3,4]')) AS d",
);
expect(rows[0]!.d).toBeCloseTo(5.0, 4);
});
it('vector_distance_dot() returns more negative for similar vectors', async () => {
const db = getDb();
const rows = await db.select<{ d_similar: number; d_different: number }>(
`SELECT
vector_distance_dot(vector32('[1,1,1]'), vector32('[1,1,1]')) AS d_similar,
vector_distance_dot(vector32('[1,1,1]'), vector32('[-1,-1,-1]')) AS d_different`,
);
// dot returns negative dot product; more negative = more similar
expect(rows[0]!.d_similar).toBeLessThan(rows[0]!.d_different);
});
// ---------------------------------------------------------------------------
// Similarity search pattern
// ---------------------------------------------------------------------------
it('finds nearest neighbors by cosine distance', async () => {
const db = getDb();
await db.execute('CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT, embedding BLOB)');
await db.execute("INSERT INTO docs (title, embedding) VALUES (?, vector32('[1,0,0,0]'))", [
'Doc A',
]);
await db.execute("INSERT INTO docs (title, embedding) VALUES (?, vector32('[0,1,0,0]'))", [
'Doc B',
]);
await db.execute(
"INSERT INTO docs (title, embedding) VALUES (?, vector32('[0.95,0.05,0,0]'))",
['Doc C'],
);
// Query vector is close to [1,0,0,0]
const rows = await db.select<{ title: string; distance: number }>(
`SELECT title,
vector_distance_cos(embedding, vector32('[1,0,0,0]')) AS distance
FROM docs ORDER BY distance ASC LIMIT 2`,
);
expect(rows).toHaveLength(2);
// Doc A (exact match) and Doc C (very close) should be the nearest
expect(rows[0]!.title).toBe('Doc A');
expect(rows[1]!.title).toBe('Doc C');
expect(rows[0]!.distance).toBeCloseTo(0, 4);
expect(rows[0]!.distance).toBeLessThan(rows[1]!.distance);
});
it('finds nearest neighbors by L2 distance', async () => {
const db = getDb();
await db.execute('CREATE TABLE points (id INTEGER PRIMARY KEY, pos BLOB)');
await db.execute("INSERT INTO points (pos) VALUES (vector32('[0,0]'))");
await db.execute("INSERT INTO points (pos) VALUES (vector32('[3,4]'))");
await db.execute("INSERT INTO points (pos) VALUES (vector32('[1,1]'))");
const rows = await db.select<{ id: number; dist: number }>(
`SELECT id, vector_distance_l2(pos, vector32('[0,0]')) AS dist
FROM points ORDER BY dist ASC`,
);
expect(rows).toHaveLength(3);
// Origin first, then [1,1] (dist ~1.41), then [3,4] (dist 5)
expect(rows[0]!.dist).toBeCloseTo(0, 4);
expect(rows[1]!.dist).toBeCloseTo(Math.sqrt(2), 4);
expect(rows[2]!.dist).toBeCloseTo(5, 4);
});
// ---------------------------------------------------------------------------
// vector_extract()
// ---------------------------------------------------------------------------
it('vector_extract() converts BLOB back to JSON', async () => {
const db = getDb();
const rows = await db.select<{ json: string }>(
"SELECT vector_extract(vector32('[10.5, 20.5, 30.5]')) AS json",
);
const parsed: number[] = JSON.parse(rows[0]!.json);
expect(parsed).toHaveLength(3);
expect(parsed[0]).toBeCloseTo(10.5);
expect(parsed[2]).toBeCloseTo(30.5);
});
// ---------------------------------------------------------------------------
// vector_slice()
// ---------------------------------------------------------------------------
it('vector_slice() extracts a sub-vector', async () => {
const db = getDb();
const rows = await db.select<{ json: string }>(
"SELECT vector_extract(vector_slice(vector32('[10,20,30,40,50]'), 1, 4)) AS json",
);
const parsed: number[] = JSON.parse(rows[0]!.json);
// zero-based: elements at index 1,2,3
expect(parsed).toHaveLength(3);
expect(parsed[0]).toBeCloseTo(20);
expect(parsed[1]).toBeCloseTo(30);
expect(parsed[2]).toBeCloseTo(40);
});
// ---------------------------------------------------------------------------
// vector_concat()
// ---------------------------------------------------------------------------
it('vector_concat() merges two vectors', async () => {
const db = getDb();
const rows = await db.select<{ json: string }>(
"SELECT vector_extract(vector_concat(vector32('[1,2]'), vector32('[3,4]'))) AS json",
);
const parsed: number[] = JSON.parse(rows[0]!.json);
expect(parsed).toHaveLength(4);
expect(parsed).toEqual(
expect.arrayContaining([
expect.closeTo(1),
expect.closeTo(2),
expect.closeTo(3),
expect.closeTo(4),
]),
);
});
// ---------------------------------------------------------------------------
// Mixed precision
// ---------------------------------------------------------------------------
it('vector_distance_cos() works with vector64', async () => {
const db = getDb();
const rows = await db.select<{ d: number }>(
"SELECT vector_distance_cos(vector64('[1,0,0]'), vector64('[1,0,0]')) AS d",
);
expect(rows[0]!.d).toBeCloseTo(0, 5);
});
it('vector_distance_l2() works with vector64', async () => {
const db = getDb();
const rows = await db.select<{ d: number }>(
"SELECT vector_distance_l2(vector64('[0,0]'), vector64('[3,4]')) AS d",
);
expect(rows[0]!.d).toBeCloseTo(5.0, 4);
});
}
@@ -0,0 +1,41 @@
import { describe, beforeEach, afterEach } from 'vitest';
import { NodeDatabaseService } from '@/services/database/nodeDatabaseService';
import { DatabaseService } from '@/types/database';
import { baseTests } from './suites/base-tests';
import { ftsTests } from './suites/fts-tests';
import { vectorTests } from './suites/vector-tests';
import { migrationTests } from './suites/migration-tests';
/**
* Integration tests using a real in-memory SQLite database via @tursodatabase/database.
* These complement the mock-based tests in mock.test.ts by exercising
* actual SQL execution through the DatabaseService interface using the same
* turso engine that powers the browser-based @tursodatabase/database-wasm.
*/
describe('NodeDatabaseService (real in-memory SQLite)', () => {
let db: DatabaseService;
beforeEach(async () => {
db = await NodeDatabaseService.open(':memory:', { experimental: ['index_method'] });
});
afterEach(async () => {
await db.close();
});
describe('Base Operations', () => {
baseTests(() => db);
});
describe('Full-Text Search', () => {
ftsTests(() => db);
});
describe('Vector Search', () => {
vectorTests(() => db);
});
describe('Migrations', () => {
migrationTests(() => db);
});
});
@@ -0,0 +1,111 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { invoke } from '../tauri/tauri-invoke';
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
import { baseTests } from './suites/base-tests';
import { ftsTests } from './suites/fts-tests';
import { vectorTests } from './suites/vector-tests';
import { migrationTests } from './suites/migration-tests';
/**
* Thin DatabaseService adapter over raw Tauri IPC invoke() calls.
* Enables reuse of the shared test suites.
*/
class TauriDatabaseAdapter implements DatabaseService {
constructor(private dbPath: string) {}
async execute(sql: string, params: unknown[] = []): Promise<DatabaseExecResult> {
const result = (await invoke('plugin:turso|execute', {
db: this.dbPath,
query: sql,
values: params,
})) as { rowsAffected: number; lastInsertId: number };
return {
rowsAffected: result.rowsAffected,
lastInsertId: result.lastInsertId,
};
}
async select<T extends DatabaseRow = DatabaseRow>(
sql: string,
params: unknown[] = [],
): Promise<T[]> {
return (await invoke('plugin:turso|select', {
db: this.dbPath,
query: sql,
values: params,
})) as T[];
}
async batch(statements: string[]): Promise<void> {
await invoke('plugin:turso|batch', {
db: this.dbPath,
queries: statements,
});
}
async close(): Promise<void> {
await invoke('plugin:turso|close', { db: this.dbPath });
}
}
/**
* Integration tests for the turso-backed tauri-plugin-turso running inside
* the Tauri WebView. Calls plugin IPC commands via __TAURI_INTERNALS__.invoke().
*
* The database is opened with experimental index_method enabled so that
* FTS (Tantivy-based) CREATE INDEX ... USING fts works.
*/
describe('turso plugin (native Tauri)', () => {
const DB_PATH = 'sqlite::memory:';
let dbPath: string;
let db: DatabaseService;
beforeEach(async () => {
dbPath = (await invoke('plugin:turso|load', {
options: { path: DB_PATH, experimental: ['index_method'] },
})) as string;
db = new TauriDatabaseAdapter(dbPath);
});
afterEach(async () => {
await db.close();
});
// ---------------------------------------------------------------------------
// Connection lifecycle
// ---------------------------------------------------------------------------
it('loads an in-memory database and returns a path', () => {
expect(typeof dbPath).toBe('string');
expect(dbPath.length).toBeGreaterThan(0);
});
// ---------------------------------------------------------------------------
// Plugin config
// ---------------------------------------------------------------------------
it('get_config returns encryption status', async () => {
const config = (await invoke('plugin:turso|get_config')) as { encrypted: boolean };
expect(typeof config.encrypted).toBe('boolean');
});
// ---------------------------------------------------------------------------
// Shared test suites
// ---------------------------------------------------------------------------
describe('Base Operations', () => {
baseTests(() => db);
});
describe('Full-Text Search', () => {
ftsTests(() => db);
});
describe('Vector Search', () => {
vectorTests(() => db);
});
describe('Migrations', () => {
migrationTests(() => db);
});
});
@@ -0,0 +1,40 @@
import { describe, beforeEach, afterEach } from 'vitest';
import { WebDatabaseService } from '@/services/database/webDatabaseService';
import { DatabaseService } from '@/types/database';
import { baseTests } from './suites/base-tests';
import { ftsTests } from './suites/fts-tests';
import { vectorTests } from './suites/vector-tests';
import { migrationTests } from './suites/migration-tests';
/**
* Browser-based integration tests for WebDatabaseService using @tursodatabase/database-wasm.
* These run in real headless Chromium via @vitest/browser + Playwright, providing
* Web Workers, SharedArrayBuffer, and OPFS support required by the WASM module.
*/
describe('WebDatabaseService (browser WASM, in-memory SQLite)', () => {
let db: DatabaseService;
beforeEach(async () => {
db = await WebDatabaseService.open(':memory:', { experimental: ['index_method'] });
});
afterEach(async () => {
await db.close();
});
describe('Base Operations', () => {
baseTests(() => db);
});
describe('Full-Text Search', () => {
ftsTests(() => db);
});
describe('Vector Search', () => {
vectorTests(() => db);
});
describe('Migrations', () => {
migrationTests(() => db);
});
});
@@ -0,0 +1,303 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
import type { FoliateView } from '@/types/view';
import { wrappedFoliateView } from '@/types/view';
// Register a stub paginator custom element so View.open() doesn't fail in jsdom
if (!customElements.get('foliate-paginator')) {
customElements.define(
'foliate-paginator',
class extends HTMLElement {
override setAttribute() {}
override addEventListener() {}
open() {}
},
);
}
// Mock the paginator module import so View doesn't try to load the real one
vi.mock('foliate-js/paginator.js', () => ({}));
let book: BookDoc;
let view: FoliateView;
let totalSections: number;
const loadEPUB = async () => {
const epubPath = resolve(__dirname, '../fixtures/data/sample-alice.epub');
const buffer = readFileSync(epubPath);
const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' });
const loader = new DocumentLoader(file);
const { book } = await loader.open();
return book;
};
describe('getCFIProgress with real EPUB', () => {
beforeAll(async () => {
book = await loadEPUB();
totalSections = book.sections!.length;
// Import View and create the element, wrapping it like the app does
await import('foliate-js/view.js');
const rawView = document.createElement('foliate-view') as FoliateView;
view = wrappedFoliateView(rawView);
await view.open(book);
}, 30000);
it('should load the EPUB with sections', () => {
expect(book.sections!.length).toBeGreaterThan(0);
expect(view.book).toBe(book);
});
it('should return a valid progress object with all fields', async () => {
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
const doc = await book.sections![sectionIndex]!.createDocument();
const body = doc.body ?? doc.documentElement;
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const firstTextNode = walker.nextNode();
expect(firstTextNode).not.toBeNull();
const range = doc.createRange();
range.setStart(firstTextNode!, 0);
range.setEnd(firstTextNode!, 0);
const cfi = view.getCFI(sectionIndex, range);
const result = await view.getCFIProgress(cfi);
expect(result).not.toBeNull();
// fraction is the overall book progress
expect(result!.fraction).toBeGreaterThanOrEqual(0);
expect(result!.fraction).toBeLessThanOrEqual(1);
// section info
expect(result!.section.current).toBe(sectionIndex);
expect(result!.section.total).toBe(totalSections);
// location info
expect(result!.location.current).toBeGreaterThanOrEqual(0);
expect(result!.location.next).toBeGreaterThanOrEqual(result!.location.current);
expect(result!.location.total).toBeGreaterThan(0);
// time info
expect(result!.time.section).toBeGreaterThanOrEqual(0);
expect(result!.time.total).toBeGreaterThanOrEqual(0);
});
it('should return the smallest fraction for a CFI at the start of a section', async () => {
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
expect(sectionIndex).toBeGreaterThanOrEqual(0);
const doc = await book.sections![sectionIndex]!.createDocument();
const body = doc.body ?? doc.documentElement;
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const firstTextNode = walker.nextNode();
expect(firstTextNode).not.toBeNull();
const range = doc.createRange();
range.setStart(firstTextNode!, 0);
range.setEnd(firstTextNode!, 0);
const cfi = view.getCFI(sectionIndex, range);
const result = await view.getCFIProgress(cfi);
expect(result).not.toBeNull();
expect(result!.section.current).toBe(sectionIndex);
// At the start of a section, fraction should equal the section's start fraction
// (i.e. only prior sections' sizes contribute)
expect(result!.fraction).toBeGreaterThanOrEqual(0);
});
it('should return a larger fraction for a CFI at the end of a section', async () => {
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
const doc = await book.sections![sectionIndex]!.createDocument();
const body = doc.body ?? doc.documentElement;
// Get start CFI
const walkerStart = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const firstTextNode = walkerStart.nextNode()!;
const startRange = doc.createRange();
startRange.setStart(firstTextNode, 0);
startRange.setEnd(firstTextNode, 0);
const startCfi = view.getCFI(sectionIndex, startRange);
const startResult = await view.getCFIProgress(startCfi);
// Get end CFI
const walkerEnd = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
let lastTextNode: Node | null = null;
for (let node = walkerEnd.nextNode(); node; node = walkerEnd.nextNode()) {
lastTextNode = node;
}
expect(lastTextNode).not.toBeNull();
const endRange = doc.createRange();
endRange.setStart(lastTextNode!, lastTextNode!.textContent!.length);
endRange.setEnd(lastTextNode!, lastTextNode!.textContent!.length);
const endCfi = view.getCFI(sectionIndex, endRange);
const endResult = await view.getCFIProgress(endCfi);
expect(startResult).not.toBeNull();
expect(endResult).not.toBeNull();
expect(endResult!.section.current).toBe(sectionIndex);
expect(endResult!.fraction).toBeGreaterThan(startResult!.fraction);
});
it('should return a fraction between start and end for a midpoint CFI', async () => {
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 1000);
expect(sectionIndex).toBeGreaterThanOrEqual(0);
const doc = await book.sections![sectionIndex]!.createDocument();
const body = doc.body ?? doc.documentElement;
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const textNodes: Node[] = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
textNodes.push(node);
}
expect(textNodes.length).toBeGreaterThan(0);
const midNodeIndex = Math.floor(textNodes.length / 2);
const midNode = textNodes[midNodeIndex]!;
const midOffset = Math.floor((midNode.textContent?.length ?? 0) / 2);
const range = doc.createRange();
range.setStart(midNode, midOffset);
range.setEnd(midNode, midOffset);
const cfi = view.getCFI(sectionIndex, range);
const result = await view.getCFIProgress(cfi);
expect(result).not.toBeNull();
expect(result!.section.current).toBe(sectionIndex);
expect(result!.fraction).toBeGreaterThan(0);
expect(result!.fraction).toBeLessThan(1);
});
it('should produce consistent results for the same CFI', async () => {
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 500);
const doc = await book.sections![sectionIndex]!.createDocument();
const body = doc.body ?? doc.documentElement;
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const node = walker.nextNode();
expect(node).not.toBeNull();
const range = doc.createRange();
const offset = Math.min(3, node!.textContent!.length);
range.setStart(node!, offset);
range.setEnd(node!, offset);
const cfi = view.getCFI(sectionIndex, range);
const result1 = await view.getCFIProgress(cfi);
const result2 = await view.getCFIProgress(cfi);
expect(result1).not.toBeNull();
expect(result2).not.toBeNull();
expect(result1!.fraction).toBe(result2!.fraction);
expect(result1!.section.current).toBe(result2!.section.current);
expect(result1!.location.current).toBe(result2!.location.current);
});
it('should return increasing fractions for successive CFIs in the same section', async () => {
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 2000);
expect(sectionIndex).toBeGreaterThanOrEqual(0);
const doc = await book.sections![sectionIndex]!.createDocument();
const body = doc.body ?? doc.documentElement;
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const textNodes: Node[] = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
textNodes.push(node);
}
const positions = [0, Math.floor(textNodes.length / 4), Math.floor((textNodes.length * 3) / 4)];
const fractions: number[] = [];
for (const pos of positions) {
const node = textNodes[pos]!;
const range = doc.createRange();
range.setStart(node, 0);
range.setEnd(node, 0);
const cfi = view.getCFI(sectionIndex, range);
const result = await view.getCFIProgress(cfi);
expect(result).not.toBeNull();
expect(result!.section.current).toBe(sectionIndex);
fractions.push(result!.fraction);
}
for (let i = 1; i < fractions.length; i++) {
expect(fractions[i]).toBeGreaterThan(fractions[i - 1]!);
}
});
it('should return increasing fractions across different sections', async () => {
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no' && s.size > 0);
expect(linearSections.length).toBeGreaterThan(1);
const fractions: number[] = [];
for (const { s, i } of linearSections.slice(0, 3)) {
const doc = await s.createDocument();
const body = doc.body ?? doc.documentElement;
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
const firstTextNode = walker.nextNode();
if (!firstTextNode) continue;
const range = doc.createRange();
range.setStart(firstTextNode, 0);
range.setEnd(firstTextNode, 0);
const cfi = view.getCFI(i, range);
const result = await view.getCFIProgress(cfi);
expect(result).not.toBeNull();
expect(result!.section.current).toBe(i);
expect(result!.fraction).toBeGreaterThanOrEqual(0);
expect(result!.fraction).toBeLessThanOrEqual(1);
fractions.push(result!.fraction);
}
// Fractions across sections should be increasing
for (let i = 1; i < fractions.length; i++) {
expect(fractions[i]).toBeGreaterThan(fractions[i - 1]!);
}
});
it('should return null for an invalid CFI', async () => {
const result = await view.getCFIProgress('epubcfi(/99/999!/0)');
expect(result).toBeNull();
});
it('should return progress for real CFIs', async () => {
const expectedData = [
{ cfi: 'epubcfi(/99/999!/0)', fraction: null },
{ cfi: 'epubcfi(/6/4!/4/2/6,/1:0,/1:13)', fraction: 0.009, page: 1 },
{ cfi: 'epubcfi(/6/8!/4/2/2[chapter_458]/4/18,/1:0,/1:681)', fraction: 0.045, page: 5 },
{ cfi: 'epubcfi(/6/10!/4/2/2[chapter_460]/2,/3:1,/3:18)', fraction: 0.098, page: 11 },
{ cfi: 'epubcfi(/6/10!/4/2/2[chapter_460]/4/70,/1:0,/1:261)', fraction: 0.161, page: 19 },
{ cfi: 'epubcfi(/6/12!/4/2/2[chapter_462]/4/22,/1:0,/1:156)', fraction: 0.179, page: 21 },
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/6,/1:0,/1:65)', fraction: 0.411, page: 46 },
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/94,/1:1,/1:30)', fraction: 0.446, page: 51 },
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/134,/1:0,/1:58)', fraction: 0.473, page: 54 },
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/170,/1:0,/1:124)', fraction: 0.491, page: 55 },
{ cfi: 'epubcfi(/6/26!/4/2/2[chapter_476]/4/120,/1:0,/1:117)', fraction: 0.804, page: 91 },
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4,/82/1:0,/86/1:26)', fraction: 0.955, page: 107 },
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4/134,/1:1,/1:118)', fraction: 0.955, page: 108 },
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4/134,/1:119,/1:358)', fraction: 0.955, page: 108 },
];
for (const { cfi, fraction, page } of expectedData) {
const result = await view.getCFIProgress(cfi);
if (fraction === null) {
expect(result).toBeNull();
} else {
expect(result).not.toBeNull();
expect(result!.fraction).toBeCloseTo(fraction, 1);
expect(result!.location.current).toBe(page! - 1);
expect(Math.abs(result!.fraction - fraction)).toBeLessThan(0.006);
}
}
});
});
@@ -0,0 +1,223 @@
import { describe, it, expect, vi } from 'vitest';
import { PageProgress } from 'foliate-js/progress.js';
const createHTML = (html: string): Document => {
const parser = new DOMParser();
return parser.parseFromString(`<!DOCTYPE html><html><body>${html}</body></html>`, 'text/html');
};
const makeBook = (sections: { html: string }[]) => ({
sections: sections.map(({ html }) => ({
createDocument: () => Promise.resolve(createHTML(html)),
})),
});
describe('PageProgress', () => {
describe('getProgress', () => {
it('should return fraction 0 when anchor targets the start of a section', async () => {
const book = makeBook([{ html: '<p>Hello world</p>' }]);
const resolveNavigation = () => ({
index: 0,
anchor: (doc: Document) => {
const text = doc.body.querySelector('p')!.firstChild!;
const range = doc.createRange();
range.setStart(text, 0);
range.setEnd(text, 0);
return range;
},
});
const pp = new PageProgress(book, resolveNavigation);
const result = await pp.getProgress('cfi-start');
expect(result).not.toBeNull();
expect(result!.fraction).toBe(0);
expect(result!.index).toBe(0);
});
it('should return fraction 1 when anchor targets the end of a section', async () => {
const book = makeBook([{ html: '<p>Hello world</p>' }]);
const resolveNavigation = () => ({
index: 0,
anchor: (doc: Document) => {
const text = doc.body.querySelector('p')!.firstChild!;
const range = doc.createRange();
range.setStart(text, text.textContent!.length);
range.setEnd(text, text.textContent!.length);
return range;
},
});
const pp = new PageProgress(book, resolveNavigation);
const result = await pp.getProgress('cfi-end');
expect(result).not.toBeNull();
expect(result!.fraction).toBe(1);
expect(result!.index).toBe(0);
});
it('should return correct fraction for a midpoint in a section', async () => {
const book = makeBook([{ html: '<p>abcdefghij</p>' }]);
const resolveNavigation = () => ({
index: 0,
anchor: (doc: Document) => {
const text = doc.body.querySelector('p')!.firstChild!;
const range = doc.createRange();
range.setStart(text, 5);
range.setEnd(text, 5);
return range;
},
});
const pp = new PageProgress(book, resolveNavigation);
const result = await pp.getProgress('cfi-mid');
expect(result).not.toBeNull();
expect(result!.fraction).toBe(0.5);
expect(result!.index).toBe(0);
});
it('should handle multiple text nodes across elements', async () => {
const book = makeBook([{ html: '<p>aaaa</p><p>bbbb</p><p>cccc</p>' }]);
const resolveNavigation = () => ({
index: 0,
anchor: (doc: Document) => {
// target start of second <p>, which is at offset 4 of 12 total chars
const text = doc.body.querySelectorAll('p')[1]!.firstChild!;
const range = doc.createRange();
range.setStart(text, 0);
range.setEnd(text, 0);
return range;
},
});
const pp = new PageProgress(book, resolveNavigation);
const result = await pp.getProgress('cfi-multi');
expect(result).not.toBeNull();
expect(result!.fraction).toBeCloseTo(4 / 12);
expect(result!.index).toBe(0);
});
it('should handle anchor returning an Element instead of a Range', async () => {
const book = makeBook([{ html: '<p>first</p><p id="target">second</p><p>third</p>' }]);
const resolveNavigation = () => ({
index: 0,
anchor: (doc: Document) => doc.getElementById('target')!,
});
const pp = new PageProgress(book, resolveNavigation);
const result = await pp.getProgress('cfi-element');
expect(result).not.toBeNull();
// "first" is 5 chars, "second" starts at offset 5 of 16 total
expect(result!.fraction).toBeCloseTo(5 / 16);
expect(result!.index).toBe(0);
});
it('should return correct index for non-zero section', async () => {
const book = makeBook([
{ html: '<p>chapter one</p>' },
{ html: '<p>chapter two content</p>' },
]);
const resolveNavigation = () => ({
index: 1,
anchor: (doc: Document) => {
const text = doc.body.querySelector('p')!.firstChild!;
const range = doc.createRange();
range.setStart(text, 0);
range.setEnd(text, 0);
return range;
},
});
const pp = new PageProgress(book, resolveNavigation);
const result = await pp.getProgress('cfi-section1');
expect(result).not.toBeNull();
expect(result!.fraction).toBe(0);
expect(result!.index).toBe(1);
});
it('should return null when resolveNavigation returns no index', async () => {
const book = makeBook([{ html: '<p>text</p>' }]);
const resolveNavigation = (): { index: undefined; anchor: undefined } => ({
index: undefined,
anchor: undefined,
});
const pp = new PageProgress(book, resolveNavigation);
const result = await pp.getProgress('bad-cfi');
expect(result).toBeNull();
});
it('should return null when resolveNavigation returns no anchor', async () => {
const book = makeBook([{ html: '<p>text</p>' }]);
const resolveNavigation = (): { index: number; anchor: undefined } => ({
index: 0,
anchor: undefined,
});
const pp = new PageProgress(book, resolveNavigation);
const result = await pp.getProgress('no-anchor');
expect(result).toBeNull();
});
it('should return null when section has no createDocument', async () => {
const book = { sections: [{}] };
const resolveNavigation = () => ({
index: 0,
anchor: () => null,
});
const pp = new PageProgress(book, resolveNavigation);
const result = await pp.getProgress('no-doc');
expect(result).toBeNull();
});
it('should return fraction 0 for an empty document', async () => {
const book = makeBook([{ html: '' }]);
const resolveNavigation = () => ({
index: 0,
anchor: (doc: Document) => {
const range = doc.createRange();
range.selectNodeContents(doc.body);
return range;
},
});
const pp = new PageProgress(book, resolveNavigation);
const result = await pp.getProgress('cfi-empty');
expect(result).not.toBeNull();
expect(result!.fraction).toBe(0);
});
it('should return null and log error when resolveNavigation throws', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const book = makeBook([{ html: '<p>text</p>' }]);
const resolveNavigation = () => {
throw new Error('bad cfi');
};
const pp = new PageProgress(book, resolveNavigation);
const result = await pp.getProgress('invalid');
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('should handle nested elements correctly', async () => {
const book = makeBook([{ html: '<div><span>ab</span><em>cd</em></div><p>ef</p>' }]);
const resolveNavigation = () => ({
index: 0,
anchor: (doc: Document) => {
// target the <em> text node "cd" at offset 0, which is at char 2 of 6
const text = doc.body.querySelector('em')!.firstChild!;
const range = doc.createRange();
range.setStart(text, 0);
range.setEnd(text, 2);
return range;
},
});
const pp = new PageProgress(book, resolveNavigation);
const result = await pp.getProgress('cfi-nested');
expect(result).not.toBeNull();
expect(result!.fraction).toBeCloseTo(2 / 6);
});
});
});
@@ -0,0 +1,501 @@
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';
// 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 () => {
const resp = await fetch(EPUB_URL);
const buffer = await resp.arrayBuffer();
const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' });
const loader = new DocumentLoader(file);
const { book } = await loader.open();
return book;
};
/**
* Wait for the paginator to emit 'stabilized'.
* MUST be called BEFORE the action that triggers stabilization (e.g. goTo),
* because #display dispatches 'stabilized' synchronously before returning.
*/
const waitForStabilized = (el: HTMLElement, timeout = 10000) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout);
el.addEventListener(
'stabilized',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
/** Wait until `getContents().length >= n` or timeout. */
const waitForViews = async (el: PaginatorElement, n: number, timeout = 10000) => {
const start = Date.now();
while (Date.now() - start < timeout) {
if (el.getContents().length >= n) return;
await new Promise((r) => setTimeout(r, 100));
}
};
/** Wait for fill to complete by polling until getContents count stabilizes. */
const waitForFillComplete = async (el: PaginatorElement, timeout = 10000) => {
const start = Date.now();
let lastCount = -1;
let stableFor = 0;
while (Date.now() - start < timeout) {
const count = el.getContents().length;
if (count === lastCount) {
stableFor += 100;
if (stableFor >= 500) return;
} else {
stableFor = 0;
lastCount = count;
}
await new Promise((r) => setTimeout(r, 100));
}
};
describe('Paginator multi-view architecture (browser)', () => {
let paginator: PaginatorElement;
beforeAll(async () => {
book = await loadEPUB();
await import('foliate-js/paginator.js');
}, 30000);
const createPaginator = () => {
const el = document.createElement('foliate-paginator') as PaginatorElement;
// The paginator needs non-zero dimensions for layout calculations
Object.assign(el.style, {
width: '800px',
height: '600px',
position: 'absolute',
left: '0',
top: '0',
});
document.body.appendChild(el);
return el;
};
/** Create paginator, open book, navigate to section, wait for stabilized. */
const setupAt = async (index: number) => {
paginator = createPaginator();
paginator.open(book);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index });
await stabilized;
};
afterEach(() => {
if (paginator) {
try {
paginator.destroy();
} catch {
/* iframe body may already be torn down */
}
paginator.remove();
}
});
describe('Initial state', () => {
it('should expose primaryIndex as -1 before any section is loaded', () => {
paginator = createPaginator();
paginator.open(book);
expect(paginator.primaryIndex).toBe(-1);
});
it('should have no pages before loading a section', () => {
paginator = createPaginator();
paginator.open(book);
expect(paginator.pages).toBe(0);
});
it('should return empty contents before loading', () => {
paginator = createPaginator();
paginator.open(book);
expect(paginator.getContents()).toEqual([]);
});
});
describe('Single section display', () => {
it('should set primaryIndex after goTo', async () => {
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(firstLinear);
expect(paginator.primaryIndex).toBe(firstLinear);
});
it('should have positive page count after loading', async () => {
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(firstLinear);
expect(paginator.pages).toBeGreaterThan(0);
});
it('should emit stabilized event after display', async () => {
paginator = createPaginator();
paginator.open(book);
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
let stabilizedFired = false;
paginator.addEventListener('stabilized', () => {
stabilizedFired = true;
});
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: firstLinear });
await stabilized;
expect(stabilizedFired).toBe(true);
});
it('should have at least one entry in getContents after loading', async () => {
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(firstLinear);
const contents = paginator.getContents();
expect(contents.length).toBeGreaterThanOrEqual(1);
expect(contents.find((c) => c.index === firstLinear)).toBeDefined();
});
it('should have a valid document in getContents entries', async () => {
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(firstLinear);
const contents = paginator.getContents();
const primary = contents.find((c) => c.index === firstLinear);
expect(primary).toBeDefined();
// Iframe documents have a different Document constructor than the
// test's global scope, so use nodeType check instead of instanceof.
expect(primary!.doc.nodeType).toBe(Node.DOCUMENT_NODE);
expect(primary!.doc.body).toBeTruthy();
});
});
describe('Multi-view filling', () => {
it('should load adjacent sections after display', async () => {
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(firstLinear);
await waitForViews(paginator, 2);
const contents = paginator.getContents();
expect(contents.length).toBeGreaterThan(1);
});
it('should return contents sorted by section index', async () => {
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(firstLinear);
await waitForViews(paginator, 2);
const contents = paginator.getContents();
const indices = contents.map((c) => c.index);
expect(indices).toEqual([...indices].sort((a, b) => a - b));
});
it('should include the primary section in getContents', async () => {
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(firstLinear);
await waitForViews(paginator, 2);
const contents = paginator.getContents();
const primary = contents.find((c) => c.index === firstLinear);
expect(primary).toBeDefined();
});
});
describe('Navigation between sections', () => {
it('should update primaryIndex when navigating to a different section', async () => {
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no');
expect(linearSections.length).toBeGreaterThan(1);
const first = linearSections[0]!.i;
await setupAt(first);
expect(paginator.primaryIndex).toBe(first);
// Second goTo may reuse an already-loaded view (no 'stabilized' event).
// Just await the goTo promise directly.
const second = linearSections[1]!.i;
await paginator.goTo({ index: second });
await new Promise((r) => setTimeout(r, 200));
expect(paginator.primaryIndex).toBe(second);
});
it('should navigate to a section by fraction anchor', async () => {
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no' && s.size > 2000);
const idx = linearSections[0]!.i;
paginator = createPaginator();
paginator.open(book);
const stabilized = waitForStabilized(paginator);
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);
});
it('should navigate to a later section and back', async () => {
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no');
expect(linearSections.length).toBeGreaterThan(2);
const later = linearSections[Math.min(5, linearSections.length - 1)]!.i;
await setupAt(later);
expect(paginator.primaryIndex).toBe(later);
// Navigate back to first — far enough that views won't be reused
const first = linearSections[0]!.i;
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: first });
await stabilized;
expect(paginator.primaryIndex).toBe(first);
});
});
describe('Relocate events', () => {
it('should emit relocate with fraction data after display', async () => {
paginator = createPaginator();
paginator.open(book);
const relocates: Array<{ index: number; fraction: number; reason: string }> = [];
paginator.addEventListener('relocate', ((e: CustomEvent) => {
relocates.push(e.detail);
}) as EventListener);
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: firstLinear });
await stabilized;
// Give time for afterScroll to fire
await new Promise((r) => setTimeout(r, 500));
expect(relocates.length).toBeGreaterThan(0);
const last = relocates[relocates.length - 1]!;
expect(last.index).toBe(firstLinear);
expect(typeof last.fraction).toBe('number');
expect(last.fraction).toBeGreaterThanOrEqual(0);
expect(last.fraction).toBeLessThanOrEqual(1);
});
it('should report fraction=0 when anchored at start of section', async () => {
paginator = createPaginator();
paginator.open(book);
const relocates: Array<{ index: number; fraction: number }> = [];
paginator.addEventListener('relocate', ((e: CustomEvent) => {
relocates.push(e.detail);
}) as EventListener);
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: firstLinear, anchor: 0 });
await stabilized;
await new Promise((r) => setTimeout(r, 500));
const last = relocates[relocates.length - 1]!;
expect(last.fraction).toBe(0);
});
});
describe('Two-column layout at 800×600', () => {
// At 800px width, Math.ceil(800/720) = 2 and max-column-count defaults
// to 2, so the paginator uses a two-column (spread) layout.
it('should use columnCount=2 at 800px width', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(idx);
expect(paginator.columnCount).toBe(2);
});
it('should have viewSize >= size for a section with content', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 2000);
await setupAt(idx);
// viewSize includes padding + content columns
expect(paginator.viewSize).toBeGreaterThanOrEqual(paginator.size);
});
it('should compute pages = ceil(viewSize / size)', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 2000);
await setupAt(idx);
const expectedPages = Math.ceil(paginator.viewSize / paginator.size);
expect(paginator.pages).toBe(expectedPages);
});
it('should advance fraction by 2 columns per page turn', async () => {
paginator = createPaginator();
paginator.open(book);
// Pick a section large enough to span multiple spreads
const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 5000);
const relocates: Array<{ fraction: number; size: number }> = [];
paginator.addEventListener('relocate', ((e: CustomEvent) => {
relocates.push({ fraction: e.detail.fraction, size: e.detail.size });
}) as EventListener);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx, anchor: 0 });
await stabilized;
await waitForFillComplete(paginator);
await new Promise((r) => setTimeout(r, 300));
const fractionAtStart = relocates[relocates.length - 1]?.fraction ?? 0;
expect(fractionAtStart).toBe(0);
// Page forward once — should advance by 2 columns worth of content
await paginator.next();
await new Promise((r) => setTimeout(r, 500));
const fractionAfterNext = relocates[relocates.length - 1]!.fraction;
expect(fractionAfterNext).toBeGreaterThan(fractionAtStart);
// The fraction step per page should equal columnCount / textPages.
// With 2 columns, each page turn advances by exactly `size` (detail.size).
const lastSize = relocates[relocates.length - 1]!.size;
expect(lastSize).toBeGreaterThan(0);
expect(lastSize).toBeLessThanOrEqual(1);
// fraction advanced by approximately `size` (2/textPages)
const step = fractionAfterNext - fractionAtStart;
expect(step).toBeCloseTo(lastSize, 1);
});
it('should report two header/footer cells for two-column layout', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(idx);
// In 2-column layout, the paginator creates 2 header and 2 footer cells
const header = paginator.shadowRoot?.getElementById('header');
const footer = paginator.shadowRoot?.getElementById('footer');
expect(header?.children.length).toBe(2);
expect(footer?.children.length).toBe(2);
});
it('should use columnCount=1 in scrolled mode regardless of width', async () => {
paginator = createPaginator();
paginator.open(book);
paginator.setAttribute('flow', 'scrolled');
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
// Scrolled mode always uses 1 column
expect(paginator.columnCount).toBe(1);
});
});
describe('Adjacent index skipping non-linear sections', () => {
it('should skip non-linear sections during navigation', async () => {
paginator = createPaginator();
paginator.open(book);
const sections = book.sections!;
const nonLinearIdx = sections.findIndex((s) => s.linear === 'no');
// If there are non-linear sections, navigating past them should work
if (nonLinearIdx >= 0) {
const prevLinear = sections.slice(0, nonLinearIdx).findLastIndex((s) => s.linear !== 'no');
if (prevLinear >= 0) {
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: prevLinear });
await stabilized;
await waitForFillComplete(paginator);
// Next should skip non-linear and go to next linear
await paginator.next();
await new Promise((r) => setTimeout(r, 500));
expect(paginator.primaryIndex).not.toBe(nonLinearIdx);
expect(sections[paginator.primaryIndex]!.linear).not.toBe('no');
}
}
// If no non-linear sections, the test passes trivially
expect(true).toBe(true);
});
});
describe('Scrolled mode', () => {
it('should support flow=scrolled attribute', async () => {
paginator = createPaginator();
paginator.open(book);
paginator.setAttribute('flow', 'scrolled');
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
expect(paginator.scrolled).toBe(true);
expect(paginator.primaryIndex).toBe(idx);
});
});
});
describe('FoliateView CFI navigation (browser)', () => {
let view: FoliateView;
beforeAll(async () => {
book = await loadEPUB();
// Import both view.js (registers foliate-view) and paginator.js
await import('foliate-js/view.js');
await import('foliate-js/paginator.js');
}, 30000);
const createView = () => {
const el = document.createElement('foliate-view') as FoliateView;
Object.assign(el.style, {
width: '800px',
height: '600px',
position: 'absolute',
left: '0',
top: '0',
});
document.body.appendChild(el);
return el;
};
afterEach(async () => {
if (view) {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
try {
view.close();
} catch {
/* iframe body may already be torn down */
}
view.remove();
}
});
it('should navigate to CFI and render Chapter 5', async () => {
view = createView();
await view.open(book);
const cfi = 'epubcfi(/6/16!/4,,/2/2[chapter_466]/4/18/1:70)';
const { index } = view.resolveCFI(cfi);
// Wait for the renderer (paginator) to stabilize after goTo
const stabilized = new Promise<void>((resolve) => {
view.renderer.addEventListener('stabilized', () => resolve(), { once: true });
});
await view.goTo(cfi);
await stabilized;
// The primary section should contain "Chapter 5"
const contents = view.renderer.getContents();
const primary = contents.find((c) => c.index === index);
expect(primary).toBeDefined();
const headings = primary!.doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
const chapterHeading = Array.from(headings).find((h) => h.textContent?.includes('Chapter 5'));
expect(chapterHeading).toBeDefined();
expect(chapterHeading!.textContent).toContain('Chapter 5');
});
});
@@ -0,0 +1,297 @@
import { describe, it, expect, vi } from 'vitest';
// Mock the paginator module to avoid custom element registration conflicts
vi.mock('foliate-js/paginator.js', () => ({}));
describe('Paginator multi-view architecture', () => {
describe('View padding configuration', () => {
it('should have default padding of {before:1, after:1}', () => {
// The View class has #padding defaulting to {before:1, after:1}
// This is verified by checking that paginator.pages includes 2 padding pages
// when there's a single view (pages = contentPages + 2)
expect(true).toBe(true); // placeholder - tested via integration
});
});
describe('Paginator primaryIndex getter', () => {
it('should expose primaryIndex on the paginator element', async () => {
// Register a stub custom element if not already registered
if (!customElements.get('foliate-paginator')) {
// Use a simplified stub that mirrors the real Paginator's public interface
customElements.define(
'foliate-paginator',
class extends HTMLElement {
#primaryIndex = -1;
get primaryIndex() {
return this.#primaryIndex;
}
},
);
}
const el = document.createElement('foliate-paginator') as HTMLElement & {
primaryIndex: number;
};
expect(el.primaryIndex).toBe(-1);
});
});
describe('getContents returns all loaded views', () => {
it('should return contents sorted by section index', () => {
// With multi-view, getContents() returns entries from all loaded views
// sorted by section index. Each entry has { doc, index, overlayer }.
// This is verified by the fact that consumers can .find() by index.
const contents = [
{ doc: {} as Document, index: 3, overlayer: null },
{ doc: {} as Document, index: 4, overlayer: null },
{ doc: {} as Document, index: 5, overlayer: null },
];
const primaryIndex = 4;
const primary = contents.find((x) => x.index === primaryIndex) ?? contents[0];
expect(primary?.index).toBe(4);
});
it('should fall back to first entry when primaryIndex not found', () => {
const contents = [{ doc: {} as Document, index: 3, overlayer: null }];
const primaryIndex = 99; // not in contents
const primary = contents.find((x) => x.index === primaryIndex) ?? contents[0];
expect(primary?.index).toBe(3);
});
it('should handle empty contents gracefully', () => {
const contents: { doc: Document; index: number; overlayer: unknown }[] = [];
const primaryIndex = 0;
const primary = contents.find((x) => x.index === primaryIndex) ?? contents[0];
expect(primary).toBeUndefined();
});
});
describe('View padding assignment logic', () => {
it('should assign {before:1, after:1} for single view', () => {
const views = [{ index: 5, padding: { before: 0, after: 0 } }];
// Simulate #updateViewPadding
if (views.length === 1) {
views[0]!.padding = { before: 1, after: 1 };
}
expect(views[0]!.padding).toEqual({ before: 1, after: 1 });
});
it('should assign correct padding for multiple views', () => {
const views = [
{ index: 3, padding: { before: 0, after: 0 } },
{ index: 4, padding: { before: 0, after: 0 } },
{ index: 5, padding: { before: 0, after: 0 } },
];
// Simulate #updateViewPadding
for (let i = 0; i < views.length; i++) {
const before = i === 0 ? 1 : 0;
const after = i === views.length - 1 ? 1 : 0;
views[i]!.padding = { before, after };
}
expect(views[0]!.padding).toEqual({ before: 1, after: 0 });
expect(views[1]!.padding).toEqual({ before: 0, after: 0 });
expect(views[2]!.padding).toEqual({ before: 0, after: 1 });
});
it('should assign correct padding for two views', () => {
const views = [
{ index: 3, padding: { before: 0, after: 0 } },
{ index: 4, padding: { before: 0, after: 0 } },
];
for (let i = 0; i < views.length; i++) {
const before = i === 0 ? 1 : 0;
const after = i === views.length - 1 ? 1 : 0;
views[i]!.padding = { before, after };
}
expect(views[0]!.padding).toEqual({ before: 1, after: 0 });
expect(views[1]!.padding).toEqual({ before: 0, after: 1 });
});
});
describe('Page counting across views (column-level sizing)', () => {
// contentPages is now in column units. View element sizes are:
// contentColumns * columnSize + (padding.before + padding.after) * spreadSize
// where columnSize = spreadSize / columnCount
const spreadSize = 800;
const columnCount = 2;
const columnSize = spreadSize / columnCount; // 400
type ViewInfo = {
index: number;
contentPages: number;
padding: { before: number; after: number };
};
const getViewElementSize = (view: ViewInfo) => {
return (
view.contentPages * columnSize + (view.padding.before + view.padding.after) * spreadSize
);
};
const getViewOffset = (views: ViewInfo[], targetIndex: number) => {
let offset = 0;
for (const view of views) {
if (view.index === targetIndex) return offset;
offset += getViewElementSize(view);
}
return offset;
};
// #getPagesBeforeView uses pixel offsets divided by spread size (Math.floor)
const getPagesBeforeView = (views: ViewInfo[], targetIndex: number) => {
return Math.floor(getViewOffset(views, targetIndex) / spreadSize);
};
it('should count spread pages before a given view using pixel offsets', () => {
const views: ViewInfo[] = [
{ index: 3, contentPages: 5, padding: { before: 1, after: 0 } },
{ index: 4, contentPages: 2, padding: { before: 0, after: 0 } },
{ index: 5, contentPages: 8, padding: { before: 0, after: 1 } },
];
expect(getPagesBeforeView(views, 3)).toBe(0);
// View 3: 5*400 + 800 = 2800px → floor(2800/800) = 3
expect(getPagesBeforeView(views, 4)).toBe(3);
// View 3+4: 2800 + 2*400 = 3600 → floor(3600/800) = 4
expect(getPagesBeforeView(views, 5)).toBe(4);
});
it('should compute total pages using Math.ceil of viewSize / spreadSize', () => {
const views: ViewInfo[] = [
{ index: 0, contentPages: 5, padding: { before: 1, after: 0 } },
{ index: 1, contentPages: 2, padding: { before: 0, after: 0 } },
{ index: 2, contentPages: 8, padding: { before: 0, after: 1 } },
];
const totalViewSize = views.reduce((sum, v) => sum + getViewElementSize(v), 0);
// 5*400+800 + 2*400 + 8*400+800 = 2800 + 800 + 4000 = 7600
const totalPages = Math.ceil(totalViewSize / spreadSize);
expect(totalPages).toBe(Math.ceil(7600 / 800)); // 10
});
it('should place 1-column section at first column with next section after', () => {
// Image page (1 col) + normal section (10 cols)
const views: ViewInfo[] = [
{ index: 0, contentPages: 1, padding: { before: 1, after: 0 } },
{ index: 1, contentPages: 10, padding: { before: 0, after: 1 } },
];
// View 0: 1*400 + 800 = 1200. View 1: 10*400 + 800 = 4800
const totalViewSize = views.reduce((sum, v) => sum + getViewElementSize(v), 0);
expect(totalViewSize).toBe(6000);
expect(Math.ceil(totalViewSize / spreadSize)).toBe(8);
// Pages before view 1: floor(1200/800) = 1
expect(getPagesBeforeView(views, 1)).toBe(1);
});
it('should handle single-column layout (columnCount=1) same as before', () => {
const singleColSpread = 800;
const singleColSize = singleColSpread; // columnCount=1
// contentPages=3 means 3 columns, but with columnCount=1, each column IS a spread
const viewSize = 3 * singleColSize + 2 * singleColSpread;
expect(viewSize).toBe(4000);
expect(Math.ceil(viewSize / singleColSpread)).toBe(5); // 3 content + 2 padding
});
});
describe('Fraction and anchor calculation with column-level sizing', () => {
const columnCount = 2;
it('should compute fraction as localColumn / textPages', () => {
// Simulate #afterScroll fraction calculation
const textPages = 6; // 6 content columns
// At spread 0 (first content spread)
const localPage0 = 0;
const localColumn0 = localPage0 * columnCount; // 0
const fraction0 = Math.max(0, Math.min(1, localColumn0 / textPages));
expect(fraction0).toBe(0);
// At spread 1 (second content spread)
const localPage1 = 1;
const localColumn1 = localPage1 * columnCount; // 2
const fraction1 = Math.max(0, Math.min(1, localColumn1 / textPages));
expect(fraction1).toBeCloseTo(1 / 3);
// At spread 2 (third content spread, last)
const localPage2 = 2;
const localColumn2 = localPage2 * columnCount; // 4
const fraction2 = Math.max(0, Math.min(1, localColumn2 / textPages));
expect(fraction2).toBeCloseTo(2 / 3);
});
it('should convert anchor fraction to spread page for scrolling', () => {
const textPages = 5; // 5 content columns
// anchor=0 → column 0 → spread 0
expect(Math.floor(Math.round(0 * (textPages - 1)) / columnCount)).toBe(0);
// anchor=0.5 → column 2 → spread 1
expect(Math.floor(Math.round(0.5 * (textPages - 1)) / columnCount)).toBe(1);
// anchor=1.0 → column 4 → spread 2
expect(Math.floor(Math.round(1.0 * (textPages - 1)) / columnCount)).toBe(2);
});
});
describe('Primary section detection', () => {
it('should detect primary view as first visible view', () => {
// Simulate #detectPrimaryView — uses visibleStart, not midpoint
const views = [
{ index: 3, offset: 0, size: 100 },
{ index: 4, offset: 100, size: 50 },
{ index: 5, offset: 150, size: 200 },
];
const detectPrimary = (visibleStart: number) => {
for (const view of views) {
if (visibleStart < view.offset + view.size) {
return view.index;
}
}
return views[views.length - 1]?.index;
};
expect(detectPrimary(0)).toBe(3); // start in first view
expect(detectPrimary(50)).toBe(3); // still in first view
expect(detectPrimary(100)).toBe(4); // at boundary → second view
expect(detectPrimary(125)).toBe(4); // in second view
expect(detectPrimary(150)).toBe(5); // at boundary → third view
expect(detectPrimary(300)).toBe(5); // in third view
});
});
describe('Adjacent index with fromIndex parameter', () => {
it('should find next linear section from a given index', () => {
const sections = [
{ linear: 'yes' }, // 0
{ linear: 'no' }, // 1 (non-linear)
{ linear: 'yes' }, // 2
{ linear: 'yes' }, // 3
{ linear: 'no' }, // 4 (non-linear)
{ linear: 'yes' }, // 5
];
const adjacentIndex = (dir: number, fromIndex: number) => {
for (let index = fromIndex + dir; index >= 0 && index < sections.length; index += dir) {
if (sections[index]?.linear !== 'no') return index;
}
return undefined;
};
expect(adjacentIndex(1, 0)).toBe(2); // skip non-linear 1
expect(adjacentIndex(1, 2)).toBe(3);
expect(adjacentIndex(1, 3)).toBe(5); // skip non-linear 4
expect(adjacentIndex(-1, 5)).toBe(3); // skip non-linear 4
expect(adjacentIndex(-1, 2)).toBe(0); // skip non-linear 1
expect(adjacentIndex(1, 5)).toBeUndefined(); // end of book
expect(adjacentIndex(-1, 0)).toBeUndefined(); // start of book
});
});
describe('Trim distant views', () => {
it('should keep max 4 views (1 before + primary + 2 after)', () => {
// Simulate #trimDistantViews
const viewIndices = [1, 3, 5, 7, 9, 11];
const primaryIndex = 5;
const sorted = [...viewIndices].sort((a, b) => a - b);
const primaryPos = sorted.indexOf(primaryIndex);
const keep = new Set([primaryIndex]);
if (primaryPos > 0) keep.add(sorted[primaryPos - 1]!);
for (let d = 1; d <= 2; d++) {
if (primaryPos + d < sorted.length) keep.add(sorted[primaryPos + d]!);
}
const remaining = viewIndices.filter((i) => keep.has(i));
expect(remaining).toEqual([3, 5, 7, 9]);
expect(remaining.length).toBeLessThanOrEqual(4);
});
});
});
@@ -0,0 +1,450 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
// 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 () => {
const resp = await fetch(EPUB_URL);
const buffer = await resp.arrayBuffer();
const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' });
const loader = new DocumentLoader(file);
const { book } = await loader.open();
return book;
};
/**
* Wait for the paginator to emit 'stabilized'.
* MUST be called BEFORE the action that triggers stabilization (e.g. goTo),
* because #display dispatches 'stabilized' synchronously before returning.
*/
const waitForStabilized = (el: HTMLElement, timeout = 10000) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout);
el.addEventListener(
'stabilized',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
/** Wait until `getContents().length >= n` or timeout. */
const waitForViews = async (el: PaginatorElement, n: number, timeout = 10000) => {
const start = Date.now();
while (Date.now() - start < timeout) {
if (el.getContents().length >= n) return;
await new Promise((r) => setTimeout(r, 100));
}
};
/** Wait for fill to complete by polling until getContents count stabilizes. */
const waitForFillComplete = async (el: PaginatorElement, timeout = 10000) => {
const start = Date.now();
let lastCount = -1;
let stableFor = 0;
while (Date.now() - start < timeout) {
const count = el.getContents().length;
if (count === lastCount) {
stableFor += 100;
if (stableFor >= 500) return;
} else {
stableFor = 0;
lastCount = count;
}
await new Promise((r) => setTimeout(r, 100));
}
};
describe('Paginator stabilization (browser)', () => {
let paginator: PaginatorElement;
// Suppress unhandled errors from paginator's #replaceBackground firing
// after views are destroyed (queued iframe loads from rapid navigation).
// This is a known paginator cleanup race, not a test failure.
const suppressHandler = (e: ErrorEvent) => {
if (e.message?.includes('getComputedStyle')) e.preventDefault();
};
beforeAll(async () => {
window.addEventListener('error', suppressHandler);
book = await loadEPUB();
await import('foliate-js/paginator.js');
}, 30000);
afterAll(() => {
window.removeEventListener('error', suppressHandler);
});
const createPaginator = () => {
const el = document.createElement('foliate-paginator') as PaginatorElement;
Object.assign(el.style, {
width: '800px',
height: '600px',
position: 'absolute',
left: '0',
top: '0',
});
document.body.appendChild(el);
return el;
};
/** Create paginator, open book, navigate to section, wait for stabilized. */
const setupAt = async (index: number) => {
paginator = createPaginator();
paginator.open(book);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index });
await stabilized;
};
afterEach(async () => {
if (paginator) {
// Flush pending RAFs and iframe load callbacks before destroying.
// Rapid navigation tests can leave queued callbacks that reference
// views — destroying immediately would cause unhandled errors in
// paginator.js (#replaceBackground accessing destroyed documents).
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
try {
paginator.destroy();
} catch {
/* iframe body may already be torn down */
}
paginator.remove();
}
});
describe('Stabilized event lifecycle', () => {
it('should emit stabilized event after goTo completes', async () => {
paginator = createPaginator();
paginator.open(book);
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
let stabilizedCount = 0;
paginator.addEventListener('stabilized', () => {
stabilizedCount++;
});
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
expect(stabilizedCount).toBeGreaterThanOrEqual(1);
});
it('should emit stabilized for each navigation to a new section', async () => {
paginator = createPaginator();
paginator.open(book);
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no');
expect(linearSections.length).toBeGreaterThan(1);
let stabilizedCount = 0;
paginator.addEventListener('stabilized', () => {
stabilizedCount++;
});
const s1 = waitForStabilized(paginator);
await paginator.goTo({ index: linearSections[0]!.i });
await s1;
const countAfterFirst = stabilizedCount;
expect(countAfterFirst).toBeGreaterThanOrEqual(1);
// Navigate to a far section so view is not reused
const farIdx = linearSections[Math.min(5, linearSections.length - 1)]!.i;
const s2 = waitForStabilized(paginator);
await paginator.goTo({ index: farIdx });
await s2;
expect(stabilizedCount).toBeGreaterThan(countAfterFirst);
});
it('should have content visible (opacity=1) after stabilized', async () => {
paginator = createPaginator();
paginator.open(book);
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
let containerOpacity = '';
paginator.addEventListener('stabilized', () => {
// Access shadow DOM container to check opacity
const container = paginator.shadowRoot?.getElementById('container');
containerOpacity = container?.style.opacity ?? '';
});
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
// After stabilized, opacity should be '1' (visible)
expect(containerOpacity).toBe('1');
});
});
describe('Container opacity during stabilization', () => {
it('should set opacity to 0 at start and 1 at end of display', async () => {
paginator = createPaginator();
paginator.open(book);
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
const opacities: string[] = [];
// Observe the shadow DOM container for style changes
const container = paginator.shadowRoot?.getElementById('container');
if (container) {
const observer = new MutationObserver(() => {
opacities.push(container.style.opacity);
});
observer.observe(container, { attributes: true, attributeFilter: ['style'] });
}
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
// Should have seen opacity transitions: 0 → 1
expect(opacities).toContain('0');
expect(opacities).toContain('1');
// The last opacity should be '1' (content visible)
const lastOpacity = opacities[opacities.length - 1];
expect(lastOpacity).toBe('1');
});
});
describe('Font loading integration', () => {
it('should load view even when document has no custom fonts', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(idx);
// View should be loaded with a valid document
const contents = paginator.getContents();
const primary = contents.find((c) => c.index === idx);
expect(primary).toBeDefined();
expect(primary!.doc.fonts).toBeDefined();
});
});
describe('Render micro-stabilization', () => {
it('should emit stabilized when render is called on a loaded paginator', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(idx);
// Wait for fill to fully complete so #stabilizing is false
await waitForFillComplete(paginator);
const stabilizedFromRender = waitForStabilized(paginator);
paginator.render();
// render() dispatches stabilized in a RAF
await stabilizedFromRender;
// If we get here, stabilized was emitted
expect(true).toBe(true);
});
it('should preserve primaryIndex across re-renders', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 1000);
await setupAt(idx);
await waitForFillComplete(paginator);
const indexBefore = paginator.primaryIndex;
const stabilized = waitForStabilized(paginator);
paginator.render();
await stabilized;
expect(paginator.primaryIndex).toBe(indexBefore);
});
});
describe('Stabilization suppresses scroll-to-anchor', () => {
it('should not emit extra relocate events during initial fill', async () => {
paginator = createPaginator();
paginator.open(book);
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
const relocates: Array<{ index: number; fraction: number; reason: string }> = [];
paginator.addEventListener('relocate', ((e: CustomEvent) => {
relocates.push(e.detail);
}) as EventListener);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
// Wait for fill to complete
await waitForFillComplete(paginator);
// All relocate events should reference valid section indices
for (const r of relocates) {
expect(r.index).toBeGreaterThanOrEqual(0);
expect(r.index).toBeLessThan(book.sections!.length);
}
// Primary index should still be the one we navigated to
expect(paginator.primaryIndex).toBe(idx);
});
});
describe('Fill visible area completes after stabilized', () => {
it('should have more views after fill completes than at stabilized time', async () => {
paginator = createPaginator();
paginator.open(book);
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
let viewsAtStabilized = 0;
paginator.addEventListener(
'stabilized',
() => {
viewsAtStabilized = paginator.getContents().length;
},
{ once: true },
);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
// Wait for fill to complete
await waitForFillComplete(paginator);
const viewsAfterFill = paginator.getContents().length;
// Fill should have loaded additional sections beyond what was
// available at stabilized time (or at least the same count if
// the book has very few sections)
expect(viewsAfterFill).toBeGreaterThanOrEqual(viewsAtStabilized);
});
it('should block backward loading while stabilizing', async () => {
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no');
// Navigate to a middle section so there are sections before
const midIdx = linearSections[Math.min(3, linearSections.length - 1)]!.i;
await setupAt(midIdx);
// After stabilized, primary should still be the target
expect(paginator.primaryIndex).toBe(midIdx);
});
});
describe('Scrolled mode stabilization', () => {
it('should stabilize correctly in scrolled mode', async () => {
paginator = createPaginator();
paginator.open(book);
paginator.setAttribute('flow', 'scrolled');
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
expect(paginator.scrolled).toBe(true);
expect(paginator.primaryIndex).toBe(idx);
expect(paginator.getContents().length).toBeGreaterThanOrEqual(1);
});
it('should pre-load previous section in scrolled mode', async () => {
paginator = createPaginator();
paginator.open(book);
paginator.setAttribute('flow', 'scrolled');
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no');
// Navigate to second linear section with anchor=0 (top)
if (linearSections.length > 1) {
const secondIdx = linearSections[1]!.i;
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: secondIdx, anchor: 0 });
await stabilized;
// In scrolled mode, previous section is pre-loaded in #display
await waitForViews(paginator, 2);
const contents = paginator.getContents();
const indices = contents.map((c) => c.index);
// Should have loaded the previous section
const firstIdx = linearSections[0]!.i;
expect(indices).toContain(firstIdx);
}
});
});
describe('Attribute-triggered re-render', () => {
it('should re-stabilize when flow attribute changes', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(idx);
await waitForFillComplete(paginator);
// Switch to scrolled mode — should trigger render() and stabilization
const stabilizedFromSwitch = waitForStabilized(paginator);
paginator.setAttribute('flow', 'scrolled');
await stabilizedFromSwitch;
expect(paginator.scrolled).toBe(true);
});
it('should preserve primaryIndex when switching flow modes', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 1000);
await setupAt(idx);
await waitForFillComplete(paginator);
const indexBefore = paginator.primaryIndex;
// Switch to scrolled
const s1 = waitForStabilized(paginator);
paginator.setAttribute('flow', 'scrolled');
await s1;
expect(paginator.primaryIndex).toBe(indexBefore);
// Wait for fill to complete before switching back
await waitForFillComplete(paginator);
// Switch back to paginated
const s2 = waitForStabilized(paginator);
paginator.removeAttribute('flow');
await s2;
expect(paginator.primaryIndex).toBe(indexBefore);
});
});
describe('Multiple rapid navigations', () => {
it('should settle on the last navigation target after rapid goTo calls', async () => {
paginator = createPaginator();
paginator.open(book);
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no');
expect(linearSections.length).toBeGreaterThan(2);
// Fire multiple navigations rapidly
const first = linearSections[0]!.i;
const second = linearSections[1]!.i;
const third = linearSections[Math.min(2, linearSections.length - 1)]!.i;
// Don't await intermediate — fire them all
paginator.goTo({ index: first });
paginator.goTo({ index: second });
await paginator.goTo({ index: third });
// Wait long enough for all background RAF callbacks to flush
// (setStyles triggers RAF → #replaceBackground which reads computed styles)
await waitForFillComplete(paginator);
// The paginator should have settled on a valid section
expect(paginator.primaryIndex).toBeGreaterThanOrEqual(0);
expect(paginator.getContents().length).toBeGreaterThanOrEqual(1);
});
});
});
@@ -0,0 +1,309 @@
import { describe, it, expect, vi } from 'vitest';
// Mock the paginator module to avoid custom element registration conflicts
vi.mock('foliate-js/paginator.js', () => ({}));
describe('Paginator stabilization', () => {
describe('View.fontReady property', () => {
it('should default to a resolved promise', async () => {
// View.fontReady should initialize to Promise.resolve()
// so awaiting it never blocks when no doc is loaded
const fontReady = Promise.resolve();
await expect(fontReady).resolves.toBeUndefined();
});
it('should be set from doc.fonts.ready in load()', () => {
// After View.load(), fontReady should be assigned from doc.fonts.ready.then(...)
// Simulating the pattern: this.fontReady = doc.fonts.ready.then(() => this.expand())
const expandFn = vi.fn();
const fontsReady = Promise.resolve().then(() => expandFn());
const view = { fontReady: fontsReady };
expect(view.fontReady).toBeInstanceOf(Promise);
});
});
describe('Stabilization flag suppresses scroll-to-anchor', () => {
it('should not call scrollToAnchor during stabilization on expand', () => {
// In #createView, the onExpand callback should check both #filling and #stabilizing
// Simulate: if (!this.#filling && !this.#stabilizing) this.#scrollToAnchor(...)
const scrollToAnchor = vi.fn();
const filling = false;
const stabilizing = true;
// onExpand callback
if (!filling && !stabilizing) scrollToAnchor();
expect(scrollToAnchor).not.toHaveBeenCalled();
});
it('should call scrollToAnchor when not filling and not stabilizing', () => {
const scrollToAnchor = vi.fn();
const filling = false;
const stabilizing = false;
if (!filling && !stabilizing) scrollToAnchor();
expect(scrollToAnchor).toHaveBeenCalledOnce();
});
it('should not call scrollToAnchor when filling', () => {
const scrollToAnchor = vi.fn();
const filling = true;
const stabilizing = false;
if (!filling && !stabilizing) scrollToAnchor();
expect(scrollToAnchor).not.toHaveBeenCalled();
});
});
describe('Font timeout mechanism', () => {
it('should resolve within timeout even if fonts never load', async () => {
const neverResolves = new Promise<void>(() => {
/* intentionally never resolves */
});
const timeout = 50; // shorter for test
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const start = Date.now();
await Promise.race([Promise.all([neverResolves]), wait(timeout)]);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(timeout + 50);
});
it('should resolve immediately if fonts are already loaded', async () => {
const alreadyLoaded = Promise.resolve();
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const start = Date.now();
await Promise.race([Promise.all([alreadyLoaded]), wait(3000)]);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(50);
});
});
describe('Stabilization lifecycle in #display()', () => {
it('should set opacity to 0 at start and 1 at end', () => {
const container = { style: { opacity: '' } };
// Start of display
container.style.opacity = '0';
expect(container.style.opacity).toBe('0');
// End of display (after fillVisibleArea)
container.style.opacity = '1';
expect(container.style.opacity).toBe('1');
});
it('should dispatch stabilized event after completing', () => {
const events: string[] = [];
const dispatchEvent = (event: { type: string }) => {
events.push(event.type);
};
// Simulate end of #display()
dispatchEvent({ type: 'stabilized' });
expect(events).toContain('stabilized');
});
});
describe('Stabilizing stays true until fill completes', () => {
it('should keep stabilizing true after display until fillPromise resolves', async () => {
let stabilizing = true;
let fillResolve: () => void;
const fillPromise = new Promise<void>((resolve) => {
fillResolve = resolve;
});
// Simulate #display end: dispatch stabilized but don't clear #stabilizing
// Instead, defer clearing to fillPromise completion
fillPromise.then(() => {
stabilizing = false;
});
// stabilizing should still be true before fill completes
expect(stabilizing).toBe(true);
// Resolve fill
fillResolve!();
await fillPromise;
expect(stabilizing).toBe(false);
});
it('should block backward loading while stabilizing', () => {
const loadPrevSection = vi.fn();
const filling = false;
const stabilizing = true;
const scrollStart = 50; // near top
const viewportSize = 800;
// Debounced scroll handler check
if (!filling && !stabilizing && scrollStart < viewportSize) {
loadPrevSection();
}
expect(loadPrevSection).not.toHaveBeenCalled();
});
it('should allow backward loading after stabilizing clears', () => {
const loadPrevSection = vi.fn();
const filling = false;
const stabilizing = false;
const scrollStart = 50;
const viewportSize = 800;
if (!filling && !stabilizing && scrollStart < viewportSize) {
loadPrevSection();
}
expect(loadPrevSection).toHaveBeenCalledOnce();
});
});
describe('Debounced scroll handler skips during stabilization', () => {
function simulateDebouncedHandler(state: {
stabilizing: boolean;
justAnchored: boolean;
filling: boolean;
start: number;
size: number;
}) {
const afterScroll = vi.fn();
const loadPrevSection = vi.fn();
if (state.stabilizing) return { afterScroll, loadPrevSection };
if (state.justAnchored) state.justAnchored = false;
else afterScroll();
if (!state.filling && state.start < state.size) {
loadPrevSection();
}
return { afterScroll, loadPrevSection };
}
it('should skip entirely while stabilizing', () => {
const state = { stabilizing: true, justAnchored: true, filling: false, start: 0, size: 800 };
const { afterScroll, loadPrevSection } = simulateDebouncedHandler(state);
expect(afterScroll).not.toHaveBeenCalled();
expect(loadPrevSection).not.toHaveBeenCalled();
expect(state.justAnchored).toBe(true); // preserved
});
it('should run normally after stabilizing clears', () => {
const state = {
stabilizing: false,
justAnchored: false,
filling: false,
start: 50,
size: 800,
};
const { afterScroll, loadPrevSection } = simulateDebouncedHandler(state);
expect(afterScroll).toHaveBeenCalledOnce();
expect(loadPrevSection).toHaveBeenCalledOnce();
});
it('should not load backward when prev section already loaded (views.has check)', () => {
// When prev section is pre-loaded in #display, views.has(prevIdx) is true
// so the debounced handler's backward loading is a no-op.
// This test verifies the pre-load prevents cascade.
const prevAlreadyLoaded = true;
const loadPrevSection = vi.fn();
if (!prevAlreadyLoaded) loadPrevSection();
expect(loadPrevSection).not.toHaveBeenCalled();
});
});
describe('Pre-load previous section in #display for scrolled mode', () => {
it('should pre-load prev section when anchor <= 0.5 in scrolled mode', () => {
const scrolled = true;
const anchor = 0; // beginning of section
const contentPages = 10;
const columnCount = 2;
const needsPrev =
(contentPages > 0 && contentPages < columnCount) ||
(scrolled && typeof anchor === 'number' && anchor <= 0.5);
expect(needsPrev).toBe(true);
});
it('should pre-load prev section when anchor = 0.5 in scrolled mode', () => {
const scrolled = true;
const anchor = 0.5;
const contentPages = 10;
const columnCount = 2;
const needsPrev =
(contentPages > 0 && contentPages < columnCount) ||
(scrolled && typeof anchor === 'number' && anchor <= 0.5);
expect(needsPrev).toBe(true);
});
it('should NOT pre-load prev section when anchor > 0.5 in scrolled mode', () => {
const scrolled = true;
const anchor = 0.8;
const contentPages = 10;
const columnCount = 2;
const needsPrev =
(contentPages > 0 && contentPages < columnCount) ||
(scrolled && typeof anchor === 'number' && anchor <= 0.5);
expect(needsPrev).toBe(false);
});
it('should still pre-load for short primary alignment regardless of mode', () => {
const scrolled = false;
const anchor = 0.8;
const contentPages = 1; // shorter than one spread
const columnCount = 2;
const needsPrev =
(contentPages > 0 && contentPages < columnCount) ||
(scrolled && typeof anchor === 'number' && anchor <= 0.5);
expect(needsPrev).toBe(true);
});
it('should NOT pre-load in paginated mode with anchor=0 and normal section', () => {
const scrolled = false;
const anchor = 0;
const contentPages = 10;
const columnCount = 2;
const needsPrev =
(contentPages > 0 && contentPages < columnCount) ||
(scrolled && typeof anchor === 'number' && anchor <= 0.5);
expect(needsPrev).toBe(false);
});
});
describe('Render micro-stabilization', () => {
it('should only micro-stabilize when not already in stabilization', () => {
let stabilizing = false;
const container = { style: { opacity: '' } };
// When not already stabilizing
const needsStabilize = !stabilizing;
if (needsStabilize) {
stabilizing = true;
container.style.opacity = '0';
}
expect(needsStabilize).toBe(true);
expect(stabilizing).toBe(true);
expect(container.style.opacity).toBe('0');
});
it('should not micro-stabilize when already stabilizing', () => {
let stabilizing = true;
const container = { style: { opacity: '0' } };
const needsStabilize = !stabilizing;
if (needsStabilize) {
stabilizing = true;
container.style.opacity = '0';
}
expect(needsStabilize).toBe(false);
// stabilizing remains true, container opacity unchanged (already 0)
expect(stabilizing).toBe(true);
});
});
});
@@ -0,0 +1,241 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { readFileSync } from 'fs';
import { resolve, join } from 'path';
import { parse, toRange, fromRange } from 'foliate-js/epubcfi.js';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
const vendorDir = join(process.cwd(), 'public/vendor');
/**
* Tests EPUB CFI resolution with a real PDF loaded via DocumentLoader.
*
* Reference CFIs captured from sample-alice.pdf in a full browser environment:
* - index 3: epubcfi(/6/8!/4/4,/94/1:0,/118/1:10)
* - index 4: epubcfi(/6/10!/4/4/42,/1:0,/1:46)
*
* In jsdom the canvas 2d context is unavailable, so createDocument() uses a
* fallback that produces one <span> per getTextContent() item. The element
* count differs from the full TextLayer render, so these tests generate CFIs
* from the fallback DOM and verify round-trip resolution.
*/
describe('PDF CFI resolution with real document', () => {
let book: BookDoc;
let doc3: Document;
let doc4: Document;
/** Shift the spine-level part from a parsed CFI (as view.js resolveCFI does). */
const shiftSpine = (parts: ReturnType<typeof parse>) => {
(parts.parent ?? parts).shift();
return parts;
};
beforeAll(async () => {
await import('foliate-js/pdf.js');
const pdfjsLib = (globalThis as Record<string, unknown>)['pdfjsLib'] as {
GlobalWorkerOptions: { workerSrc: string };
};
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
`file://${join(vendorDir, 'pdfjs/pdf.worker.min.mjs')}`,
).href;
const pdfPath = resolve(__dirname, '../fixtures/data/sample-alice.pdf');
const buffer = readFileSync(pdfPath);
const file = new File([buffer], 'sample-alice.pdf', { type: 'application/pdf' });
const loader = new DocumentLoader(file);
const result = await loader.open();
book = result.book;
doc3 = await book.sections[3]!.createDocument();
doc4 = await book.sections[4]!.createDocument();
}, 30_000);
// ---------- DOM structure -------------------------------------------------
it('should have textLayer, canvas, and annotationLayer wrappers', () => {
expect(doc3.querySelector('#canvas')).toBeTruthy();
expect(doc3.querySelector('.textLayer')).toBeTruthy();
expect(doc3.querySelector('.annotationLayer')).toBeTruthy();
const textLayer = doc3.querySelector('.textLayer')!;
expect(textLayer.children.length).toBeGreaterThan(0);
});
// ---------- Round-trip: fromRange → parse → toRange -----------------------
it('should round-trip a range CFI on page 3', () => {
const textLayer = doc3.querySelector('.textLayer')!;
const spans = textLayer.querySelectorAll('span');
// Find a span with enough text content
let targetSpan: Element | null = null;
for (const span of spans) {
if (span.firstChild && span.firstChild.textContent!.length >= 10) {
targetSpan = span;
break;
}
}
expect(targetSpan).toBeTruthy();
const srcRange = doc3.createRange();
srcRange.setStart(targetSpan!.firstChild!, 0);
srcRange.setEnd(targetSpan!.firstChild!, 10);
const expectedText = srcRange.toString();
const cfi = fromRange(srcRange);
const parts = parse(cfi);
const resolved = toRange(doc3, parts);
expect(resolved).toBeInstanceOf(Range);
expect(resolved!.toString()).toBe(expectedText);
});
it('should round-trip a range CFI on page 4', () => {
const textLayer = doc4.querySelector('.textLayer')!;
const spans = textLayer.querySelectorAll('span');
let targetSpan: Element | null = null;
for (const span of spans) {
if (span.firstChild && span.firstChild.textContent!.length >= 10) {
targetSpan = span;
break;
}
}
expect(targetSpan).toBeTruthy();
const srcRange = doc4.createRange();
srcRange.setStart(targetSpan!.firstChild!, 0);
srcRange.setEnd(targetSpan!.firstChild!, 10);
const expectedText = srcRange.toString();
const cfi = fromRange(srcRange);
const parts = parse(cfi);
const resolved = toRange(doc4, parts);
expect(resolved).toBeInstanceOf(Range);
expect(resolved!.toString()).toBe(expectedText);
});
it('should round-trip a multi-span range CFI', () => {
const textLayer = doc3.querySelector('.textLayer')!;
const spans = textLayer.querySelectorAll('span');
// Select a range spanning two different spans
const span1 = spans[0]!;
const span2 = spans[2]!;
expect(span1.firstChild).toBeTruthy();
expect(span2.firstChild).toBeTruthy();
const srcRange = doc3.createRange();
srcRange.setStart(span1.firstChild!, 0);
const endOffset = Math.min(5, span2.firstChild!.textContent!.length);
srcRange.setEnd(span2.firstChild!, endOffset);
const expectedText = srcRange.toString();
const cfi = fromRange(srcRange);
const parts = parse(cfi);
const resolved = toRange(doc3, parts);
expect(resolved).toBeInstanceOf(Range);
expect(resolved!.toString()).toBe(expectedText);
});
it('should round-trip a collapsed (point) CFI', () => {
const textLayer = doc3.querySelector('.textLayer')!;
const span = textLayer.querySelector('span')!;
expect(span.firstChild).toBeTruthy();
const srcRange = doc3.createRange();
srcRange.setStart(span.firstChild!, 3);
srcRange.collapse(true);
const cfi = fromRange(srcRange);
const parts = parse(cfi);
const resolved = toRange(doc3, parts);
expect(resolved).toBeInstanceOf(Range);
expect(resolved!.collapsed).toBe(true);
});
// ---------- Cross-page CFI mismatch ---------------------------------------
it('should fail to resolve a page 3 CFI on page 4 document', () => {
const textLayer = doc3.querySelector('.textLayer')!;
const spans = textLayer.querySelectorAll('span');
// Pick the last span so its index likely exceeds page 4's element count
const lastSpan = spans[spans.length - 1]!;
expect(lastSpan.firstChild).toBeTruthy();
const srcRange = doc3.createRange();
srcRange.setStart(lastSpan.firstChild!, 0);
const endOffset = Math.min(5, lastSpan.firstChild!.textContent!.length);
srcRange.setEnd(lastSpan.firstChild!, endOffset);
const cfi = fromRange(srcRange);
const parts = parse(cfi);
const range = toRange(doc4, parts);
// May resolve to a different node or return null depending on DOM sizes;
// either way it should NOT match the original text
if (range) {
expect(range.toString()).not.toBe(srcRange.toString());
}
});
// ---------- Reference CFI format verification -----------------------------
it('should parse real browser-captured CFIs correctly', () => {
// These CFIs were captured from sample-alice.pdf in a full browser with
// TextLayer. They verify that the CFI format is structurally valid.
const cfi3 = 'epubcfi(/6/8!/4/4,/94/1:0,/118/1:10)';
const cfi4 = 'epubcfi(/6/10!/4/4/42,/1:0,/1:46)';
const parts3 = parse(cfi3);
expect(parts3.parent).toBeTruthy();
expect(parts3.start).toBeTruthy();
expect(parts3.end).toBeTruthy();
const parts4 = parse(cfi4);
expect(parts4.parent).toBeTruthy();
expect(parts4.start).toBeTruthy();
expect(parts4.end).toBeTruthy();
});
it('should encode the correct section index in CFI spine step', () => {
// Section index 3 → spine step /6/8, section index 4 → spine step /6/10
const cfi3 = 'epubcfi(/6/8!/4/4,/94/1:0,/118/1:10)';
const cfi4 = 'epubcfi(/6/10!/4/4/42,/1:0,/1:46)';
expect(cfi3).toContain('/6/8!');
expect(cfi4).toContain('/6/10!');
// Spine step /6/N: N = (index + 1) * 2
// index 3 → (3+1)*2 = 8, index 4 → (4+1)*2 = 10
const parts3 = parse(cfi3);
expect(parts3.parent[0][0].index).toBe(6);
expect(parts3.parent[0][1].index).toBe(8);
const parts4 = parse(cfi4);
expect(parts4.parent[0][0].index).toBe(6);
expect(parts4.parent[0][1].index).toBe(10);
});
// ---------- Out-of-bounds CFI indices -------------------------------------
it('should return null when CFI child indices exceed the DOM', () => {
const cfi = 'epubcfi(/6/8!/4/4,/9000/1:0,/9002/1:5)';
const parts = shiftSpine(parse(cfi));
const range = toRange(doc3, parts);
expect(range).toBeNull();
});
it('should return null for a simple CFI with an unreachable node', () => {
const cfi = 'epubcfi(/6/8!/4/9000/1:0)';
const parts = shiftSpine(parse(cfi));
const range = toRange(doc3, parts);
expect(range).toBeNull();
});
it('should return null when start resolves but end does not', () => {
const cfi = 'epubcfi(/6/8!/4/4,/2/1:0,/9999/1:5)';
const parts = shiftSpine(parse(cfi));
const range = toRange(doc3, parts);
expect(range).toBeNull();
});
});
@@ -0,0 +1,266 @@
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { readFileSync } from 'fs';
import { resolve, join } from 'path';
import { textWalker } from 'foliate-js/text-walker.js';
import { TTS } from 'foliate-js/tts.js';
import { createRejectFilter } from '@/utils/node';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
// The @pdfjs alias in vitest.config.mts resolves to public/vendor/pdfjs,
// mirroring how foliate-js/pdf.js does `import '@pdfjs/pdf.min.mjs'`.
const vendorDir = join(process.cwd(), 'public/vendor');
/** Strip all XML/SSML tags to get plain text content */
const stripTags = (ssml: string): string => ssml.replace(/<[^>]+\/?>/g, '').trim();
const highlight = vi.fn();
/**
* Build a document that mimics a rendered PDF page with text layer,
* matching the structure that pdf.js produces in the iframe.
*/
const createPDFTextLayerDoc = (textSpans: string[], annotationText?: string): Document => {
const parser = new DOMParser();
const spans = textSpans.map((t) => `<span>${t}</span>`).join('');
const annotation = annotationText
? `<div class="annotationLayer"><a href="#">${annotationText}</a></div>`
: '<div class="annotationLayer"></div>';
const html =
`<!DOCTYPE html><html lang="en">` +
`<body>` +
`<div id="canvas"><canvas></canvas></div>` +
`<div class="textLayer">${spans}</div>` +
`${annotation}` +
`</body></html>`;
return parser.parseFromString(html, 'text/html');
};
/** Node filter matching what TTSController uses for PDFs */
const pdfNodeFilter = createRejectFilter({
tags: ['rt', 'canvas'],
classes: ['annotationLayer'],
contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/ }],
});
describe('PDF TTS', () => {
describe('TTS with PDF text layer document', () => {
it('should generate SSML from text layer spans', () => {
const doc = createPDFTextLayerDoc(['Alice was beginning to get very ', 'tired of sitting']);
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
const text = stripTags(ssml!);
expect(text).toContain('Alice');
expect(text).toContain('tired');
});
it('should filter out canvas content', () => {
const doc = createPDFTextLayerDoc(['Hello world']);
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(stripTags(ssml!)).toBe('Hello world');
});
it('should filter out annotation layer text', () => {
const doc = createPDFTextLayerDoc(['Main text content'], 'Link annotation text');
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const allText: string[] = [];
let ssml = tts.start();
while (ssml) {
allText.push(stripTags(ssml));
ssml = tts.next();
}
const combined = allText.join(' ');
expect(combined).toContain('Main text content');
expect(combined).not.toContain('Link annotation text');
});
it('should produce valid SSML with speak root element', () => {
const doc = createPDFTextLayerDoc(['Test content']);
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(ssml).toContain('<speak');
expect(ssml).toContain('</speak>');
});
it('should include mark elements in SSML output', () => {
const doc = createPDFTextLayerDoc(['Some text with multiple words']);
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(ssml).toContain('<mark');
});
});
describe('PDF node filter', () => {
it('should reject canvas elements', () => {
const canvas = document.createElement('canvas');
expect(pdfNodeFilter(canvas)).toBe(NodeFilter.FILTER_REJECT);
});
it('should reject annotationLayer elements', () => {
const div = document.createElement('div');
div.className = 'annotationLayer';
expect(pdfNodeFilter(div)).toBe(NodeFilter.FILTER_REJECT);
});
it('should reject rt elements', () => {
const rt = document.createElement('rt');
expect(pdfNodeFilter(rt)).toBe(NodeFilter.FILTER_REJECT);
});
it('should skip regular div elements', () => {
const div = document.createElement('div');
div.className = 'textLayer';
expect(pdfNodeFilter(div)).toBe(NodeFilter.FILTER_SKIP);
});
it('should accept text nodes', () => {
const text = document.createTextNode('hello');
expect(pdfNodeFilter(text)).toBe(NodeFilter.FILTER_ACCEPT);
});
it('should reject footnote-like anchor content', () => {
const a = document.createElement('a');
a.textContent = '[1]';
expect(pdfNodeFilter(a)).toBe(NodeFilter.FILTER_REJECT);
});
it('should not reject normal anchor content', () => {
const a = document.createElement('a');
a.textContent = 'Click here for more';
expect(pdfNodeFilter(a)).toBe(NodeFilter.FILTER_SKIP);
});
});
describe('DocumentLoader with sample-alice.pdf', () => {
let book: BookDoc;
beforeAll(async () => {
// Override workerSrc to an absolute file path so the pdfjs fake-worker
// can import it inside jsdom (the module-level code in pdf.js sets it
// to a URL path that only works in a real browser).
// Import pdf.js first to trigger the @pdfjs side-effect that sets globalThis.pdfjsLib.
await import('foliate-js/pdf.js');
const pdfjsLib = (globalThis as Record<string, unknown>)['pdfjsLib'] as {
GlobalWorkerOptions: { workerSrc: string };
};
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
`file://${join(vendorDir, 'pdfjs/pdf.worker.min.mjs')}`,
).href;
const pdfPath = resolve(__dirname, '../fixtures/data/sample-alice.pdf');
const buffer = readFileSync(pdfPath);
const file = new File([buffer], 'sample-alice.pdf', { type: 'application/pdf' });
const loader = new DocumentLoader(file);
const result = await loader.open();
book = result.book;
expect(result.format).toBe('PDF');
}, 30_000);
it('should load the sample PDF and return a book object', () => {
expect(book).toBeTruthy();
expect(book.rendition.layout).toBe('pre-paginated');
});
it('should have sections matching the number of pages', () => {
expect(book.sections).toBeTruthy();
expect(book.sections.length).toBeGreaterThan(0);
});
it('should extract metadata', () => {
expect(book.metadata).toBeTruthy();
// sample-alice.pdf should have a title
expect(book.metadata.title).toBeTruthy();
});
it('should provide createDocument on every section', () => {
for (const section of book.sections) {
expect(typeof section.createDocument).toBe('function');
}
});
it('should generate TTS SSML from createDocument output', async () => {
const doc = await book.sections[0]!.createDocument();
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(ssml).toContain('<speak');
expect(ssml).toContain('<mark');
const text = stripTags(ssml!);
expect(text.length).toBeGreaterThan(0);
});
it('should navigate through all TTS blocks of a page', async () => {
const doc = await book.sections[0]!.createDocument();
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const blocks: string[] = [];
let ssml = tts.start();
while (ssml) {
blocks.push(stripTags(ssml));
ssml = tts.next();
}
expect(blocks.length).toBeGreaterThan(0);
for (const block of blocks) {
expect(block.length).toBeGreaterThan(0);
}
});
it('should produce createDocument output for multiple pages', async () => {
const pagesToTest = Math.min(book.sections.length, 3);
for (let i = 0; i < pagesToTest; i++) {
const doc = await book.sections[i]!.createDocument();
expect(doc).toBeTruthy();
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(stripTags(ssml!).length).toBeGreaterThan(0);
}
});
it('should return consistent text across repeated createDocument calls', async () => {
const doc1 = await book.sections[0]!.createDocument();
const doc2 = await book.sections[0]!.createDocument();
const tts1 = new TTS(doc1, textWalker, undefined, highlight, 'word');
const tts2 = new TTS(doc2, textWalker, undefined, highlight, 'word');
expect(stripTags(tts1.start()!)).toBe(stripTags(tts2.start()!));
});
it('should work with sentence granularity on real PDF content', async () => {
const doc = await book.sections[0]!.createDocument();
const tts = new TTS(doc, textWalker, undefined, highlight, 'sentence');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(stripTags(ssml!).length).toBeGreaterThan(0);
});
it('should call highlight callback when marking words from PDF', async () => {
highlight.mockClear();
const doc = await book.sections[0]!.createDocument();
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
tts.start();
const range = tts.setMark('0');
expect(range).toBeTruthy();
expect(highlight).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,213 @@
import { describe, it, expect, vi } from 'vitest';
import { textWalker } from 'foliate-js/text-walker.js';
import { TTS } from 'foliate-js/tts.js';
const createHTMLDoc = (bodyHTML: string, attrs: Record<string, string> = {}): Document => {
const parser = new DOMParser();
const attrStr = Object.entries(attrs)
.map(([k, v]) => ` ${k}="${v}"`)
.join('');
const html = `<!DOCTYPE html><html${attrStr}><body>${bodyHTML}</body></html>`;
return parser.parseFromString(html, 'text/html');
};
const createXHTMLDoc = (bodyHTML: string, attrs: Record<string, string> = {}): Document => {
const parser = new DOMParser();
const attrStr = Object.entries(attrs)
.map(([k, v]) => ` ${k}="${v}"`)
.join('');
const xml =
`<?xml version="1.0" encoding="UTF-8"?>` +
`<html${attrStr}><head><title></title></head><body>${bodyHTML}</body></html>`;
return parser.parseFromString(xml, 'application/xhtml+xml');
};
const createPlainHTMLDoc = (html: string): Document => {
const parser = new DOMParser();
return parser.parseFromString(html, 'text/html');
};
/** Strip all XML tags to get plain text content from SSML */
const stripTags = (ssml: string): string => ssml.replace(/<[^>]+\/?>/g, '').trim();
const highlight = vi.fn();
describe('TTS', () => {
describe('plain HTML document', () => {
it('should init and generate SSML for a plain HTML doc without doctype or lang', () => {
const doc = createPlainHTMLDoc(`<html><head></head><body><p>Hello world</p></body></html>`);
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(stripTags(ssml!)).toBe('Hello world');
});
});
describe('document with lang attribute', () => {
it('should init and generate SSML with lang on html element', () => {
const doc = createHTMLDoc('<p>Hello world</p>', { lang: 'en' });
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(stripTags(ssml!)).toBe('Hello world');
});
it('should init and generate SSML with xml:lang on XHTML element', () => {
const doc = createXHTMLDoc('<p>Hello world</p>', {
xmlns: 'http://www.w3.org/1999/xhtml',
'xml:lang': 'en',
});
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(stripTags(ssml!)).toBe('Hello world');
});
it('should propagate lang into SSML speak element', () => {
const doc = createHTMLDoc('<p>Hello world</p>', { lang: 'fr' });
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(ssml).toContain('xml:lang="fr"');
});
});
describe('document without lang or xml:lang', () => {
it('should init and generate SSML for HTML doc without any lang', () => {
const doc = createHTMLDoc('<p>Hello world</p>');
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(stripTags(ssml!)).toBe('Hello world');
});
it('should init and generate SSML for XHTML doc with xmlns but no lang', () => {
const doc = createXHTMLDoc('<p>Hello world</p>', {
xmlns: 'http://www.w3.org/1999/xhtml',
});
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(stripTags(ssml!)).toBe('Hello world');
});
it('should navigate with next() on doc without lang', () => {
const doc = createHTMLDoc('<p>First paragraph</p><p>Second paragraph</p>');
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml1 = tts.start();
const ssml2 = tts.next();
expect(ssml1).toBeTruthy();
expect(stripTags(ssml1!)).toContain('First paragraph');
if (ssml2) {
expect(stripTags(ssml2)).toContain('Second paragraph');
}
});
it('should generate SSML with sentence granularity on doc without lang', () => {
const doc = createHTMLDoc('<p>First sentence. Second sentence.</p>');
const tts = new TTS(doc, textWalker, undefined, highlight, 'sentence');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(stripTags(ssml!)).toContain('First sentence');
});
it('should not include xml:lang on speak element when doc has no lang', () => {
const doc = createHTMLDoc('<p>No lang content</p>');
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
const speakMatch = ssml!.match(/<speak[^>]*>/);
expect(speakMatch).toBeTruthy();
expect(speakMatch![0]).not.toContain('xml:lang');
});
});
describe('document without xmlns namespace declarations', () => {
it('should init and generate SSML for XHTML doc without xmlns', () => {
// XHTML without xmlns="http://www.w3.org/1999/xhtml" causes doc.body to be null
const doc = createXHTMLDoc('<p>Hello world</p>');
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(stripTags(ssml!)).toBe('Hello world');
});
it('should init and generate SSML for XHTML doc without xmlns but with xml:lang', () => {
const doc = createXHTMLDoc('<p>Hello world</p>', {
'xml:lang': 'en',
});
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(stripTags(ssml!)).toBe('Hello world');
});
it('should init and generate SSML for XHTML doc without epub namespace', () => {
// Missing xmlns:epub="http://www.idpf.org/2007/ops" should not prevent TTS
const doc = createXHTMLDoc('<p>Hello world</p>', {
xmlns: 'http://www.w3.org/1999/xhtml',
});
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(stripTags(ssml!)).toBe('Hello world');
});
});
describe('document without both lang and xmlns', () => {
it('should init and generate SSML for bare XHTML doc', () => {
// No xmlns and no lang: both issues combined
const doc = createXHTMLDoc('<p>Bare document content</p>');
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(stripTags(ssml!)).toBe('Bare document content');
});
it('should handle multiple blocks in bare XHTML doc', () => {
const doc = createXHTMLDoc('<p>First block</p><div>Second block</div><p>Third block</p>');
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml1 = tts.start();
expect(ssml1).toBeTruthy();
const ssml2 = tts.next();
if (ssml2) {
expect(ssml2).toBeTruthy();
}
});
});
describe('SSML output correctness', () => {
it('should produce valid SSML with speak root element', () => {
const doc = createHTMLDoc('<p>Test content</p>', { lang: 'en' });
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(ssml).toContain('<speak');
expect(ssml).toContain('</speak>');
});
it('should include mark elements in SSML output', () => {
const doc = createHTMLDoc('<p>Some text with words</p>', { lang: 'en' });
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(ssml).toContain('<mark');
});
});
});

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