forked from akai/readest
Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c11f79e843 | |||
| 87f0240b0a | |||
| 2905506017 | |||
| 1936136596 | |||
| e0cf7e8d9f | |||
| 0177a03a90 | |||
| f5df80f154 | |||
| 76b239f382 | |||
| 2f430bf2ff | |||
| 0e8605d298 | |||
| 7445d5befe | |||
| e41bcfbac9 | |||
| 91bc4ddec7 | |||
| e8f70b896e | |||
| 1f4f5f8a6d | |||
| 764acf59de | |||
| eab83c6d3f | |||
| 7f62cdcab9 | |||
| e4ab06abcd | |||
| fef0aa6947 | |||
| 2935eacb1c | |||
| ad9bb58cd4 | |||
| 871d6467c3 | |||
| 3f19ce24de | |||
| 8280585e70 | |||
| 1f7483b911 | |||
| b2bb92036b | |||
| 3831f24ff7 | |||
| 3bec89c85d | |||
| 3dfe6bd65e | |||
| b00d321817 | |||
| 1af55e24da | |||
| f7b2a2432c | |||
| db3dee025b | |||
| 25352efece | |||
| 1297e33c62 | |||
| 8274c6bc4f | |||
| ed5bbd25d6 | |||
| d06e1849cc | |||
| 34ad6e6749 | |||
| b163012488 | |||
| f9a3559086 | |||
| 674a0bf16d | |||
| 9b53ccc9de | |||
| 7a605a357a | |||
| af649edd0d | |||
| 64a71e25e4 | |||
| 51a3767940 | |||
| a8572ab7c6 | |||
| 907b672313 | |||
| 8df9f909b4 | |||
| bcd7a90f27 | |||
| 5646e0cfb5 | |||
| fad27d74a0 | |||
| 5fbbfa523b | |||
| 569c6707a4 | |||
| 99f8a29326 | |||
| 04737d6f35 | |||
| 8850e6c00f | |||
| 93b96d64eb | |||
| 9f8894c1e0 | |||
| dac4f2e8ec | |||
| 54bc1514df | |||
| 97cab2d70b | |||
| 13588b4a65 | |||
| 0f5bd5ca1c | |||
| a74c1a56a9 | |||
| 1e9bd1d821 | |||
| 02cc0a9ebb | |||
| 5273ef75dc | |||
| bd957a4eb8 | |||
| 38552a0c2e | |||
| 0609e828b1 | |||
| 35ade2c855 | |||
| b68c14da1f | |||
| 5c41394961 | |||
| 5685944599 | |||
| 94e761f681 | |||
| 7f636a2072 | |||
| 480b8b98a3 | |||
| d1fb67316f | |||
| f0e9ddd2ae | |||
| b16a4445ae | |||
| 515d47e64d | |||
| 3fd78c4ed8 | |||
| 152a95941a | |||
| bf5805910d | |||
| 431d14f4a4 | |||
| 0d2e5b7c76 | |||
| e949476d27 | |||
| 0afff573a1 | |||
| d4bb61f12b | |||
| 09c45b4615 | |||
| d8eef87bf0 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -46,4 +46,9 @@ fastlane/report.xml
|
||||
|
||||
# nix
|
||||
result*
|
||||
|
||||
.playwright-mcp/
|
||||
|
||||
.claude/worktrees
|
||||
.claude/settings.local.json
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+2
-8
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
Submodule
+1
Submodule apps/readest-app/.claude/skills/gstack added at 1f4b6fd7a2
@@ -3,7 +3,7 @@ PDFJS_FONTS_PATH=../../packages/foliate-js/node_modules/pdfjs-dist
|
||||
PDFJS_STYLE_PATH=../../packages/foliate-js/vendor/pdfjs
|
||||
|
||||
NEXT_PUBLIC_DEFAULT_POSTHOG_URL_BASE64="aHR0cHM6Ly91cy5pLnBvc3Rob2cuY29t"
|
||||
NEXT_PUBLIC_DEFAULT_POSTHOG_KEY_BASE64="cGhjX2ViNXowbVRxWm8yZm5YYnZGNmE3bFh5TThpTmRSNTNsR1A3VFM3VGh4S08="
|
||||
NEXT_PUBLIC_DEFAULT_POSTHOG_KEY_BASE64="cGhjX0x3ekhZRWtsZUVub3ZSc05ZQlRpTVRTV2MyS1NUOFdZMzBIWWFhN0ZPa1IK"
|
||||
|
||||
NEXT_PUBLIC_DEFAULT_SUPABASE_URL_BASE64="aHR0cHM6Ly9yZWFkZXN0LnN1cGFiYXNlLmNv"
|
||||
NEXT_PUBLIC_DEFAULT_SUPABASE_KEY_BASE64="ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKemRYQmhZbUZ6WlNJc0luSmxaaUk2SW5aaWMzbDRablZ6YW1weFpIaHJhbkZzZVhOaklpd2ljbTlzWlNJNkltRnViMjRpTENKcFlYUWlPakUzTXpReE1qTTJOekVzSW1WNGNDSTZNakEwT1RZNU9UWTNNWDAuM1U1VXFhb3VfMVNnclZlMWVvOXJBcGMwdUtqcWhwUWRVWGh2d1VIbVVmZw=="
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -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.
|
||||
@@ -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} ... />
|
||||
```
|
||||
@@ -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) |
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["mocha", "@wdio/globals/types"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -59,6 +59,14 @@ const nextConfig = {
|
||||
'marked',
|
||||
]),
|
||||
],
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/reader/:ids',
|
||||
destination: '/reader?ids=:ids',
|
||||
},
|
||||
];
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.101",
|
||||
"version": "0.10.1",
|
||||
"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,15 +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",
|
||||
@@ -145,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",
|
||||
@@ -154,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",
|
||||
@@ -172,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": "تطبيق على هذا الكتاب",
|
||||
@@ -872,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.": "فشل تصدير الكتاب.",
|
||||
@@ -1036,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",
|
||||
@@ -1107,5 +1107,52 @@
|
||||
"System Screen Brightness": "سطوع شاشة النظام",
|
||||
"Page:": "صفحة:",
|
||||
"Page: {{number}}": "صفحة: {{number}}",
|
||||
"Annotation page 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": "تحديث البيانات الوصفية"
|
||||
}
|
||||
|
||||
@@ -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}} মিনিট",
|
||||
@@ -832,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.": "বই রপ্তানি করতে ব্যর্থ।",
|
||||
@@ -988,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 পড়া শুরু করুন",
|
||||
@@ -1059,5 +1059,48 @@
|
||||
"System Screen Brightness": "সিস্টেম স্ক্রিন উজ্জ্বলতা",
|
||||
"Page:": "পৃষ্ঠা:",
|
||||
"Page: {{number}}": "পৃষ্ঠা: {{number}}",
|
||||
"Annotation page 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": "དཔེ་དེབ་འདིར་སྤྱོད་པ།",
|
||||
@@ -822,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.": "དཔེ་དེབ་ཕྱིར་འདོན་མི་ཐུབ།",
|
||||
@@ -976,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 ཀློག་འགོ་འཛུགས་པ།",
|
||||
@@ -1047,5 +1047,47 @@
|
||||
"System Screen Brightness": "མ་ལག་ཤེལ་སྒོའི་གསལ་ཚད།",
|
||||
"Page:": "ཤོག་ལྷེ།:",
|
||||
"Page: {{number}}": "ཤོག་ལྷེ།: {{number}}",
|
||||
"Annotation page 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",
|
||||
@@ -832,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.",
|
||||
@@ -988,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",
|
||||
@@ -1059,5 +1059,48 @@
|
||||
"System Screen Brightness": "System-Bildschirmhelligkeit",
|
||||
"Page:": "Seite:",
|
||||
"Page: {{number}}": "Seite: {{number}}",
|
||||
"Annotation page number": "Seitenzahl der Anmerkung"
|
||||
"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": "Εφαρμογή σε αυτό το βιβλίο",
|
||||
@@ -832,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.": "Αποτυχία εξαγωγής του βιβλίου.",
|
||||
@@ -988,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",
|
||||
@@ -1059,5 +1059,48 @@
|
||||
"System Screen Brightness": "Φωτεινότητα οθόνης συστήματος",
|
||||
"Page:": "Σελίδα:",
|
||||
"Page: {{number}}": "Σελίδα: {{number}}",
|
||||
"Annotation page 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": "Ανανέωση μεταδεδομένων"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -842,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.",
|
||||
@@ -1000,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",
|
||||
@@ -1071,5 +1071,49 @@
|
||||
"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"
|
||||
"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": "اِعمال برای این کتاب",
|
||||
@@ -832,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.": "صادر کردن کتاب ناموفق بود.",
|
||||
@@ -988,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",
|
||||
@@ -1059,5 +1059,48 @@
|
||||
"System Screen Brightness": "روشنایی صفحه نمایش سیستم",
|
||||
"Page:": "صفحه:",
|
||||
"Page: {{number}}": "صفحه: {{number}}",
|
||||
"Annotation page 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",
|
||||
@@ -842,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.",
|
||||
@@ -1000,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",
|
||||
@@ -1071,5 +1071,49 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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": "ללא הגבלת זמן",
|
||||
@@ -966,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)",
|
||||
@@ -1071,5 +1071,49 @@
|
||||
"System Screen Brightness": "בהירות מסך המערכת",
|
||||
"Page:": "עמוד:",
|
||||
"Page: {{number}}": "עמוד: {{number}}",
|
||||
"Annotation page 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": "इस पुस्तक पर लागू करें",
|
||||
@@ -832,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.": "पुस्तक निर्यात करने में विफल।",
|
||||
@@ -988,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 पठन शुरू करें",
|
||||
@@ -1059,5 +1059,48 @@
|
||||
"System Screen Brightness": "सिस्टम स्क्रीन चमक",
|
||||
"Page:": "पृष्ठ:",
|
||||
"Page: {{number}}": "पृष्ठ: {{number}}",
|
||||
"Annotation page 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",
|
||||
@@ -822,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.",
|
||||
@@ -976,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",
|
||||
@@ -1047,5 +1047,47 @@
|
||||
"System Screen Brightness": "Kecerahan Layar Sistem",
|
||||
"Page:": "Halaman:",
|
||||
"Page: {{number}}": "Halaman: {{number}}",
|
||||
"Annotation page number": "Nomor halaman anotasi"
|
||||
"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",
|
||||
@@ -842,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.",
|
||||
@@ -1000,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",
|
||||
@@ -1071,5 +1071,49 @@
|
||||
"System Screen Brightness": "Luminosità dello schermo del sistema",
|
||||
"Page:": "Pagina:",
|
||||
"Page: {{number}}": "Pagina: {{number}}",
|
||||
"Annotation page number": "Numero di pagina dell'annotazione"
|
||||
"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": "この書籍に適用",
|
||||
@@ -822,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.": "書籍のエクスポートに失敗しました。",
|
||||
@@ -976,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読書を開始",
|
||||
@@ -1047,5 +1047,47 @@
|
||||
"System Screen Brightness": "システムの画面の明るさ",
|
||||
"Page:": "ページ:",
|
||||
"Page: {{number}}": "ページ: {{number}}",
|
||||
"Annotation page 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": "이 책에 적용",
|
||||
@@ -822,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.": "책 내보내기에 실패했습니다.",
|
||||
@@ -976,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 독서 시작",
|
||||
@@ -1047,5 +1047,47 @@
|
||||
"System Screen Brightness": "시스템 화면 밝기",
|
||||
"Page:": "페이지:",
|
||||
"Page: {{number}}": "페이지: {{number}}",
|
||||
"Annotation page 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": "메타데이터 새로고침"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -822,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.",
|
||||
@@ -976,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",
|
||||
@@ -1047,5 +1047,47 @@
|
||||
"System Screen Brightness": "Kecerahan Skrin Sistem",
|
||||
"Page:": "Halaman:",
|
||||
"Page: {{number}}": "Halaman: {{number}}",
|
||||
"Annotation page number": "Nombor halaman anotasi"
|
||||
"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",
|
||||
@@ -832,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.",
|
||||
@@ -988,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",
|
||||
@@ -1059,5 +1059,48 @@
|
||||
"System Screen Brightness": "Systeemhelderheid van het scherm",
|
||||
"Page:": "Pagina:",
|
||||
"Page: {{number}}": "Pagina: {{number}}",
|
||||
"Annotation page number": "Annotatiepaginanummer"
|
||||
"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",
|
||||
@@ -852,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.",
|
||||
@@ -1012,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",
|
||||
@@ -1083,5 +1083,50 @@
|
||||
"System Screen Brightness": "Jasność ekranu systemowego",
|
||||
"Page:": "Strona:",
|
||||
"Page: {{number}}": "Strona: {{number}}",
|
||||
"Annotation page number": "Numer strony adnotacji"
|
||||
"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",
|
||||
@@ -842,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.",
|
||||
@@ -1000,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",
|
||||
@@ -1071,5 +1071,49 @@
|
||||
"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"
|
||||
"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": "Применить к этой книге",
|
||||
@@ -852,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.": "Не удалось экспортировать книгу.",
|
||||
@@ -1012,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",
|
||||
@@ -1083,5 +1083,50 @@
|
||||
"System Screen Brightness": "Систেমная яркость экрана",
|
||||
"Page:": "Страница:",
|
||||
"Page: {{number}}": "Страница: {{number}}",
|
||||
"Annotation page 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": "Обновить метаданные"
|
||||
}
|
||||
|
||||
@@ -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}}",
|
||||
@@ -832,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.": "පොත අපනයනය කිරීමට අසමත් විය.",
|
||||
@@ -988,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 කියවීම ආරම්භ කරන්න",
|
||||
@@ -1059,5 +1059,48 @@
|
||||
"System Screen Brightness": "පද්ධති තිර දීප්තිය",
|
||||
"Page:": "පිටුව:",
|
||||
"Page: {{number}}": "පිටුව: {{number}}",
|
||||
"Annotation page 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
@@ -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",
|
||||
@@ -832,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.",
|
||||
@@ -988,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",
|
||||
@@ -1059,5 +1059,48 @@
|
||||
"System Screen Brightness": "Systemets skärmljusstyrka",
|
||||
"Page:": "Sida:",
|
||||
"Page: {{number}}": "Sida: {{number}}",
|
||||
"Annotation page number": "Annoteringssidnummer"
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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}} நிமிடம்",
|
||||
@@ -832,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.": "புத்தகத்தை ஏற்றுமதி செய்ய இயலவில்லை.",
|
||||
@@ -988,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 வாசிப்பைத் தொடங்கு",
|
||||
@@ -1059,5 +1059,48 @@
|
||||
"System Screen Brightness": "கணினித் திரை பிரகாசம்",
|
||||
"Page:": "பக்கம்:",
|
||||
"Page: {{number}}": "பக்கம்: {{number}}",
|
||||
"Annotation page 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": "மெட்டாடேட்டா புதுப்பிக்கவும்"
|
||||
}
|
||||
|
||||
@@ -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}} นาที",
|
||||
@@ -822,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.": "ส่งออกหนังสือล้มเหลว",
|
||||
@@ -976,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",
|
||||
@@ -1047,5 +1047,47 @@
|
||||
"System Screen Brightness": "ความสว่างหน้าจอระบบ",
|
||||
"Page:": "หน้า:",
|
||||
"Page: {{number}}": "หน้า: {{number}}",
|
||||
"Annotation page 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",
|
||||
@@ -832,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ı.",
|
||||
@@ -988,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",
|
||||
@@ -1059,5 +1059,48 @@
|
||||
"System Screen Brightness": "Sistem Ekran Parlaklığı",
|
||||
"Page:": "Sayfa:",
|
||||
"Page: {{number}}": "Sayfa: {{number}}",
|
||||
"Annotation page number": "Açıklama sayfa numarası"
|
||||
"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": "Застосувати лише до цієї книги",
|
||||
@@ -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,23 +588,23 @@
|
||||
"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": "<1>Залишилось </1><0>{{number}}</0><1> сторінок у розділі</1>",
|
||||
"Device": "Пристрій",
|
||||
@@ -615,9 +614,9 @@
|
||||
"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": "Тільки переклад",
|
||||
@@ -636,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",
|
||||
@@ -717,8 +716,8 @@
|
||||
"Sample": "Зразок",
|
||||
"Download": "Завантажити",
|
||||
"Open & Read": "Відкрити та читати",
|
||||
"Tags": "Теги",
|
||||
"Tag": "Тег",
|
||||
"Tags": "Теґи",
|
||||
"Tag": "Теґ",
|
||||
"First": "Перша",
|
||||
"Previous": "Попередня",
|
||||
"Next": "Наступна",
|
||||
@@ -727,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}}",
|
||||
@@ -755,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}} файлів",
|
||||
@@ -776,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": "Бібліотека",
|
||||
@@ -803,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": "Швидка дія",
|
||||
@@ -851,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.": "Не вдалося експортувати книгу.",
|
||||
@@ -908,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": "Стиль анотації",
|
||||
@@ -932,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": "ШІ-асистент",
|
||||
@@ -945,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": "Модель",
|
||||
@@ -961,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": "Результатів не знайдено для",
|
||||
@@ -975,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}} книги",
|
||||
@@ -1007,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": "З початку розділу",
|
||||
@@ -1083,5 +1083,50 @@
|
||||
"System Screen Brightness": "Системна яскравість екрана",
|
||||
"Page:": "Сторінка:",
|
||||
"Page: {{number}}": "Сторінка: {{number}}",
|
||||
"Annotation page 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",
|
||||
@@ -822,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.",
|
||||
@@ -976,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",
|
||||
@@ -1047,5 +1047,47 @@
|
||||
"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"
|
||||
"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": "应用于此书籍",
|
||||
@@ -822,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.": "书籍导出失败。",
|
||||
@@ -976,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 阅读",
|
||||
@@ -1047,5 +1048,46 @@
|
||||
"System Screen Brightness": "系统屏幕亮度",
|
||||
"Page:": "页面:",
|
||||
"Page: {{number}}": "页面: {{number}}",
|
||||
"Annotation page 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": "應用於此書籍",
|
||||
@@ -822,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.": "書籍匯出失敗。",
|
||||
@@ -976,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 閱讀",
|
||||
@@ -1047,5 +1047,47 @@
|
||||
"System Screen Brightness": "系統螢幕亮度",
|
||||
"Page:": "頁面:",
|
||||
"Page: {{number}}": "頁面: {{number}}",
|
||||
"Annotation page 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": "重新整理中繼資料"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.10.1": {
|
||||
"date": "2026-03-22",
|
||||
"notes": [
|
||||
"Reading: Added continuous scroll and spread layout support for EPUBs",
|
||||
"Reading: Added current time and battery display in footer",
|
||||
"Reading: Added TTS, annotation support, pinch-to-zoom, and scroll mode for PDFs",
|
||||
"Library: Added backup/restore to a zip file and batch metadata refresh",
|
||||
"KOReader: Added bookmark and annotation sync between KOReader and Readest devices",
|
||||
"Footnotes: Added back navigation with link history and more responsive popup sizing",
|
||||
"Annotations: Added loupe display when selecting text in instant annotation mode",
|
||||
"Text-to-Speech: Fixed TTS reading position tracking in scrolled mode"
|
||||
]
|
||||
},
|
||||
"0.9.101": {
|
||||
"date": "2026-02-28",
|
||||
"notes": [
|
||||
|
||||
Executable
+71
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
+2
-2
@@ -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())
|
||||
}
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
Submodule apps/readest-app/src-tauri/plugins/tauri-plugin-turso added at b18e78107a
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import NumberInput from '@/components/settings/NumberInput';
|
||||
|
||||
vi.mock('@/hooks/useTranslation', () => ({
|
||||
useTranslation: () => (s: string) => s,
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('NumberInput', () => {
|
||||
const defaultProps = {
|
||||
label: 'Font Size',
|
||||
value: 16,
|
||||
min: 8,
|
||||
max: 72,
|
||||
onChange: vi.fn(),
|
||||
};
|
||||
|
||||
it('commits value on Enter key (form submit)', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumberInput {...defaultProps} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByDisplayValue('16');
|
||||
fireEvent.change(input, { target: { value: '24' } });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
// Enter triggers form submit which commits the value
|
||||
fireEvent.submit(input.closest('form')!);
|
||||
expect(onChange).toHaveBeenCalledWith(24);
|
||||
});
|
||||
|
||||
it('commits value on blur', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumberInput {...defaultProps} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByDisplayValue('16');
|
||||
fireEvent.change(input, { target: { value: '20' } });
|
||||
fireEvent.blur(input);
|
||||
expect(onChange).toHaveBeenCalledWith(20);
|
||||
});
|
||||
|
||||
it('clamps value to min/max on commit', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumberInput {...defaultProps} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByDisplayValue('16');
|
||||
fireEvent.change(input, { target: { value: '100' } });
|
||||
fireEvent.submit(input.closest('form')!);
|
||||
expect(onChange).toHaveBeenCalledWith(72);
|
||||
});
|
||||
});
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -6,18 +6,6 @@ import type { BookDoc } from '@/libs/document';
|
||||
import type { FoliateView } from '@/types/view';
|
||||
import { wrappedFoliateView } from '@/types/view';
|
||||
|
||||
// jsdom's Blob doesn't implement arrayBuffer(), polyfill it via FileReader
|
||||
if (typeof Blob.prototype.arrayBuffer !== 'function') {
|
||||
Blob.prototype.arrayBuffer = function () {
|
||||
return new Promise((res, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => res(reader.result as ArrayBuffer);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsArrayBuffer(this);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Register a stub paginator custom element so View.open() doesn't fail in jsdom
|
||||
if (!customElements.get('foliate-paginator')) {
|
||||
customElements.define(
|
||||
@@ -38,7 +26,7 @@ let view: FoliateView;
|
||||
let totalSections: number;
|
||||
|
||||
const loadEPUB = async () => {
|
||||
const epubPath = resolve(__dirname, '../fixtures/sample-alice.epub');
|
||||
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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeBookConfigs, mergeBookMetadata } from '@/services/backupService';
|
||||
import { Book, BookConfig, BookNote } from '@/types/book';
|
||||
|
||||
function makeBook(overrides: Partial<Book> = {}): Book {
|
||||
return {
|
||||
hash: 'abc123',
|
||||
format: 'EPUB',
|
||||
title: 'Test Book',
|
||||
author: 'Author',
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeNote(overrides: Partial<BookNote> = {}): BookNote {
|
||||
return {
|
||||
id: 'note-1',
|
||||
type: 'annotation',
|
||||
cfi: 'cfi-1',
|
||||
note: 'test note',
|
||||
createdAt: 100,
|
||||
updatedAt: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('mergeBookConfigs', () => {
|
||||
it('should keep higher progress from backup', () => {
|
||||
const current: BookConfig = { progress: [50, 200], updatedAt: 100 };
|
||||
const backup: BookConfig = { progress: [100, 200], updatedAt: 90 };
|
||||
const result = mergeBookConfigs(current, backup);
|
||||
expect(result.progress).toEqual([100, 200]);
|
||||
});
|
||||
|
||||
it('should keep higher progress from current', () => {
|
||||
const current: BookConfig = { progress: [150, 200], updatedAt: 100 };
|
||||
const backup: BookConfig = { progress: [100, 200], updatedAt: 90 };
|
||||
const result = mergeBookConfigs(current, backup);
|
||||
expect(result.progress).toEqual([150, 200]);
|
||||
});
|
||||
|
||||
it('should use location from the config with higher progress', () => {
|
||||
const current: BookConfig = { progress: [50, 200], location: 'loc-current', updatedAt: 100 };
|
||||
const backup: BookConfig = { progress: [100, 200], location: 'loc-backup', updatedAt: 90 };
|
||||
const result = mergeBookConfigs(current, backup);
|
||||
expect(result.location).toBe('loc-backup');
|
||||
});
|
||||
|
||||
it('should merge booknotes, keeping latest by updatedAt', () => {
|
||||
const note1 = makeNote({ id: '1', note: 'old', updatedAt: 100 });
|
||||
const note1Newer = makeNote({ id: '1', note: 'new', updatedAt: 200 });
|
||||
const note2 = makeNote({ id: '2', note: 'only-backup', updatedAt: 150 });
|
||||
|
||||
const current: BookConfig = { booknotes: [note1], updatedAt: 100 };
|
||||
const backup: BookConfig = { booknotes: [note1Newer, note2], updatedAt: 200 };
|
||||
const result = mergeBookConfigs(current, backup);
|
||||
|
||||
expect(result.booknotes).toHaveLength(2);
|
||||
expect(result.booknotes!.find((n) => n.id === '1')!.note).toBe('new');
|
||||
expect(result.booknotes!.find((n) => n.id === '2')!.note).toBe('only-backup');
|
||||
});
|
||||
|
||||
it('should keep current note when updatedAt is equal', () => {
|
||||
const currentNote = makeNote({ id: '1', note: 'current', updatedAt: 100 });
|
||||
const backupNote = makeNote({ id: '1', note: 'backup', updatedAt: 100 });
|
||||
|
||||
const current: BookConfig = { booknotes: [currentNote], updatedAt: 100 };
|
||||
const backup: BookConfig = { booknotes: [backupNote], updatedAt: 100 };
|
||||
const result = mergeBookConfigs(current, backup);
|
||||
|
||||
expect(result.booknotes).toHaveLength(1);
|
||||
expect(result.booknotes![0]!.note).toBe('current');
|
||||
});
|
||||
|
||||
it('should handle missing progress in current', () => {
|
||||
const current: BookConfig = { updatedAt: 100 };
|
||||
const backup: BookConfig = { progress: [50, 200], updatedAt: 90 };
|
||||
const result = mergeBookConfigs(current, backup);
|
||||
expect(result.progress).toEqual([50, 200]);
|
||||
});
|
||||
|
||||
it('should handle missing progress in backup', () => {
|
||||
const current: BookConfig = { progress: [50, 200], updatedAt: 100 };
|
||||
const backup: BookConfig = { updatedAt: 90 };
|
||||
const result = mergeBookConfigs(current, backup);
|
||||
expect(result.progress).toEqual([50, 200]);
|
||||
});
|
||||
|
||||
it('should handle missing booknotes in both', () => {
|
||||
const current: BookConfig = { updatedAt: 100 };
|
||||
const backup: BookConfig = { updatedAt: 90 };
|
||||
const result = mergeBookConfigs(current, backup);
|
||||
expect(result.booknotes).toEqual([]);
|
||||
});
|
||||
|
||||
it('should preserve viewSettings from the config with higher progress', () => {
|
||||
const current: BookConfig = {
|
||||
progress: [10, 200],
|
||||
viewSettings: { zoomLevel: 1.5 },
|
||||
updatedAt: 100,
|
||||
};
|
||||
const backup: BookConfig = {
|
||||
progress: [100, 200],
|
||||
viewSettings: { zoomLevel: 2.0 },
|
||||
updatedAt: 90,
|
||||
};
|
||||
const result = mergeBookConfigs(current, backup);
|
||||
expect(result.viewSettings?.zoomLevel).toBe(2.0);
|
||||
});
|
||||
|
||||
it('should combine notes from current-only and backup-only', () => {
|
||||
const currentNote = makeNote({ id: 'c1', note: 'current-only' });
|
||||
const backupNote = makeNote({ id: 'b1', note: 'backup-only' });
|
||||
|
||||
const current: BookConfig = { booknotes: [currentNote], updatedAt: 100 };
|
||||
const backup: BookConfig = { booknotes: [backupNote], updatedAt: 100 };
|
||||
const result = mergeBookConfigs(current, backup);
|
||||
|
||||
expect(result.booknotes).toHaveLength(2);
|
||||
expect(result.booknotes!.find((n) => n.id === 'c1')).toBeDefined();
|
||||
expect(result.booknotes!.find((n) => n.id === 'b1')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeBookMetadata', () => {
|
||||
it('should not delete when current is deleted but backup is not', () => {
|
||||
const current = makeBook({ deletedAt: 5000 });
|
||||
const backup = makeBook({ deletedAt: null });
|
||||
const result = mergeBookMetadata(current, backup);
|
||||
expect(result.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should not delete when backup is deleted but current is not', () => {
|
||||
const current = makeBook({ deletedAt: null });
|
||||
const backup = makeBook({ deletedAt: 5000 });
|
||||
const result = mergeBookMetadata(current, backup);
|
||||
expect(result.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should keep later deletedAt when both sides are deleted', () => {
|
||||
const current = makeBook({ deletedAt: 3000 });
|
||||
const backup = makeBook({ deletedAt: 5000 });
|
||||
const result = mergeBookMetadata(current, backup);
|
||||
expect(result.deletedAt).toBe(5000);
|
||||
});
|
||||
|
||||
it('should not delete when neither side is deleted', () => {
|
||||
const current = makeBook({ deletedAt: null });
|
||||
const backup = makeBook({ deletedAt: null });
|
||||
const result = mergeBookMetadata(current, backup);
|
||||
expect(result.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should set updatedAt to max of both', () => {
|
||||
const current = makeBook({ updatedAt: 2000 });
|
||||
const backup = makeBook({ updatedAt: 3000 });
|
||||
const result = mergeBookMetadata(current, backup);
|
||||
expect(result.updatedAt).toBe(3000);
|
||||
});
|
||||
|
||||
it('should set createdAt to min of both', () => {
|
||||
const current = makeBook({ createdAt: 500 });
|
||||
const backup = makeBook({ createdAt: 1000 });
|
||||
const result = mergeBookMetadata(current, backup);
|
||||
expect(result.createdAt).toBe(500);
|
||||
});
|
||||
|
||||
it('should use backup fields when backup has higher updatedAt', () => {
|
||||
const current = makeBook({ updatedAt: 1000, title: 'Old Title' });
|
||||
const backup = makeBook({ updatedAt: 2000, title: 'New Title' });
|
||||
const result = mergeBookMetadata(current, backup);
|
||||
expect(result.title).toBe('New Title');
|
||||
});
|
||||
|
||||
it('should use current fields when current has higher updatedAt', () => {
|
||||
const current = makeBook({ updatedAt: 3000, title: 'Current Title' });
|
||||
const backup = makeBook({ updatedAt: 1000, title: 'Backup Title' });
|
||||
const result = mergeBookMetadata(current, backup);
|
||||
expect(result.title).toBe('Current Title');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
getFoliateDataPath,
|
||||
mapFoliateColor,
|
||||
convertFoliateAnnotation,
|
||||
convertFoliateBookmark,
|
||||
convertFoliateData,
|
||||
parseFoliateData,
|
||||
FoliateAnnotation,
|
||||
FoliateData,
|
||||
} from '@/services/annotation/providers/foliate';
|
||||
import { mergeBookConfigs } from '@/services/backupService';
|
||||
import { BookConfig, BookNote } from '@/types/book';
|
||||
|
||||
const BOOK_HASH = 'abc123';
|
||||
|
||||
// Freeze Date.now for deterministic tests
|
||||
const NOW = 1700000000000;
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(NOW);
|
||||
});
|
||||
|
||||
describe('getFoliateDataPath', () => {
|
||||
it('should construct path with encoded identifier', () => {
|
||||
const result = getFoliateDataPath('/home/user/.local/share', 'urn:isbn:9780123456789');
|
||||
expect(result).toBe(
|
||||
'/home/user/.local/share/com.github.johnfactotum.Foliate/urn%3Aisbn%3A9780123456789.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle identifiers with special characters', () => {
|
||||
const result = getFoliateDataPath('/data', 'http://example.com/book?id=1&v=2');
|
||||
expect(result).toBe(
|
||||
'/data/com.github.johnfactotum.Foliate/http%3A%2F%2Fexample.com%2Fbook%3Fid%3D1%26v%3D2.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle simple identifier', () => {
|
||||
const result = getFoliateDataPath('/data', 'simple-id');
|
||||
expect(result).toBe('/data/com.github.johnfactotum.Foliate/simple-id.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapFoliateColor', () => {
|
||||
it('should map yellow to highlight/yellow', () => {
|
||||
expect(mapFoliateColor('yellow')).toEqual({ style: 'highlight', color: 'yellow' });
|
||||
});
|
||||
|
||||
it('should map orange to highlight/yellow', () => {
|
||||
expect(mapFoliateColor('orange')).toEqual({ style: 'highlight', color: 'yellow' });
|
||||
});
|
||||
|
||||
it('should map red to highlight/red', () => {
|
||||
expect(mapFoliateColor('red')).toEqual({ style: 'highlight', color: 'red' });
|
||||
});
|
||||
|
||||
it('should map magenta to highlight/violet', () => {
|
||||
expect(mapFoliateColor('magenta')).toEqual({ style: 'highlight', color: 'violet' });
|
||||
});
|
||||
|
||||
it('should map aqua to highlight/blue', () => {
|
||||
expect(mapFoliateColor('aqua')).toEqual({ style: 'highlight', color: 'blue' });
|
||||
});
|
||||
|
||||
it('should map lime to highlight/green', () => {
|
||||
expect(mapFoliateColor('lime')).toEqual({ style: 'highlight', color: 'green' });
|
||||
});
|
||||
|
||||
it('should map underline to underline/red', () => {
|
||||
expect(mapFoliateColor('underline')).toEqual({ style: 'underline', color: 'red' });
|
||||
});
|
||||
|
||||
it('should map squiggly to squiggly/red', () => {
|
||||
expect(mapFoliateColor('squiggly')).toEqual({ style: 'squiggly', color: 'red' });
|
||||
});
|
||||
|
||||
it('should map strikethrough to highlight/red', () => {
|
||||
expect(mapFoliateColor('strikethrough')).toEqual({ style: 'highlight', color: 'red' });
|
||||
});
|
||||
|
||||
it('should default undefined to highlight/yellow', () => {
|
||||
expect(mapFoliateColor(undefined)).toEqual({ style: 'highlight', color: 'yellow' });
|
||||
});
|
||||
|
||||
it('should pass through custom hex color', () => {
|
||||
expect(mapFoliateColor('#ff5500')).toEqual({ style: 'highlight', color: '#ff5500' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertFoliateAnnotation', () => {
|
||||
it('should convert a full annotation', () => {
|
||||
const annotation: FoliateAnnotation = {
|
||||
value: 'epubcfi(/6/4!/4/2,/1:0,/1:10)',
|
||||
text: 'highlighted text',
|
||||
color: 'aqua',
|
||||
note: 'my note',
|
||||
created: '2024-01-15T10:30:00Z',
|
||||
modified: '2024-01-16T12:00:00Z',
|
||||
};
|
||||
const result = convertFoliateAnnotation(BOOK_HASH, annotation);
|
||||
|
||||
expect(result.type).toBe('annotation');
|
||||
expect(result.cfi).toBe('epubcfi(/6/4!/4/2,/1:0,/1:10)');
|
||||
expect(result.text).toBe('highlighted text');
|
||||
expect(result.style).toBe('highlight');
|
||||
expect(result.color).toBe('blue');
|
||||
expect(result.note).toBe('my note');
|
||||
expect(result.createdAt).toBe(new Date('2024-01-15T10:30:00Z').getTime());
|
||||
expect(result.updatedAt).toBe(new Date('2024-01-16T12:00:00Z').getTime());
|
||||
expect(result.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should produce stable IDs for the same CFI', () => {
|
||||
const annotation: FoliateAnnotation = { value: 'epubcfi(/6/4!/4/2,/1:0,/1:10)' };
|
||||
const first = convertFoliateAnnotation(BOOK_HASH, annotation);
|
||||
const second = convertFoliateAnnotation(BOOK_HASH, annotation);
|
||||
expect(first.id).toBe(second.id);
|
||||
});
|
||||
|
||||
it('should produce different IDs for different CFIs', () => {
|
||||
const a = convertFoliateAnnotation(BOOK_HASH, { value: 'cfi-a' });
|
||||
const b = convertFoliateAnnotation(BOOK_HASH, { value: 'cfi-b' });
|
||||
expect(a.id).not.toBe(b.id);
|
||||
});
|
||||
|
||||
it('should handle missing optional fields', () => {
|
||||
const annotation: FoliateAnnotation = {
|
||||
value: 'epubcfi(/6/4)',
|
||||
};
|
||||
const result = convertFoliateAnnotation(BOOK_HASH, annotation);
|
||||
|
||||
expect(result.text).toBe('');
|
||||
expect(result.note).toBe('');
|
||||
expect(result.style).toBe('highlight');
|
||||
expect(result.color).toBe('yellow');
|
||||
expect(result.createdAt).toBe(NOW);
|
||||
expect(result.updatedAt).toBe(NOW);
|
||||
});
|
||||
|
||||
it('should fall back to Date.now() for invalid dates', () => {
|
||||
const annotation: FoliateAnnotation = {
|
||||
value: 'cfi',
|
||||
created: 'not-a-date',
|
||||
modified: 'also-invalid',
|
||||
};
|
||||
const result = convertFoliateAnnotation(BOOK_HASH, annotation);
|
||||
|
||||
expect(result.createdAt).toBe(NOW);
|
||||
expect(result.updatedAt).toBe(NOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertFoliateBookmark', () => {
|
||||
it('should create a bookmark-type note', () => {
|
||||
const result = convertFoliateBookmark(BOOK_HASH, 'epubcfi(/6/8!/4/2)');
|
||||
|
||||
expect(result.type).toBe('bookmark');
|
||||
expect(result.cfi).toBe('epubcfi(/6/8!/4/2)');
|
||||
expect(result.note).toBe('');
|
||||
expect(result.createdAt).toBe(NOW);
|
||||
expect(result.updatedAt).toBe(NOW);
|
||||
expect(result.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should produce stable IDs for the same CFI', () => {
|
||||
const first = convertFoliateBookmark(BOOK_HASH, 'epubcfi(/6/8!/4/2)');
|
||||
const second = convertFoliateBookmark(BOOK_HASH, 'epubcfi(/6/8!/4/2)');
|
||||
expect(first.id).toBe(second.id);
|
||||
});
|
||||
|
||||
it('should produce different IDs from annotations with the same CFI', () => {
|
||||
const bookmark = convertFoliateBookmark(BOOK_HASH, 'cfi-same');
|
||||
const annotation = convertFoliateAnnotation(BOOK_HASH, { value: 'cfi-same' });
|
||||
expect(bookmark.id).not.toBe(annotation.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertFoliateData', () => {
|
||||
it('should convert full data with annotations and bookmarks', () => {
|
||||
const data: FoliateData = {
|
||||
annotations: [
|
||||
{ value: 'cfi-1', text: 'text1', color: 'yellow' },
|
||||
{ value: 'cfi-2', text: 'text2', color: 'red', note: 'note2' },
|
||||
],
|
||||
bookmarks: ['cfi-bm-1', 'cfi-bm-2'],
|
||||
progress: [42, 100],
|
||||
lastLocation: 'cfi-last',
|
||||
};
|
||||
const result = convertFoliateData(BOOK_HASH, data);
|
||||
|
||||
expect(result.booknotes).toHaveLength(4);
|
||||
expect(result.booknotes!.filter((n) => n.type === 'annotation')).toHaveLength(2);
|
||||
expect(result.booknotes!.filter((n) => n.type === 'bookmark')).toHaveLength(2);
|
||||
expect(result.progress).toEqual([42, 100]);
|
||||
expect(result.location).toBe('cfi-last');
|
||||
});
|
||||
|
||||
it('should handle empty arrays', () => {
|
||||
const data: FoliateData = {
|
||||
annotations: [],
|
||||
bookmarks: [],
|
||||
};
|
||||
const result = convertFoliateData(BOOK_HASH, data);
|
||||
|
||||
expect(result.booknotes).toEqual([]);
|
||||
expect(result.progress).toBeUndefined();
|
||||
expect(result.location).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle missing fields', () => {
|
||||
const data: FoliateData = {};
|
||||
const result = convertFoliateData(BOOK_HASH, data);
|
||||
|
||||
expect(result.booknotes).toEqual([]);
|
||||
expect(result.progress).toBeUndefined();
|
||||
expect(result.location).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle only bookmarks', () => {
|
||||
const data: FoliateData = { bookmarks: ['cfi-1'] };
|
||||
const result = convertFoliateData(BOOK_HASH, data);
|
||||
|
||||
expect(result.booknotes).toHaveLength(1);
|
||||
expect(result.booknotes![0]!.type).toBe('bookmark');
|
||||
});
|
||||
|
||||
it('should handle only progress', () => {
|
||||
const data: FoliateData = { progress: [10, 200] };
|
||||
const result = convertFoliateData(BOOK_HASH, data);
|
||||
|
||||
expect(result.booknotes).toEqual([]);
|
||||
expect(result.progress).toEqual([10, 200]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFoliateData', () => {
|
||||
it('should parse valid JSON', () => {
|
||||
const json = JSON.stringify({
|
||||
annotations: [{ value: 'cfi-1', text: 'hello' }],
|
||||
bookmarks: ['cfi-2'],
|
||||
progress: [5, 100],
|
||||
});
|
||||
const result = parseFoliateData(json);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.annotations).toHaveLength(1);
|
||||
expect(result!.bookmarks).toHaveLength(1);
|
||||
expect(result!.progress).toEqual([5, 100]);
|
||||
});
|
||||
|
||||
it('should return null for invalid JSON', () => {
|
||||
expect(parseFoliateData('not json')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-object JSON (array)', () => {
|
||||
expect(parseFoliateData('[1, 2, 3]')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-object JSON (string)', () => {
|
||||
expect(parseFoliateData('"hello"')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for null JSON', () => {
|
||||
expect(parseFoliateData('null')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const result = parseFoliateData('{}');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration: merge converted Foliate data with existing config', () => {
|
||||
it('should merge Foliate notes with existing config notes', () => {
|
||||
const existingNote: BookNote = {
|
||||
id: 'existing-1',
|
||||
type: 'annotation',
|
||||
cfi: 'existing-cfi',
|
||||
note: 'existing note',
|
||||
createdAt: 100,
|
||||
updatedAt: 100,
|
||||
};
|
||||
const currentConfig: Partial<BookConfig> = {
|
||||
progress: [50, 200],
|
||||
booknotes: [existingNote],
|
||||
updatedAt: 500,
|
||||
};
|
||||
|
||||
const foliateData: FoliateData = {
|
||||
annotations: [{ value: 'foliate-cfi', text: 'foliate text', color: 'lime' }],
|
||||
bookmarks: ['bookmark-cfi'],
|
||||
progress: [30, 200],
|
||||
};
|
||||
const converted = convertFoliateData(BOOK_HASH, foliateData);
|
||||
const merged = mergeBookConfigs(currentConfig, converted);
|
||||
|
||||
// Should keep higher progress from current (50 > 30)
|
||||
expect(merged.progress).toEqual([50, 200]);
|
||||
// Should have all notes: 1 existing + 1 annotation + 1 bookmark = 3
|
||||
expect(merged.booknotes).toHaveLength(3);
|
||||
expect(merged.booknotes!.find((n) => n.id === 'existing-1')).toBeDefined();
|
||||
expect(merged.booknotes!.filter((n) => n.type === 'bookmark')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should use Foliate progress when higher', () => {
|
||||
const currentConfig: Partial<BookConfig> = {
|
||||
progress: [10, 200],
|
||||
updatedAt: 500,
|
||||
};
|
||||
const foliateData: FoliateData = {
|
||||
progress: [80, 200],
|
||||
lastLocation: 'foliate-loc',
|
||||
};
|
||||
const converted = convertFoliateData(BOOK_HASH, foliateData);
|
||||
const merged = mergeBookConfigs(currentConfig, converted);
|
||||
|
||||
expect(merged.progress).toEqual([80, 200]);
|
||||
expect(merged.location).toBe('foliate-loc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,575 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Book } from '@/types/book';
|
||||
import { getMetadataHash } from '@/utils/book';
|
||||
|
||||
const mockOpen = vi.hoisted(() => vi.fn());
|
||||
const mockPartialMD5 = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/utils/md5', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/utils/md5')>('@/utils/md5');
|
||||
return { ...actual, partialMD5: mockPartialMD5 };
|
||||
});
|
||||
|
||||
vi.mock('@/libs/document', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/libs/document')>('@/libs/document');
|
||||
class MockDocumentLoader {
|
||||
open() {
|
||||
return mockOpen();
|
||||
}
|
||||
}
|
||||
return { ...actual, DocumentLoader: MockDocumentLoader };
|
||||
});
|
||||
|
||||
vi.mock('@/utils/txt', () => ({ TxtToEpubConverter: vi.fn() }));
|
||||
vi.mock('@/utils/svg', () => ({ svg2png: vi.fn() }));
|
||||
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
|
||||
vi.mock('@/libs/storage', () => ({
|
||||
downloadFile: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
createProgressHandler: vi.fn(),
|
||||
batchGetDownloadUrls: vi.fn(),
|
||||
}));
|
||||
|
||||
import { BaseAppService } from '@/services/appService';
|
||||
|
||||
// Concrete test subclass of BaseAppService with mocked fs
|
||||
class TestAppService extends BaseAppService {
|
||||
protected fs = {
|
||||
openFile: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
copyFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
readDir: vi.fn(),
|
||||
createDir: vi.fn(),
|
||||
removeDir: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
stats: vi.fn(),
|
||||
resolvePath: vi.fn(),
|
||||
getURL: vi.fn(),
|
||||
getBlobURL: vi.fn().mockResolvedValue(''),
|
||||
getImageURL: vi.fn(),
|
||||
getPrefix: vi.fn(),
|
||||
};
|
||||
|
||||
protected resolvePath() {
|
||||
return { baseDir: 0, basePrefix: async () => '', fp: '', base: 'Books' as const };
|
||||
}
|
||||
|
||||
async init() {}
|
||||
async setCustomRootDir() {}
|
||||
async selectDirectory() {
|
||||
return '';
|
||||
}
|
||||
async selectFiles() {
|
||||
return [];
|
||||
}
|
||||
async saveFile() {
|
||||
return false;
|
||||
}
|
||||
async ask() {
|
||||
return false;
|
||||
}
|
||||
async openDatabase() {
|
||||
return {} as ReturnType<BaseAppService['openDatabase']>;
|
||||
}
|
||||
async createWindow() {}
|
||||
async getCacheDir() {
|
||||
return '';
|
||||
}
|
||||
async clearWebviewCache() {}
|
||||
async showNotification() {}
|
||||
|
||||
getFs() {
|
||||
return this.fs;
|
||||
}
|
||||
}
|
||||
|
||||
function makeBook(overrides: Partial<Book> = {}): Book {
|
||||
return {
|
||||
hash: 'old-hash-123',
|
||||
format: 'EPUB' as Book['format'],
|
||||
title: 'Test Book',
|
||||
sourceTitle: 'Test Book',
|
||||
author: 'Test Author',
|
||||
createdAt: Date.now() - 10000,
|
||||
updatedAt: Date.now() - 10000,
|
||||
downloadedAt: Date.now() - 10000,
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const TEST_METADATA = {
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
language: 'en',
|
||||
identifier: 'isbn-123',
|
||||
};
|
||||
|
||||
function setupMockBookDoc(metadata: Record<string, unknown> = TEST_METADATA) {
|
||||
const bookDoc = {
|
||||
metadata,
|
||||
getCover: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
mockOpen.mockResolvedValue({ book: bookDoc, format: 'EPUB' });
|
||||
}
|
||||
|
||||
describe('importBook metaHash deduplication', () => {
|
||||
let service: TestAppService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new TestAppService();
|
||||
const fs = service.getFs();
|
||||
fs.exists.mockResolvedValue(false);
|
||||
fs.createDir.mockResolvedValue(undefined);
|
||||
fs.writeFile.mockResolvedValue(undefined);
|
||||
fs.removeDir.mockResolvedValue(undefined);
|
||||
fs.readFile.mockResolvedValue('{}');
|
||||
});
|
||||
|
||||
it('should detect metaHash match and override existing book with new hash', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const existingBook = makeBook({ hash: 'old-hash-123', metaHash });
|
||||
const books: Book[] = [existingBook];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('new-hash-456');
|
||||
setupMockBookDoc();
|
||||
|
||||
const mockFile = new File(['new content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
const result = await service.importBook(mockFile, books);
|
||||
|
||||
// Should return the existing book, not a new one
|
||||
expect(result).toBe(existingBook);
|
||||
// Library should still have only one book
|
||||
expect(books.length).toBe(1);
|
||||
// Hash should be updated to new file's content hash
|
||||
expect(existingBook.hash).toBe('new-hash-456');
|
||||
// Metadata should be overridden
|
||||
expect(existingBook.metadata).toEqual(TEST_METADATA);
|
||||
// metaHash should be set
|
||||
expect(existingBook.metaHash).toBe(metaHash);
|
||||
});
|
||||
|
||||
it('should not match metaHash for deleted books', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const deletedBook = makeBook({
|
||||
hash: 'old-hash-123',
|
||||
metaHash,
|
||||
deletedAt: Date.now(),
|
||||
});
|
||||
const books: Book[] = [deletedBook];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('new-hash-456');
|
||||
setupMockBookDoc();
|
||||
|
||||
const mockFile = new File(['new content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
const result = await service.importBook(mockFile, books);
|
||||
|
||||
// Should create a new book since the existing one is deleted
|
||||
expect(result).not.toBe(deletedBook);
|
||||
expect(books.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should migrate config to new directory with updated bookHash and metaHash', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
const existingBook = makeBook({ hash: 'old-hash-123', metaHash });
|
||||
const books: Book[] = [existingBook];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('new-hash-456');
|
||||
setupMockBookDoc();
|
||||
|
||||
const fs = service.getFs();
|
||||
fs.exists.mockImplementation(async (path: string) => {
|
||||
if (path === 'old-hash-123/config.json') return true;
|
||||
if (path === 'old-hash-123') return true;
|
||||
return false;
|
||||
});
|
||||
fs.readFile.mockResolvedValue('{"readProgress":0.5}');
|
||||
|
||||
const mockFile = new File(['new content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
await service.importBook(mockFile, books);
|
||||
|
||||
// Should have read config from old directory
|
||||
expect(fs.readFile).toHaveBeenCalledWith('old-hash-123/config.json', 'Books', 'text');
|
||||
// Should have written config to new directory with updated bookHash and metaHash
|
||||
const writeCalls = fs.writeFile.mock.calls;
|
||||
const configWrite = writeCalls.find((c: unknown[]) => c[0] === 'new-hash-456/config.json');
|
||||
expect(configWrite).toBeDefined();
|
||||
const writtenConfig = JSON.parse(configWrite![2] as string);
|
||||
expect(writtenConfig.bookHash).toBe('new-hash-456');
|
||||
expect(writtenConfig.metaHash).toBe(metaHash);
|
||||
expect(writtenConfig.readProgress).toBe(0.5);
|
||||
// Should have removed old directory
|
||||
expect(fs.removeDir).toHaveBeenCalledWith('old-hash-123', 'Books', true);
|
||||
});
|
||||
|
||||
it('should prefer exact file hash match over metaHash match', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const exactMatchBook = makeBook({ hash: 'same-hash', metaHash });
|
||||
const metaMatchBook = makeBook({ hash: 'different-hash', metaHash });
|
||||
const books: Book[] = [exactMatchBook, metaMatchBook];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('same-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
const result = await service.importBook(mockFile, books);
|
||||
|
||||
// Should return the exact hash match, not the metaHash match
|
||||
expect(result).toBe(exactMatchBook);
|
||||
// metaHash duplicate should be soft-deleted during aggregation
|
||||
expect(metaMatchBook.deletedAt).toBeTruthy();
|
||||
expect(exactMatchBook.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should not check metaHash for transient imports', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
const existingBook = makeBook({ hash: 'old-hash', metaHash });
|
||||
const books: Book[] = [existingBook];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('new-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const fs = service.getFs();
|
||||
fs.openFile.mockResolvedValue(new File(['content'], 'test.epub'));
|
||||
|
||||
// Transient import requires string file path
|
||||
const result = await service.importBook('/path/to/test.epub', books, true, true, false, true);
|
||||
|
||||
// Should create a new entry, not override existing
|
||||
expect(result).not.toBe(existingBook);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importBook metaHash aggregation', () => {
|
||||
let service: TestAppService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new TestAppService();
|
||||
const fs = service.getFs();
|
||||
fs.exists.mockResolvedValue(false);
|
||||
fs.createDir.mockResolvedValue(undefined);
|
||||
fs.writeFile.mockResolvedValue(undefined);
|
||||
fs.removeDir.mockResolvedValue(undefined);
|
||||
fs.readFile.mockResolvedValue('{}');
|
||||
});
|
||||
|
||||
it('should remove all duplicates with same metaHash and format', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const book1 = makeBook({ hash: 'hash-1', metaHash });
|
||||
const book2 = makeBook({ hash: 'hash-2', metaHash });
|
||||
const book3 = makeBook({ hash: 'hash-3', metaHash });
|
||||
const unrelated = makeBook({ hash: 'other', metaHash: 'different' });
|
||||
const books: Book[] = [book1, book2, book3, unrelated];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('new-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
await service.importBook(mockFile, books);
|
||||
|
||||
// Duplicates should be soft-deleted, survivor updated, unrelated untouched
|
||||
const active = books.filter((b) => b.metaHash === metaHash && !b.deletedAt);
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0]!.hash).toBe('new-hash');
|
||||
expect(book2.deletedAt).toBeTruthy();
|
||||
expect(book3.deletedAt).toBeTruthy();
|
||||
expect(unrelated.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should select base config with largest progress pagenum', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const book1 = makeBook({ hash: 'hash-1', metaHash });
|
||||
const book2 = makeBook({ hash: 'hash-2', metaHash });
|
||||
const book3 = makeBook({ hash: 'hash-3', metaHash });
|
||||
const books: Book[] = [book1, book2, book3];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('new-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const fs = service.getFs();
|
||||
fs.exists.mockImplementation(async (path: string) => {
|
||||
if (path.endsWith('/config.json')) return true;
|
||||
if (['hash-1', 'hash-2', 'hash-3'].includes(path)) return true;
|
||||
return false;
|
||||
});
|
||||
fs.readFile.mockImplementation(async (path: string) => {
|
||||
if (path === 'hash-1/config.json')
|
||||
return JSON.stringify({ updatedAt: 3000, progress: [10, 200], location: 'loc1' });
|
||||
if (path === 'hash-2/config.json')
|
||||
return JSON.stringify({ updatedAt: 1000, progress: [50, 200], location: 'loc2' });
|
||||
if (path === 'hash-3/config.json')
|
||||
return JSON.stringify({ updatedAt: 2000, progress: [30, 200], location: 'loc3' });
|
||||
return '{}';
|
||||
});
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
await service.importBook(mockFile, books);
|
||||
|
||||
const writeCalls = fs.writeFile.mock.calls;
|
||||
const configWrite = writeCalls.find(
|
||||
(c: unknown[]) => (c[0] as string) === 'new-hash/config.json',
|
||||
);
|
||||
expect(configWrite).toBeDefined();
|
||||
const writtenConfig = JSON.parse(configWrite![2] as string);
|
||||
// Base config should be from hash-2 (largest progress page 50)
|
||||
expect(writtenConfig.location).toBe('loc2');
|
||||
expect(writtenConfig.progress).toEqual([50, 200]);
|
||||
});
|
||||
|
||||
it('should merge booknotes with unique id from all configs', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const book1 = makeBook({ hash: 'hash-1', metaHash });
|
||||
const book2 = makeBook({ hash: 'hash-2', metaHash });
|
||||
const books: Book[] = [book1, book2];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('new-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const fs = service.getFs();
|
||||
fs.exists.mockImplementation(async (path: string) => {
|
||||
if (path.endsWith('/config.json')) return true;
|
||||
if (['hash-1', 'hash-2'].includes(path)) return true;
|
||||
return false;
|
||||
});
|
||||
fs.readFile.mockImplementation(async (path: string) => {
|
||||
if (path === 'hash-1/config.json')
|
||||
return JSON.stringify({
|
||||
updatedAt: 1000,
|
||||
progress: [80, 200],
|
||||
booknotes: [
|
||||
{
|
||||
id: 'note-a',
|
||||
type: 'annotation',
|
||||
cfi: 'cfi-a',
|
||||
note: 'A',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
{
|
||||
id: 'note-shared',
|
||||
type: 'annotation',
|
||||
cfi: 'cfi-s',
|
||||
note: 'old',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === 'hash-2/config.json')
|
||||
return JSON.stringify({
|
||||
updatedAt: 2000,
|
||||
progress: [20, 200],
|
||||
booknotes: [
|
||||
{ id: 'note-b', type: 'bookmark', cfi: 'cfi-b', note: 'B', createdAt: 2, updatedAt: 2 },
|
||||
{
|
||||
id: 'note-shared',
|
||||
type: 'annotation',
|
||||
cfi: 'cfi-s',
|
||||
note: 'newer',
|
||||
createdAt: 1,
|
||||
updatedAt: 5,
|
||||
},
|
||||
],
|
||||
});
|
||||
return '{}';
|
||||
});
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
await service.importBook(mockFile, books);
|
||||
|
||||
const writeCalls = fs.writeFile.mock.calls;
|
||||
const configWrite = writeCalls.find(
|
||||
(c: unknown[]) => (c[0] as string) === 'new-hash/config.json',
|
||||
);
|
||||
expect(configWrite).toBeDefined();
|
||||
const writtenConfig = JSON.parse(configWrite![2] as string);
|
||||
// Base should be hash-1 (progress page 80 > 20)
|
||||
expect(writtenConfig.progress).toEqual([80, 200]);
|
||||
// Booknotes should be merged: note-a, note-b, and note-shared (latest updatedAt wins)
|
||||
const notes = writtenConfig.booknotes as Array<{ id: string; note: string; updatedAt: number }>;
|
||||
expect(notes).toHaveLength(3);
|
||||
expect(notes.find((n) => n.id === 'note-a')).toBeDefined();
|
||||
expect(notes.find((n) => n.id === 'note-b')).toBeDefined();
|
||||
const shared = notes.find((n) => n.id === 'note-shared');
|
||||
expect(shared!.note).toBe('newer');
|
||||
expect(shared!.updatedAt).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle configs with missing progress when merging', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const book1 = makeBook({ hash: 'hash-1', metaHash });
|
||||
const book2 = makeBook({ hash: 'hash-2', metaHash });
|
||||
const books: Book[] = [book1, book2];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('new-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const fs = service.getFs();
|
||||
fs.exists.mockImplementation(async (path: string) => {
|
||||
if (path.endsWith('/config.json')) return true;
|
||||
if (['hash-1', 'hash-2'].includes(path)) return true;
|
||||
return false;
|
||||
});
|
||||
fs.readFile.mockImplementation(async (path: string) => {
|
||||
if (path === 'hash-1/config.json')
|
||||
return JSON.stringify({ updatedAt: 1000, location: 'loc1' });
|
||||
if (path === 'hash-2/config.json')
|
||||
return JSON.stringify({ updatedAt: 2000, progress: [5, 100], location: 'loc2' });
|
||||
return '{}';
|
||||
});
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
await service.importBook(mockFile, books);
|
||||
|
||||
const writeCalls = fs.writeFile.mock.calls;
|
||||
const configWrite = writeCalls.find(
|
||||
(c: unknown[]) => (c[0] as string) === 'new-hash/config.json',
|
||||
);
|
||||
expect(configWrite).toBeDefined();
|
||||
const writtenConfig = JSON.parse(configWrite![2] as string);
|
||||
// hash-2 has progress [5, 100], hash-1 has none (treated as 0) — hash-2 wins
|
||||
expect(writtenConfig.progress).toEqual([5, 100]);
|
||||
expect(writtenConfig.location).toBe('loc2');
|
||||
});
|
||||
|
||||
it('should not aggregate books with different formats', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const epubBook = makeBook({ hash: 'epub-hash', metaHash });
|
||||
const pdfBook = makeBook({
|
||||
hash: 'pdf-hash',
|
||||
metaHash,
|
||||
format: 'PDF' as Book['format'],
|
||||
});
|
||||
const books: Book[] = [epubBook, pdfBook];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('new-hash');
|
||||
setupMockBookDoc(); // Opens as EPUB
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
await service.importBook(mockFile, books);
|
||||
|
||||
// PDF book should not be soft-deleted (different format)
|
||||
expect(pdfBook.deletedAt).toBeNull();
|
||||
// EPUB book should survive (promoted as existing, not a duplicate of itself)
|
||||
expect(epubBook.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should clean up directories of removed duplicates', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const book1 = makeBook({ hash: 'hash-1', metaHash });
|
||||
const book2 = makeBook({ hash: 'hash-2', metaHash });
|
||||
const book3 = makeBook({ hash: 'hash-3', metaHash });
|
||||
const books: Book[] = [book1, book2, book3];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('new-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const fs = service.getFs();
|
||||
fs.exists.mockImplementation(async (path: string) => {
|
||||
return ['hash-2', 'hash-3'].includes(path);
|
||||
});
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
await service.importBook(mockFile, books);
|
||||
|
||||
// Duplicates should be soft-deleted and their directories cleaned up
|
||||
expect(book2.deletedAt).toBeTruthy();
|
||||
expect(book3.deletedAt).toBeTruthy();
|
||||
const removeDirPaths = fs.removeDir.mock.calls.map((c: unknown[]) => c[0]);
|
||||
expect(removeDirPaths).toContain('hash-2');
|
||||
expect(removeDirPaths).toContain('hash-3');
|
||||
});
|
||||
|
||||
it('should remove metaHash duplicates even with exact hash match', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const exactMatch = makeBook({ hash: 'exact-hash', metaHash });
|
||||
const dup1 = makeBook({ hash: 'dup-1', metaHash });
|
||||
const dup2 = makeBook({ hash: 'dup-2', metaHash });
|
||||
const books: Book[] = [exactMatch, dup1, dup2];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('exact-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const fs = service.getFs();
|
||||
fs.exists.mockImplementation(async (path: string) => {
|
||||
return ['dup-1', 'dup-2'].includes(path);
|
||||
});
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
const result = await service.importBook(mockFile, books);
|
||||
|
||||
expect(result).toBe(exactMatch);
|
||||
expect(exactMatch.deletedAt).toBeNull();
|
||||
expect(dup1.deletedAt).toBeTruthy();
|
||||
expect(dup2.deletedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should merge configs on exact hash match with duplicates', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const exactMatch = makeBook({ hash: 'exact-hash', metaHash });
|
||||
const dup = makeBook({ hash: 'dup-hash', metaHash });
|
||||
const books: Book[] = [exactMatch, dup];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('exact-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const fs = service.getFs();
|
||||
fs.exists.mockImplementation(async (path: string) => {
|
||||
if (path.endsWith('/config.json')) return true;
|
||||
if (path === 'dup-hash') return true;
|
||||
return false;
|
||||
});
|
||||
fs.readFile.mockImplementation(async (path: string) => {
|
||||
if (path === 'exact-hash/config.json')
|
||||
return JSON.stringify({
|
||||
updatedAt: 1000,
|
||||
progress: [10, 100],
|
||||
booknotes: [
|
||||
{ id: 'n1', type: 'annotation', cfi: 'c1', note: 'x', createdAt: 1, updatedAt: 1 },
|
||||
],
|
||||
});
|
||||
if (path === 'dup-hash/config.json')
|
||||
return JSON.stringify({
|
||||
updatedAt: 5000,
|
||||
progress: [70, 100],
|
||||
location: 'newer',
|
||||
booknotes: [
|
||||
{ id: 'n2', type: 'bookmark', cfi: 'c2', note: 'y', createdAt: 2, updatedAt: 2 },
|
||||
],
|
||||
});
|
||||
return '{}';
|
||||
});
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
await service.importBook(mockFile, books);
|
||||
|
||||
const writeCalls = fs.writeFile.mock.calls;
|
||||
const configWrite = writeCalls.find(
|
||||
(c: unknown[]) => (c[0] as string) === 'exact-hash/config.json',
|
||||
);
|
||||
expect(configWrite).toBeDefined();
|
||||
const writtenConfig = JSON.parse(configWrite![2] as string);
|
||||
// Base config from dup (progress page 70 > 10)
|
||||
expect(writtenConfig.progress).toEqual([70, 100]);
|
||||
expect(writtenConfig.location).toBe('newer');
|
||||
expect(writtenConfig.bookHash).toBe('exact-hash');
|
||||
// Merged booknotes from both
|
||||
expect(writtenConfig.booknotes).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
||||
import * as fsp from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { NodeAppService } from '@/services/nodeAppService';
|
||||
import { fsTests } from './suites/fs-tests';
|
||||
import { libraryTests } from './suites/library-tests';
|
||||
import { bookTests } from './suites/book-tests';
|
||||
|
||||
const FIXTURES_DIR = path.join(process.cwd(), 'src/__tests__/fixtures/data');
|
||||
|
||||
async function getBookFile(name: string): Promise<File> {
|
||||
const buf = await fsp.readFile(path.join(FIXTURES_DIR, name));
|
||||
return new File([buf], name);
|
||||
}
|
||||
|
||||
const SANDBOX_DIR = path.join(process.cwd(), '.test-sandbox-node');
|
||||
|
||||
describe('NodeAppService', () => {
|
||||
let tmpDir: string;
|
||||
let service: NodeAppService;
|
||||
|
||||
beforeAll(async () => {
|
||||
await fsp.mkdir(SANDBOX_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fsp.rm(SANDBOX_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(SANDBOX_DIR, 'node-'));
|
||||
service = new NodeAppService(tmpDir);
|
||||
await service.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should copy files from absolute path', async () => {
|
||||
const srcPath = path.join(tmpDir, 'source.txt');
|
||||
await fsp.writeFile(srcPath, 'copy me');
|
||||
await service.copyFile(srcPath, 'copied.txt', 'Data');
|
||||
const content = await service.readFile('copied.txt', 'Data', 'text');
|
||||
expect(content).toBe('copy me');
|
||||
});
|
||||
|
||||
it('should save files via saveFile', async () => {
|
||||
const filepath = path.join(tmpDir, 'saved.txt');
|
||||
const result = await service.saveFile('saved.txt', 'saved content', { filePath: filepath });
|
||||
expect(result).toBe(true);
|
||||
const content = await fsp.readFile(filepath, 'utf-8');
|
||||
expect(content).toBe('saved content');
|
||||
});
|
||||
|
||||
it('should set localBooksDir after init', () => {
|
||||
expect(service.localBooksDir).toBe(path.join(tmpDir, 'Readest', 'Books'));
|
||||
});
|
||||
|
||||
it('should resolve file paths correctly', async () => {
|
||||
const resolved = await service.resolveFilePath('test.json', 'Books');
|
||||
expect(resolved).toBe(path.join(tmpDir, 'Readest', 'Books', 'test.json'));
|
||||
});
|
||||
|
||||
it('should resolve empty path to prefix', async () => {
|
||||
const resolved = await service.resolveFilePath('', 'Data');
|
||||
expect(resolved).toBe(path.join(tmpDir, 'Readest'));
|
||||
});
|
||||
|
||||
it('should switch to new root via setCustomRootDir', async () => {
|
||||
const newRoot = await fsp.mkdtemp(path.join(SANDBOX_DIR, 'custom-'));
|
||||
try {
|
||||
await service.setCustomRootDir(newRoot);
|
||||
expect(service.localBooksDir).toBe(path.join(newRoot, 'Readest', 'Books'));
|
||||
await service.writeFile('test.txt', 'Settings', 'settings data');
|
||||
const content = await service.readFile('test.txt', 'Settings', 'text');
|
||||
expect(content).toBe('settings data');
|
||||
} finally {
|
||||
await fsp.rm(newRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should use system dirs when no customRootDir', async () => {
|
||||
const defaultService = new NodeAppService();
|
||||
const settingsPrefix = await defaultService.resolveFilePath('', 'Settings');
|
||||
expect(settingsPrefix).toBeTruthy();
|
||||
expect(path.isAbsolute(settingsPrefix)).toBe(true);
|
||||
expect(settingsPrefix.toLowerCase()).toContain('readest');
|
||||
});
|
||||
|
||||
fsTests(() => service);
|
||||
libraryTests(() => service);
|
||||
bookTests(() => service, getBookFile);
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { Book, BookNote } from '@/types/book';
|
||||
import { AppService } from '@/types/system';
|
||||
|
||||
function makeFakeBook(overrides: Partial<Book> = {}): Book {
|
||||
return {
|
||||
hash: 'nonexistent-hash',
|
||||
format: 'EPUB',
|
||||
title: 'Missing Book',
|
||||
author: 'Nobody',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function bookTests(
|
||||
getService: () => AppService,
|
||||
getBookFile: (name: string) => Promise<File | string>,
|
||||
) {
|
||||
// Ensure the Books base directory exists before each book test.
|
||||
// writeFile creates parent dirs, but importBook uses createDir (non-recursive).
|
||||
beforeEach(async () => {
|
||||
await getService().createDir('', 'Books', true);
|
||||
});
|
||||
|
||||
describe('Book import', () => {
|
||||
it('should import an EPUB book with correct metadata', async () => {
|
||||
const service = getService();
|
||||
const file = await getBookFile('sample-alice.epub');
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(file, books);
|
||||
|
||||
expect(book).not.toBeNull();
|
||||
expect(book!.format).toBe('EPUB');
|
||||
expect(book!.hash).toBeTruthy();
|
||||
expect(book!.title).toBeTruthy();
|
||||
expect(book!.author).toBeTruthy();
|
||||
expect(books).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should deduplicate when importing the same file twice', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
|
||||
const file1 = await getBookFile('sample-alice.epub');
|
||||
const first = await service.importBook(file1, books);
|
||||
|
||||
const file2 = await getBookFile('sample-alice.epub');
|
||||
const second = await service.importBook(file2, books);
|
||||
|
||||
expect(books).toHaveLength(1);
|
||||
expect(second!.hash).toBe(first!.hash);
|
||||
});
|
||||
|
||||
it('should not add duplicate when reimporting with overwrite', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
|
||||
const file1 = await getBookFile('sample-alice.epub');
|
||||
const first = await service.importBook(file1, books);
|
||||
|
||||
const file2 = await getBookFile('sample-alice.epub');
|
||||
const second = await service.importBook(file2, books, true, true, true);
|
||||
|
||||
expect(books).toHaveLength(1);
|
||||
expect(second!.hash).toBe(first!.hash);
|
||||
expect(second!.updatedAt).toBeGreaterThanOrEqual(first!.updatedAt);
|
||||
});
|
||||
|
||||
it('should set hash and timestamps on imported book', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
const before = Date.now();
|
||||
const book = await service.importBook(await getBookFile('sample-alice.epub'), books);
|
||||
|
||||
expect(book!.hash).toBeTruthy();
|
||||
expect(book!.createdAt).toBeGreaterThanOrEqual(before);
|
||||
expect(book!.updatedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(book!.downloadedAt).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Book availability', () => {
|
||||
it('should report imported book as available', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(await getBookFile('sample-alice.epub'), books);
|
||||
|
||||
expect(await service.isBookAvailable(book!)).toBe(true);
|
||||
});
|
||||
|
||||
it('should report non-existent book as unavailable', async () => {
|
||||
const service = getService();
|
||||
expect(await service.isBookAvailable(makeFakeBook())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Book file size', () => {
|
||||
it('should return file size for imported book', async () => {
|
||||
const service = getService();
|
||||
const file = await getBookFile('sample-alice.epub');
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(file, books);
|
||||
|
||||
const size = await service.getBookFileSize(book!);
|
||||
expect(size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return null for non-existent book', async () => {
|
||||
const service = getService();
|
||||
expect(await service.getBookFileSize(makeFakeBook())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Book content', () => {
|
||||
it('should load content for imported book', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(await getBookFile('sample-alice.epub'), books);
|
||||
|
||||
const content = await service.loadBookContent(book!);
|
||||
expect(content.book.hash).toBe(book!.hash);
|
||||
expect(content.file).toBeDefined();
|
||||
expect(content.file.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should throw when loading content for non-existent book', async () => {
|
||||
const service = getService();
|
||||
await expect(service.loadBookContent(makeFakeBook())).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Book config', () => {
|
||||
it('should load default config for newly imported book', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(await getBookFile('sample-alice.epub'), books);
|
||||
|
||||
const settings = await service.loadSettings();
|
||||
const config = await service.loadBookConfig(book!, settings);
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config.updatedAt).toBeDefined();
|
||||
expect(config.viewSettings).toBeDefined();
|
||||
expect(config.searchConfig).toBeDefined();
|
||||
});
|
||||
|
||||
it('should save and load book config with progress and location', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(await getBookFile('sample-alice.epub'), books);
|
||||
|
||||
const settings = await service.loadSettings();
|
||||
const config = await service.loadBookConfig(book!, settings);
|
||||
|
||||
config.location = 'epubcfi(/6/4!/4/2/1:0)';
|
||||
config.progress = [5, 100];
|
||||
|
||||
await service.saveBookConfig(book!, config, settings);
|
||||
const loaded = await service.loadBookConfig(book!, settings);
|
||||
|
||||
expect(loaded.location).toBe('epubcfi(/6/4!/4/2/1:0)');
|
||||
expect(loaded.progress).toEqual([5, 100]);
|
||||
});
|
||||
|
||||
it('should save and load book config with annotations', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(await getBookFile('sample-alice.epub'), books);
|
||||
|
||||
const settings = await service.loadSettings();
|
||||
const config = await service.loadBookConfig(book!, settings);
|
||||
|
||||
const note: BookNote = {
|
||||
id: 'note-1',
|
||||
type: 'annotation',
|
||||
cfi: 'epubcfi(/6/4!/4/2/1:0)',
|
||||
note: 'Test annotation',
|
||||
style: 'highlight',
|
||||
color: 'yellow',
|
||||
text: 'highlighted text',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
config.booknotes = [note];
|
||||
|
||||
await service.saveBookConfig(book!, config, settings);
|
||||
const loaded = await service.loadBookConfig(book!, settings);
|
||||
|
||||
expect(loaded.booknotes).toHaveLength(1);
|
||||
expect(loaded.booknotes![0]!.id).toBe('note-1');
|
||||
expect(loaded.booknotes![0]!.note).toBe('Test annotation');
|
||||
expect(loaded.booknotes![0]!.text).toBe('highlighted text');
|
||||
});
|
||||
|
||||
it('should save config without settings and load with settings', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(await getBookFile('sample-alice.epub'), books);
|
||||
|
||||
const rawConfig = {
|
||||
updatedAt: Date.now(),
|
||||
location: 'raw-location',
|
||||
progress: [10, 200] as [number, number],
|
||||
};
|
||||
|
||||
await service.saveBookConfig(book!, rawConfig);
|
||||
const settings = await service.loadSettings();
|
||||
const loaded = await service.loadBookConfig(book!, settings);
|
||||
|
||||
expect(loaded.location).toBe('raw-location');
|
||||
expect(loaded.progress).toEqual([10, 200]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Refresh metadata', () => {
|
||||
it('should refresh metadata for a single book', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(await getBookFile('sample-alice.epub'), books);
|
||||
expect(book).not.toBeNull();
|
||||
|
||||
// Clear metadata to simulate a book imported before metadata parsing was added
|
||||
book!.metadata = undefined;
|
||||
book!.primaryLanguage = undefined;
|
||||
|
||||
const result = await service.refreshBookMetadata(book!);
|
||||
expect(result).toBe(true);
|
||||
expect(book!.metadata).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not update updatedAt', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(await getBookFile('sample-alice.epub'), books);
|
||||
expect(book).not.toBeNull();
|
||||
|
||||
const oldUpdatedAt = book!.updatedAt;
|
||||
await service.refreshBookMetadata(book!);
|
||||
expect(book!.updatedAt).toBe(oldUpdatedAt);
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user