forked from akai/readest
Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 66d2fdf999 | |||
| d8e0ceeff1 | |||
| f3ad97b989 | |||
| 1bb49ab023 | |||
| f9a0b39586 | |||
| c533da498d | |||
| b50bc0b854 | |||
| 96c465931c | |||
| 2bd54ac236 | |||
| 6ad549d13c | |||
| 67249370c9 | |||
| fe3ab011ca | |||
| 6cb4278b98 | |||
| 51468862a2 | |||
| b3a44d066f | |||
| 68d4538d40 | |||
| 80105af839 | |||
| 3904c1e8e7 | |||
| 99be6c58e2 | |||
| 664cc772d0 | |||
| 21208adbcf | |||
| 70eb59e2d6 | |||
| 3e7b57282e | |||
| 534582b125 | |||
| 4465e6986e | |||
| a535f6419e | |||
| 65da9c1d47 | |||
| 5f71fd9e47 | |||
| dcf75e07d1 | |||
| 79ba9b3818 | |||
| 128b238bcb | |||
| e26f7e6a2c | |||
| 4c5ff59bcf | |||
| 99b2a34bd2 | |||
| 40f3268ef3 | |||
| 4dac0850c5 | |||
| 118538ba35 | |||
| 4a92cacd84 | |||
| ce53cd2b47 | |||
| b99c1bc19a | |||
| eec2c39f19 | |||
| c6ae85484e | |||
| 7a9f46e93c | |||
| b9f6578127 | |||
| e75a3d254e | |||
| ea15906acf | |||
| 15d2784725 | |||
| ae3bb9da9a | |||
| 239b32fcc2 | |||
| f64739419b | |||
| af8f036ca3 | |||
| 24a87508c6 | |||
| 548d50a882 | |||
| 1e81ab5205 | |||
| a47a480aa2 | |||
| 411f9e236d | |||
| 950bbd0821 | |||
| 2824e50b51 | |||
| 129720c916 | |||
| fd3533dba1 | |||
| 920032155b | |||
| 226bf7033e | |||
| 9444be7fcc | |||
| c9a69a922b | |||
| 9a11b05833 | |||
| 6e603ee38f | |||
| 03fd6e2e6f | |||
| 7dd11c0fb0 | |||
| 968597e52c | |||
| a534050b19 | |||
| 0be828fd66 | |||
| c6d4e2bdd6 | |||
| bf72ab86cd | |||
| fb49ddf484 | |||
| d8817a88b9 | |||
| 475becafe3 | |||
| df165576e6 | |||
| 051f2e5b13 |
@@ -11,8 +11,6 @@ target
|
||||
.gitignore
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
**/dist
|
||||
.next
|
||||
**/.next
|
||||
out
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
docker/.env
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
@@ -45,4 +46,9 @@ fastlane/report.xml
|
||||
|
||||
# nix
|
||||
result*
|
||||
|
||||
.playwright-mcp/
|
||||
|
||||
.claude/worktrees
|
||||
.claude/settings.local.json
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pnpm exec lint-staged
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
pnpm -C apps/readest-app lint
|
||||
pnpm -C apps/readest-app test
|
||||
@@ -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" }
|
||||
|
||||
+32
-12
@@ -1,20 +1,40 @@
|
||||
FROM node:22-slim
|
||||
|
||||
ENV PNPM_HOME="/root/.local/share/pnpm"
|
||||
ENV PATH="${PATH}:${PNPM_HOME}"
|
||||
|
||||
RUN npm install --global pnpm
|
||||
|
||||
COPY . /app
|
||||
|
||||
FROM docker.io/node:22-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@10.29.3 --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/readest-app/package.json ./apps/readest-app/
|
||||
COPY patches/ ./patches/
|
||||
COPY packages/ ./packages/
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
FROM base AS dependencies
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter @readest/readest-app setup-vendors
|
||||
|
||||
FROM dependencies AS development-stage
|
||||
COPY . .
|
||||
WORKDIR /app/apps/readest-app
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["pnpm", "dev-web", "-H", "0.0.0.0"]
|
||||
|
||||
FROM base AS build
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG NEXT_PUBLIC_APP_PLATFORM
|
||||
ARG NEXT_PUBLIC_API_BASE_URL
|
||||
ARG NEXT_PUBLIC_OBJECT_STORAGE_TYPE
|
||||
ARG NEXT_PUBLIC_STORAGE_FIXED_QUOTA
|
||||
ARG NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA
|
||||
COPY --from=dependencies /app/node_modules /app/node_modules
|
||||
COPY --from=dependencies /app/apps/readest-app/node_modules /app/apps/readest-app/node_modules
|
||||
COPY --from=dependencies /app/apps/readest-app/public/vendor /app/apps/readest-app/public/vendor
|
||||
COPY --from=dependencies /app/packages/foliate-js/node_modules /app/packages/foliate-js/node_modules
|
||||
COPY . .
|
||||
WORKDIR /app/apps/readest-app
|
||||
RUN pnpm build-web
|
||||
|
||||
ENTRYPOINT ["pnpm", "start-web"]
|
||||
FROM build as production-stage
|
||||
ENTRYPOINT ["pnpm", "start-web", "-H", "0.0.0.0"]
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -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. | ✅ |
|
||||
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
|
||||
| **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. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **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. | ✅ |
|
||||
| **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. | ✅ |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
|
||||
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. | ✅ |
|
||||
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
|
||||
| **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. | 🛠 |
|
||||
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
|
||||
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
|
||||
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
|
||||
| **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
|
||||
```
|
||||
@@ -176,7 +176,9 @@ For Android:
|
||||
|
||||
```bash
|
||||
# Initialize the Android environment (run once)
|
||||
rm apps/readest-app/src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
git checkout apps/readest-app/src-tauri/gen/android
|
||||
|
||||
pnpm tauri android dev
|
||||
# or if you want to dev on a real device
|
||||
@@ -202,6 +204,9 @@ pnpm tauri android build
|
||||
pnpm tauri ios build
|
||||
```
|
||||
|
||||
Please refer to our release script if you experience any issues:
|
||||
https://github.com/readest/readest/blob/main/.github/workflows/release.yml
|
||||
|
||||
### 6. Setup dev environment with Nix
|
||||
|
||||
If you have Nix installed, you can leverage flake to enter a development shell
|
||||
@@ -250,6 +255,32 @@ Please check the [wiki][link-gh-wiki] of this project for more information on de
|
||||
|
||||
- See Issue [readest/readest#358](https://github.com/readest/readest/issues/358) for further details, or head over to our [Discord][link-discord] server and open a support discussion with detailed logs of your environment and the steps you’ve taken.
|
||||
|
||||
### 2. AppImage Launches but Only Shows a Taskbar Icon
|
||||
|
||||
On some Arch Linux systems—especially those using Wayland—the Readest AppImage may briefly show an icon in the taskbar and then exit without opening a window.
|
||||
|
||||
You might see logs such as:
|
||||
|
||||
```
|
||||
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
|
||||
```
|
||||
|
||||
This behavior is usually caused by compatibility issues between the bundled AppImage libraries and the system’s EGL / Wayland environment.
|
||||
|
||||
**Workaround 1: Launch with LD_PRELOAD (recommended)**
|
||||
|
||||
You can preload the system Wayland client library before launching the AppImage:
|
||||
|
||||
```
|
||||
LD_PRELOAD=/usr/lib/libwayland-client.so /path/to/Readest.AppImage
|
||||
```
|
||||
|
||||
This workaround has been confirmed to resolve the issue on affected systems.
|
||||
|
||||
**Workaround 2: Use the Flatpak Version**
|
||||
|
||||
If you prefer a more reliable out-of-the-box experience on Arch Linux, consider using the [Flatpak build on Flathub][link-flathub] instead. The Flatpak runtime helps avoid system library mismatches and tends to behave more consistently across different Wayland and X11 setups.
|
||||
|
||||
## Contributors
|
||||
|
||||
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please **review our [contributing guidelines](CONTRIBUTING.md) before you start**. We also welcome you to join our [Discord][link-discord] community for either support or contributing guidance.
|
||||
@@ -262,12 +293,12 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
|
||||
|
||||
## Support
|
||||
|
||||
If Readest has been useful to you, consider supporting its development. You can [become a sponsor on GitHub](https://github.com/sponsors/readest), [donate via Stripe](https://donate.stripe.com/4gMcN5aZdcE52kW3TFgjC01), or [contribute with crypto](https://donate.readest.com). Your contribution helps us squash bugs faster, improve performance, and keep building great features.
|
||||
If Readest has been useful to you, consider supporting its development. You can [become a sponsor on GitHub](https://github.com/sponsors/readest), [donate via Stripe](https://donate.stripe.com/4gMcN5aZdcE52kW3TFgjC01), or [donate with crypto](https://donate.readest.com). Your contribution helps us squash bugs faster, improve performance, and keep building great features.
|
||||
|
||||
### Sponsors
|
||||
|
||||
<p align="center">
|
||||
<a title="Browser testing via TestMu AI" href="https://www.testmu.ai?utm_source=readest&utm_medium=sponsor" target="_blank">
|
||||
<a title="Browser testing via TestMu AI" href="https://www.testmuai.com/?utm_medium=sponsor&utm_source=readest" target="_blank">
|
||||
<img src="https://raw.githubusercontent.com/readest/readest/refs/heads/main/data/sponsors/testmu-ai-logo.png" style="vertical-align: middle;" width="250" />
|
||||
</a>
|
||||
</p>
|
||||
@@ -291,7 +322,9 @@ The following libraries and frameworks are used in this software:
|
||||
|
||||
The following fonts are utilized in this software, either bundled within the application or provided through web fonts:
|
||||
|
||||
[Bitter](https://fonts.google.com/?query=Bitter), [Fira Code](https://fonts.google.com/?query=Fira+Code), [Literata](https://fonts.google.com/?query=Literata), [Merriweather](https://fonts.google.com/?query=Merriweather), [Noto Sans](https://fonts.google.com/?query=Noto+Sans), [Roboto](https://fonts.google.com/?query=Roboto), [LXGW WenKai](https://github.com/lxgw/LxgwWenKai), [MiSans](https://hyperos.mi.com/font/en/), [Source Han](https://github.com/adobe-fonts/source-han-sans/), [WenQuanYi Micro Hei](http://wenq.org/wqy2/)
|
||||
[Bitter](https://fonts.google.com/specimen/Bitter), [Fira Code](https://fonts.google.com/specimen/Fira+Code), [Inter](https://fonts.google.com/specimen/Inter), [Literata](https://fonts.google.com/specimen/Literata), [Merriweather](https://fonts.google.com/specimen/Merriweather), [Noto Sans](https://fonts.google.com/specimen/Noto+Sans), [Roboto](https://fonts.google.com/specimen/Roboto), [LXGW WenKai](https://github.com/lxgw/LxgwWenKai), [MiSans](https://hyperos.mi.com/font/en/), [Source Han](https://github.com/adobe-fonts/source-han-sans/), [WenQuanYi Micro Hei](http://wenq.org/wqy2/)
|
||||
|
||||
We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.netlify.app) for offering open-source tools that enable the use of Chinese fonts on the web.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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,59 +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',
|
||||
'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.99",
|
||||
"version": "0.9.101",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
"build": "dotenv -e .env.tauri -- next build",
|
||||
@@ -9,12 +10,24 @@
|
||||
"dev-web": "dotenv -e .env.web -- next dev",
|
||||
"build-web": "dotenv -e .env.web -- next build",
|
||||
"start-web": "dotenv -e .env.web -- next start",
|
||||
"dev-web:vinext": "dotenv -e .env.web -- vinext dev",
|
||||
"build-web:vinext": "dotenv -e .env.web -- vinext build",
|
||||
"start-web:vinext": "dotenv -e .env.web -- vinext start",
|
||||
"build-tauri": "dotenv -e .env.tauri -- next build",
|
||||
"i18n:extract": "i18next-scanner",
|
||||
"lint": "eslint .",
|
||||
"i18n:extract": "i18next-scanner --config i18next-scanner.config.cjs",
|
||||
"lint": "tsgo --noEmit && eslint --cache .",
|
||||
"test": "dotenv -e .env -e .env.test.local vitest",
|
||||
"test:browser": "vitest --config vitest.browser.config.mts --watch=false",
|
||||
"test:tauri": "bash scripts/test-tauri.sh",
|
||||
"test:pr:web": "pnpm test -- --watch=false && pnpm test:browser",
|
||||
"test:pr:tauri": "bash scripts/test-tauri.sh",
|
||||
"test:all": "pnpm test -- --watch=false && pnpm test:browser && bash scripts/test-tauri.sh",
|
||||
"test:e2e": "wdio run wdio.conf.ts",
|
||||
"tauri:dev:test": "dotenv -e .env.tauri -- tauri dev --features webdriver",
|
||||
"tauri:build:test": "dotenv -e .env.tauri -- tauri build --debug --features webdriver",
|
||||
"tauri": "tauri",
|
||||
"clippy": "cargo clippy -p Readest --no-deps -- -D warnings",
|
||||
"fmt:check": "cargo fmt -p Readest --check",
|
||||
"clippy:check": "cargo clippy -p Readest --no-deps -- -D warnings",
|
||||
"format": "pnpm -w format",
|
||||
"format:check": "pnpm -w format:check",
|
||||
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs ./public/vendor/simplecc",
|
||||
@@ -59,11 +72,14 @@
|
||||
"@assistant-ui/react": "0.11.56",
|
||||
"@assistant-ui/react-ai-sdk": "1.1.21",
|
||||
"@assistant-ui/react-markdown": "0.11.9",
|
||||
"@aws-sdk/client-s3": "^3.735.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.735.0",
|
||||
"@aws-sdk/client-s3": "^3.1000.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1000.0",
|
||||
"@choochmeque/tauri-plugin-sharekit-api": "^0.3.0",
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@fabianlars/tauri-plugin-oauth": "2",
|
||||
"@opennextjs/cloudflare": "^1.15.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.1",
|
||||
"@opennextjs/cloudflare": "^1.17.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -74,26 +90,30 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@serwist/next": "^9.4.2",
|
||||
"@serwist/next": "^9.5.6",
|
||||
"@serwist/webpack-plugin": "^9.5.6",
|
||||
"@stripe/react-stripe-js": "^3.7.0",
|
||||
"@stripe/stripe-js": "^7.4.0",
|
||||
"@supabase/auth-ui-react": "^0.4.7",
|
||||
"@supabase/auth-ui-shared": "^0.1.8",
|
||||
"@supabase/supabase-js": "^2.76.1",
|
||||
"@tauri-apps/api": "2.9.1",
|
||||
"@tauri-apps/api": "2.10.1",
|
||||
"@tauri-apps/plugin-cli": "^2.4.1",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.6",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-haptics": "^2.3.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.6",
|
||||
"@tauri-apps/plugin-http": "^2.5.7",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "~2.3.4",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"@tauri-apps/plugin-shell": "~2.3.5",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tauri-apps/plugin-websocket": "~2.4.2",
|
||||
"@tursodatabase/database-common": "^0.5.0",
|
||||
"@tursodatabase/database-wasm": "^0.5.0",
|
||||
"@tybys/wasm-util": "^0.10.1",
|
||||
"@zip.js/zip.js": "^2.8.16",
|
||||
"abortcontroller-polyfill": "^1.7.8",
|
||||
"ai": "^6.0.47",
|
||||
@@ -125,14 +145,15 @@
|
||||
"lunr": "^2.3.9",
|
||||
"marked": "^15.0.12",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "16.1.6",
|
||||
"next": "16.1.7",
|
||||
"next-view-transitions": "^0.3.5",
|
||||
"nunjucks": "^3.2.4",
|
||||
"overlayscrollbars": "^2.11.4",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"posthog-js": "^1.246.0",
|
||||
"react": "19.2.0",
|
||||
"react": "19.2.4",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "19.2.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-responsive": "^10.0.0",
|
||||
@@ -144,6 +165,7 @@
|
||||
"stripe": "^18.2.1",
|
||||
"styled-jsx": "^5.1.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tauri-plugin-device-info-api": "^1.0.1",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"uuid": "^11.1.0",
|
||||
"ws": "^8.18.3",
|
||||
@@ -153,12 +175,14 @@
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^15.4.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tauri-apps/cli": "2.9.6",
|
||||
"@tauri-apps/cli": "2.10.1",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@tursodatabase/database": "^0.5.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/cssbeautify": "^0.3.5",
|
||||
"@types/lunr": "^2.3.7",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "^22.15.31",
|
||||
"@types/nunjucks": "^3.2.6",
|
||||
"@types/react": "^19.0.0",
|
||||
@@ -171,28 +195,42 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260312.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitejs/plugin-rsc": "^0.5.21",
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"@vitest/browser-webdriverio": "^4.0.18",
|
||||
"@wdio/cli": "^9.25.0",
|
||||
"@wdio/globals": "^9.23.0",
|
||||
"@wdio/local-runner": "^9.24.0",
|
||||
"@wdio/mocha-framework": "^9.24.0",
|
||||
"@wdio/spec-reporter": "^9.24.0",
|
||||
"@wdio/types": "^9.24.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
"cpx2": "^8.0.0",
|
||||
"daisyui": "^4.12.24",
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-next": "16.0.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"i18next-scanner": "^4.6.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"mkdirp": "^3.0.1",
|
||||
"node-env-run": "^4.0.2",
|
||||
"playwright": "^1.58.2",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"postcss-nested": "^7.0.2",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react-server-dom-webpack": "^19.2.4",
|
||||
"serwist": "^9.3.0",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.7.2",
|
||||
"vinext": "^0.0.21",
|
||||
"vite": "^7.3.1",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.0.15",
|
||||
"wrangler": "^4.60.0"
|
||||
"vitest": "^4.0.18",
|
||||
"wrangler": "^4.73.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
"Wikipedia": "ويكيبيديا",
|
||||
"Writing Mode": "وضع الكتابة",
|
||||
"Your Library": "المكتبة الخاصة بك",
|
||||
"TTS not supported for PDF": "القراءة الصوتية غير مدعومة لملفات PDF",
|
||||
"Override Book Font": "تجاوز خط الكتاب",
|
||||
"Apply to All Books": "تطبيق على جميع الكتب",
|
||||
"Apply to This Book": "تطبيق على هذا الكتاب",
|
||||
@@ -346,12 +345,12 @@
|
||||
"Fit": "ملاءمة",
|
||||
"Reset {{settings}}": "إعادة تعيين {{settings}}",
|
||||
"Reset Settings": "إعادة تعيين الإعدادات",
|
||||
"{{count}} pages left in chapter_zero": "لم يتبق أي صفحات في هذا الفصل",
|
||||
"{{count}} pages left in chapter_one": "تبقّت صفحة واحدة في هذا الفصل",
|
||||
"{{count}} pages left in chapter_two": "تبقّت صفحتان في هذا الفصل",
|
||||
"{{count}} pages left in chapter_few": "تبقّت {{count}} صفحات في هذا الفصل",
|
||||
"{{count}} pages left in chapter_many": "تبقّت {{count}} صفحة في هذا الفصل",
|
||||
"{{count}} pages left in chapter_other": "تبقّى {{count}} صفحة في هذا الفصل",
|
||||
"{{count}} pages left in chapter_zero": "<1>لم يتبق أي صفحات في هذا الفصل</1>",
|
||||
"{{count}} pages left in chapter_one": "<1>تبقّت صفحة واحدة في هذا الفصل</1>",
|
||||
"{{count}} pages left in chapter_two": "<1>تبقّت صفحتان في هذا الفصل</1>",
|
||||
"{{count}} pages left in chapter_few": "<1>تبقّت </1><0>{{count}}</0><1> صفحات في هذا الفصل</1>",
|
||||
"{{count}} pages left in chapter_many": "<1>تبقّت </1><0>{{count}}</0><1> صفحة في هذا الفصل</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>تبقّى </1><0>{{count}}</0><1> صفحة في هذا الفصل</1>",
|
||||
"Show Remaining Pages": "عرض الصفحات المتبقية",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -615,11 +614,10 @@
|
||||
"Size": "حجم",
|
||||
"Cover": "غطاء",
|
||||
"Contain": "احتواء",
|
||||
"{{number}} pages left in chapter": "تبقّت {{number}} صفحات في هذا الفصل",
|
||||
"{{number}} pages left in chapter": "<1>تبقّت </1><0>{{number}}</0><1> صفحات في هذا الفصل</1>",
|
||||
"Device": "الجهاز",
|
||||
"E-Ink Mode": "وضع الحبر الإلكتروني",
|
||||
"Highlight Colors": "ألوان التمييز",
|
||||
"Auto Screen Brightness": "سطوع الشاشة التلقائي",
|
||||
"Pagination": "التقسيم إلى صفحات",
|
||||
"Disable Double Tap": "تعطيل النقر المزدوج",
|
||||
"Tap to Paginate": "اضغط للتقسيم إلى صفحات",
|
||||
@@ -873,6 +871,9 @@
|
||||
"Clear search": "مسح البحث",
|
||||
"Clear search history": "مسح سجل البحث",
|
||||
"Tap to Toggle Footer": "انقر لتبديل التذييل",
|
||||
"Show Current Time": "عرض الوقت الحالي",
|
||||
"Use 24 Hour Clock": "استخدام نظام 24 ساعة",
|
||||
"Show Current Battery Status": "عرض حالة البطارية الحالية",
|
||||
"Exported successfully": "تم التصدير بنجاح",
|
||||
"Book exported successfully.": "تم تصدير الكتاب بنجاح.",
|
||||
"Failed to export the book.": "فشل تصدير الكتاب.",
|
||||
@@ -1024,7 +1025,6 @@
|
||||
"Unable to start RSVP": "تعذر بدء RSVP",
|
||||
"RSVP not supported for PDF": "RSVP غير مدعوم لملفات PDF",
|
||||
"Select Chapter": "اختر الفصل",
|
||||
"{{number}} WPM": "{{number}} كلمة في الدقيقة",
|
||||
"Context": "السياق",
|
||||
"Ready": "جاهز",
|
||||
"Chapter Progress": "تقدم الفصل",
|
||||
@@ -1038,10 +1038,8 @@
|
||||
"Play (Space)": "تشغيل (المسافة)",
|
||||
"Skip forward 15 words": "تخطي للأمام 15 كلمة",
|
||||
"Forward 15 words (Shift+Right)": "للأمام 15 كلمة (Shift+اليمين)",
|
||||
"Pause:": "إيقاف مؤقت:",
|
||||
"Decrease speed": "تقليل السرعة",
|
||||
"Slower (Left/Down)": "أبطأ (اليسار/الأسفل)",
|
||||
"Current speed": "السرعة الحالية",
|
||||
"Increase speed": "زيادة السرعة",
|
||||
"Faster (Right/Up)": "أسرع (اليمين/الأعلى)",
|
||||
"Start RSVP Reading": "بدء قراءة RSVP",
|
||||
@@ -1079,5 +1077,82 @@
|
||||
"Authors": "المؤلفون",
|
||||
"Books": "الكتب",
|
||||
"Groups": "المجموعات",
|
||||
"Back to TTS Location": "العودة إلى موقع القراءة الآلية"
|
||||
"Back to TTS Location": "العودة إلى موقع القراءة الآلية",
|
||||
"Metadata": "بيانات وصفية",
|
||||
"Image viewer": "عارض الصور",
|
||||
"Previous Image": "الصورة السابقة",
|
||||
"Next Image": "الصورة التالية",
|
||||
"Zoomed": "مكبّر",
|
||||
"Zoom level": "مستوى التكبير",
|
||||
"Table viewer": "عارض الجداول",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "تعذر الاتصال بـ Readwise. يرجى التحقق من اتصال الشبكة لديك.",
|
||||
"Invalid Readwise access token": "رمز وصول Readwise غير صالح",
|
||||
"Disconnected from Readwise": "تم قطع الاتصال بـ Readwise",
|
||||
"Never": "أبداً",
|
||||
"Readwise Settings": "إعدادات Readwise",
|
||||
"Connected to Readwise": "متصل بـ Readwise",
|
||||
"Last synced: {{time}}": "آخر مزامنة: {{time}}",
|
||||
"Sync Enabled": "تم تمكين المزامنة",
|
||||
"Disconnect": "قطع الاتصال",
|
||||
"Connect your Readwise account to sync highlights.": "قم بتوصيل حساب Readwise الخاص بك لمزامنة التمييزات.",
|
||||
"Get your access token at": "احصل على رمز الوصول الخاص بك من",
|
||||
"Access Token": "رمز الوصول",
|
||||
"Paste your Readwise access token": "الصق رمز وصول Readwise الخاص بك",
|
||||
"Config": "تكوين",
|
||||
"Readwise Sync": "مزامنة Readwise",
|
||||
"Push Highlights": "إرسال التمييزات",
|
||||
"Highlights synced to Readwise": "تمت مزامنة التمييزات مع Readwise",
|
||||
"Readwise sync failed: no internet connection": "فشلت مزامنة Readwise: لا يوجد اتصال بالإنترنت",
|
||||
"Readwise sync failed: {{error}}": "فشلت مزامنة Readwise: {{error}}",
|
||||
"System Screen Brightness": "سطوع شاشة النظام",
|
||||
"Page:": "صفحة:",
|
||||
"Page: {{number}}": "صفحة: {{number}}",
|
||||
"Annotation page number": "رقم صفحة التعליق",
|
||||
"Translating...": "جارٍ الترجمة...",
|
||||
"Show Battery Percentage": "عرض نسبة البطارية",
|
||||
"Hide Scrollbar": "إخفاء شريط التمرير",
|
||||
"Skip to last reading position": "انتقل إلى آخر موضع قراءة",
|
||||
"Show context": "إظهار السياق",
|
||||
"Hide context": "إخفاء السياق",
|
||||
"Decrease font size": "تصغير حجم الخط",
|
||||
"Increase font size": "تكبير حجم الخط",
|
||||
"Focus": "تركيز",
|
||||
"Theme color": "لون السمة",
|
||||
"Import failed": "فشل الاستيراد",
|
||||
"Available Formatters:": "المُنسّقات المتاحة:",
|
||||
"Format date": "تنسيق التاريخ",
|
||||
"Markdown block quote (> per line)": "اقتباس Markdown (> لكل سطر)",
|
||||
"Newlines to <br>": "أسطر جديدة إلى <br>",
|
||||
"Change case": "تغيير حالة الأحرف",
|
||||
"Trim whitespace": "إزالة المسافات",
|
||||
"Truncate to n characters": "اقتطاع إلى n حرف",
|
||||
"Replace text": "استبدال النص",
|
||||
"Fallback value": "القيمة الاحتياطية",
|
||||
"Get length": "الحصول على الطول",
|
||||
"First/last element": "العنصر الأول/الأخير",
|
||||
"Join array": "دمج المصفوفة",
|
||||
"Backup failed: {{error}}": "فشل النسخ الاحتياطي: {{error}}",
|
||||
"Select Backup": "اختر نسخة احتياطية",
|
||||
"Restore failed: {{error}}": "فشل الاستعادة: {{error}}",
|
||||
"Backup & Restore": "النسخ الاحتياطي والاستعادة",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "أنشئ نسخة احتياطية من مكتبتك أو استعد من نسخة احتياطية سابقة. ستدمج الاستعادة مع مكتبتك الحالية.",
|
||||
"Backup Library": "نسخ المكتبة احتياطيًا",
|
||||
"Restore Library": "استعادة المكتبة",
|
||||
"Creating backup...": "جارٍ إنشاء النسخة الاحتياطية...",
|
||||
"Restoring library...": "جارٍ استعادة المكتبة...",
|
||||
"{{current}} of {{total}} items": "{{current}} من {{total}} عنصر",
|
||||
"Backup completed successfully!": "تم النسخ الاحتياطي بنجاح!",
|
||||
"Restore completed successfully!": "تمت الاستعادة بنجاح!",
|
||||
"Your library has been saved to the selected location.": "تم حفظ مكتبتك في الموقع المحدد.",
|
||||
"{{added}} books added, {{updated}} books updated.": "تمت إضافة {{added}} كتب، وتحديث {{updated}} كتب.",
|
||||
"Operation failed": "فشلت العملية",
|
||||
"Loading library...": "جارٍ تحميل المكتبة...",
|
||||
"{{count}} books refreshed_zero": "لم يتم تحديث أي كتاب",
|
||||
"{{count}} books refreshed_one": "تم تحديث كتاب واحد",
|
||||
"{{count}} books refreshed_two": "تم تحديث كتابين",
|
||||
"{{count}} books refreshed_few": "تم تحديث {{count}} كتب",
|
||||
"{{count}} books refreshed_many": "تم تحديث {{count}} كتابًا",
|
||||
"{{count}} books refreshed_other": "تم تحديث {{count}} كتاب",
|
||||
"Failed to refresh metadata": "فشل تحديث البيانات الوصفية",
|
||||
"Refresh Metadata": "تحديث البيانات الوصفية"
|
||||
}
|
||||
|
||||
@@ -186,8 +186,8 @@
|
||||
"Add your notes here...": "এখানে আপনার নোট যোগ করুন...",
|
||||
"Search notes and excerpts...": "নোট ও উদ্ধৃতি খুঁজুন...",
|
||||
"{{time}} min left in chapter": "অধ্যায়ে {{time}} মিনিট বাকি",
|
||||
"{{count}} pages left in chapter_one": "অধ্যায়ে ১ পৃষ্ঠা বাকি",
|
||||
"{{count}} pages left in chapter_other": "অধ্যায়ে {{count}} পৃষ্ঠা বাকি",
|
||||
"{{count}} pages left in chapter_one": "<1>অধ্যায়ে ১ পৃষ্ঠা বাকি</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>অধ্যায়ে </1><0>{{count}}</0><1> পৃষ্ঠা বাকি</1>",
|
||||
"Theme Mode": "থিম মোড",
|
||||
"Invert Image In Dark Mode": "ডার্ক মোডে ছবি উল্টান",
|
||||
"Override Book Color": "বইয়ের রঙ পরিবর্তন",
|
||||
@@ -319,7 +319,6 @@
|
||||
"Table of Contents": "সূচিপত্র",
|
||||
"Sidebar": "সাইডবার",
|
||||
"Disable Translation": "অনুবাদ নিষ্ক্রিয়",
|
||||
"TTS not supported for PDF": "PDF এর জন্য TTS সমর্থিত নয়",
|
||||
"TTS not supported for this document": "এই ডকুমেন্টের জন্য TTS সমর্থিত নয়",
|
||||
"No Timeout": "কোন টাইমআউট নেই",
|
||||
"{{value}} minute": "{{value}} মিনিট",
|
||||
@@ -599,11 +598,10 @@
|
||||
"Size": "আকার",
|
||||
"Cover": "ঢাকা",
|
||||
"Contain": "ধারণ",
|
||||
"{{number}} pages left in chapter": "অধ্যায়ে {{number}} পৃষ্ঠা বাকি",
|
||||
"{{number}} pages left in chapter": "<1>অধ্যায়ে </1><0>{{number}}</0><1> পৃষ্ঠা বাকি</1>",
|
||||
"Device": "যন্ত্র",
|
||||
"E-Ink Mode": "ই-ইঙ্ক মোড",
|
||||
"Highlight Colors": "হাইলাইট রঙ",
|
||||
"Auto Screen Brightness": "স্বয়ংক্রিয় স্ক্রিন উজ্জ্বলতা",
|
||||
"Pagination": "পৃষ্ঠা বিন্যাস",
|
||||
"Disable Double Tap": "ডাবল ট্যাপ অক্ষম করুন",
|
||||
"Tap to Paginate": "পৃষ্ঠায় যেতে ট্যাপ করুন",
|
||||
@@ -833,6 +831,9 @@
|
||||
"Clear search": "অনুসন্ধান সাফ করুন",
|
||||
"Clear search history": "অনুসন্ধান ইতিহাস সাফ করুন",
|
||||
"Tap to Toggle Footer": "ফুটার টগল করতে আলতো চাপুন",
|
||||
"Show Current Time": "বর্তমান সময় দেখান",
|
||||
"Use 24 Hour Clock": "২৪ ঘণ্টার ঘড়ি ব্যবহার করুন",
|
||||
"Show Current Battery Status": "বর্তমান ব্যাটারি অবস্থা দেখান",
|
||||
"Exported successfully": "সফলভাবে রপ্তানি হয়েছে",
|
||||
"Book exported successfully.": "বইটি সফলভাবে রপ্তানি হয়েছে।",
|
||||
"Failed to export the book.": "বই রপ্তানি করতে ব্যর্থ।",
|
||||
@@ -976,7 +977,6 @@
|
||||
"Unable to start RSVP": "RSVP शुरू করতে অসমর্থ",
|
||||
"RSVP not supported for PDF": "PDF-এর জন্য RSVP সমর্থিত নয়",
|
||||
"Select Chapter": "অধ্যায় নির্বাচন করুন",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "প্রসঙ্গ",
|
||||
"Ready": "প্রস্তুত",
|
||||
"Chapter Progress": "অধ্যায় অগ্রগতি",
|
||||
@@ -990,10 +990,8 @@
|
||||
"Play (Space)": "চালান (Space)",
|
||||
"Skip forward 15 words": "১৫ শব্দ এগিয়ে যান",
|
||||
"Forward 15 words (Shift+Right)": "১৫ শব্দ এগিয়ে যান (Shift+Right)",
|
||||
"Pause:": "বিরতি:",
|
||||
"Decrease speed": "গতি কমান",
|
||||
"Slower (Left/Down)": "ধীর (Left/Down)",
|
||||
"Current speed": "বর্তমান গতি",
|
||||
"Increase speed": "গতি বাড়ান",
|
||||
"Faster (Right/Up)": "দ্রুত (Right/Up)",
|
||||
"Start RSVP Reading": "RSVP পড়া শুরু করুন",
|
||||
@@ -1031,5 +1029,78 @@
|
||||
"Authors": "লেখকগণ",
|
||||
"Books": "বই",
|
||||
"Groups": "দল",
|
||||
"Back to TTS Location": "টিটিএস অবস্থানে ফিরে যান"
|
||||
"Back to TTS Location": "টিটিএস অবস্থানে ফিরে যান",
|
||||
"Metadata": "মেটাডেটা",
|
||||
"Image viewer": "চিত্র প্রদর্শক",
|
||||
"Previous Image": "পূর্ববর্তী চিত্র",
|
||||
"Next Image": "পরবর্তী চিত্র",
|
||||
"Zoomed": "জুম করা হয়েছে",
|
||||
"Zoom level": "জুম স্তর",
|
||||
"Table viewer": "টেবিল প্রদর্শক",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Readwise এর সাথে সংযোগ করতে অক্ষম। আপনার নেটওয়ার্ক সংযোগটি পরীক্ষা করুন।",
|
||||
"Invalid Readwise access token": "অকার্যকর Readwise অ্যাক্সেস টোকেন",
|
||||
"Disconnected from Readwise": "Readwise থেকে সংযোগ বিচ্ছিন্ন হয়েছে",
|
||||
"Never": "কখনো না",
|
||||
"Readwise Settings": "Readwise সেটিংস",
|
||||
"Connected to Readwise": "Readwise এর সাথে সংযুক্ত",
|
||||
"Last synced: {{time}}": "শেষ সিঙ্ক করা হয়েছে: {{time}}",
|
||||
"Sync Enabled": "সিঙ্কিং সক্ষম",
|
||||
"Disconnect": "সংযোগ বিচ্ছিন্ন করুন",
|
||||
"Connect your Readwise account to sync highlights.": "হাইলাইটগুলি সিঙ্ক করার জন্য আপনার Readwise অ্যাকাউন্টটি সংযোগ করুন।",
|
||||
"Get your access token at": "আপনার অ্যাক্সেস টোকেনটি এখানে পাবেন",
|
||||
"Access Token": "অ্যাক্সেস টোকেন",
|
||||
"Paste your Readwise access token": "আপনার Readwise অ্যাক্সেস টোকেনটি পেস্ট করুন",
|
||||
"Config": "কনফিগ",
|
||||
"Readwise Sync": "Readwise সিঙ্ক",
|
||||
"Push Highlights": "হাইলাইট পাঠান",
|
||||
"Highlights synced to Readwise": "হাইলাইটগুলি Readwise এ সিঙ্ক করা হয়েছে",
|
||||
"Readwise sync failed: no internet connection": "Readwise সিঙ্ক ব্যর্থ হয়েছে: ইন্টারনেট সংযোগ নেই",
|
||||
"Readwise sync failed: {{error}}": "Readwise সিঙ্ক ব্যর্থ হয়েছে: {{error}}",
|
||||
"System Screen Brightness": "সিস্টেম স্ক্রিন উজ্জ্বলতা",
|
||||
"Page:": "পৃষ্ঠা:",
|
||||
"Page: {{number}}": "পৃষ্ঠা: {{number}}",
|
||||
"Annotation page number": "টীকা পৃষ্ঠা নম্বর",
|
||||
"Translating...": "অনুবাদ করা হচ্ছে...",
|
||||
"Show Battery Percentage": "ব্যাটারি শতাংশ দেখান",
|
||||
"Hide Scrollbar": "স্ক্রলবার লুকান",
|
||||
"Skip to last reading position": "শেষ পড়ার অবস্থানে যান",
|
||||
"Show context": "প্রসঙ্গ দেখান",
|
||||
"Hide context": "প্রসঙ্গ লুকান",
|
||||
"Decrease font size": "ফন্টের আকার কমান",
|
||||
"Increase font size": "ফন্টের আকার বাড়ান",
|
||||
"Focus": "ফোকাস",
|
||||
"Theme color": "থিম রঙ",
|
||||
"Import failed": "আমদানি ব্যর্থ",
|
||||
"Available Formatters:": "উপলব্ধ ফরম্যাটার:",
|
||||
"Format date": "তারিখ ফরম্যাট",
|
||||
"Markdown block quote (> per line)": "Markdown ব্লক কোট (প্রতি লাইনে >)",
|
||||
"Newlines to <br>": "নতুন লাইন থেকে <br>",
|
||||
"Change case": "অক্ষরের কেস পরিবর্তন",
|
||||
"Trim whitespace": "শূন্যস্থান ছাঁটা",
|
||||
"Truncate to n characters": "n অক্ষরে সংক্ষিপ্ত করুন",
|
||||
"Replace text": "টেক্সট প্রতিস্থাপন",
|
||||
"Fallback value": "ফলব্যাক মান",
|
||||
"Get length": "দৈর্ঘ্য নিন",
|
||||
"First/last element": "প্রথম/শেষ উপাদান",
|
||||
"Join array": "অ্যারে যুক্ত করুন",
|
||||
"Backup failed: {{error}}": "ব্যাকআপ ব্যর্থ: {{error}}",
|
||||
"Select Backup": "ব্যাকআপ নির্বাচন করুন",
|
||||
"Restore failed: {{error}}": "পুনরুদ্ধার ব্যর্থ: {{error}}",
|
||||
"Backup & Restore": "ব্যাকআপ ও পুনরুদ্ধার",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "আপনার লাইব্রেরির ব্যাকআপ তৈরি করুন বা পূর্ববর্তী ব্যাকআপ থেকে পুনরুদ্ধার করুন। পুনরুদ্ধার আপনার বর্তমান লাইব্রেরির সাথে মার্জ হবে।",
|
||||
"Backup Library": "লাইব্রেরি ব্যাকআপ",
|
||||
"Restore Library": "লাইব্রেরি পুনরুদ্ধার",
|
||||
"Creating backup...": "ব্যাকআপ তৈরি হচ্ছে...",
|
||||
"Restoring library...": "লাইব্রেরি পুনরুদ্ধার হচ্ছে...",
|
||||
"{{current}} of {{total}} items": "{{current}} / {{total}} আইটেম",
|
||||
"Backup completed successfully!": "ব্যাকআপ সফলভাবে সম্পন্ন!",
|
||||
"Restore completed successfully!": "পুনরুদ্ধার সফলভাবে সম্পন্ন!",
|
||||
"Your library has been saved to the selected location.": "আপনার লাইব্রেরি নির্বাচিত স্থানে সংরক্ষিত হয়েছে।",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}}টি বই যোগ হয়েছে, {{updated}}টি বই আপডেট হয়েছে।",
|
||||
"Operation failed": "অপারেশন ব্যর্থ",
|
||||
"Loading library...": "লাইব্রেরি লোড হচ্ছে...",
|
||||
"{{count}} books refreshed_one": "{{count}}টি বইয়ের মেটাডেটা রিফ্রেশ হয়েছে",
|
||||
"{{count}} books refreshed_other": "{{count}}টি বইয়ের মেটাডেটা রিফ্রেশ হয়েছে",
|
||||
"Failed to refresh metadata": "মেটাডেটা রিফ্রেশ ব্যর্থ",
|
||||
"Refresh Metadata": "মেটাডেটা রিফ্রেশ"
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
"Wikipedia": "ཝེ་ཁི་པི་ཌི་ཡ།",
|
||||
"Writing Mode": "པར་སྒྲིག་གི་རྣམ་པ།",
|
||||
"Your Library": "དཔེ་མཛོད།",
|
||||
"TTS not supported for PDF": "PDF ཡིག་ཆར་ TTS རྒྱབ་སྐྱོར་མི་བྱེད།",
|
||||
"Override Book Font": "དཔེ་དེབ་ཀྱི་ཡིག་གཟུགས་བརྗེ་བ།",
|
||||
"Apply to All Books": "དཔེ་དེབ་ཡོད་རྒུར་སྤྱོད་པ།",
|
||||
"Apply to This Book": "དཔེ་དེབ་འདིར་སྤྱོད་པ།",
|
||||
@@ -331,7 +330,7 @@
|
||||
"Fit": "མཐུན་སྒྲིག",
|
||||
"Reset {{settings}}": "{{settings}} བསྐྱར་སྒྲིག",
|
||||
"Reset Settings": "སྒྲིག་བཀོད་བསྐྱར་སྒྲིག",
|
||||
"{{count}} pages left in chapter_other": "ལེའུ་འདིར་ད་དུང་ {{count}} ཤོག་ལྷེ་ལྷག་ཡོད།",
|
||||
"{{count}} pages left in chapter_other": "<1>ལེའུ་འདིར་ད་དུང་ </1><0>{{count}}</0><1> ཤོག་ལྷེ་ལྷག་ཡོད།</1>",
|
||||
"Show Remaining Pages": "ལྷག་མའི་ཤོག་ལྷེའི་གྲངས་ཀ་མངོན་པ།",
|
||||
"Source Han Serif CN": "སི་ཡོན་སུང་ཐི།",
|
||||
"Huiwen-MinchoGBK": "ཧུའེ་ཝུན་མིང་ཁྲའོ་ཐི།",
|
||||
@@ -595,11 +594,10 @@
|
||||
"Size": "ཨང་",
|
||||
"Cover": "དེབ་གྱི་སྤྱོད་བྱས་མ་ཐུབ།",
|
||||
"Contain": "འབྱོར་བ།",
|
||||
"{{number}} pages left in chapter": "དོན་ཚན་ནང་ཤོག་ཨང་ {{number}} ལོག་གི་འདུག།",
|
||||
"{{number}} pages left in chapter": "<1>དོན་ཚན་ནང་ཤོག་ཨང་ </1><0>{{number}}</0><1> ལོག་གི་འདུག།</1>",
|
||||
"Device": "རྐྱབ་སྐོར",
|
||||
"E-Ink Mode": "ཡིག་སྒྱུར་སྤོ་བ",
|
||||
"Highlight Colors": "མདོག་ཚད་ཀྱི་བསྡུར་བ།",
|
||||
"Auto Screen Brightness": "རྒྱབ་སྐོར་གྱི་འོད་ཟེར་རང་འགུལ།",
|
||||
"Pagination": "ཤོག་གདོང་།",
|
||||
"Disable Double Tap": "བརྒྱབ་གདོང་གཉིས་མ་འགྱོད།",
|
||||
"Tap to Paginate": "ཤོག་གདོང་ལ་ཐོག་འགྲོ།",
|
||||
@@ -823,6 +821,9 @@
|
||||
"Clear search": "འཚོལ་བཤེར་གཙང་སེལ།",
|
||||
"Clear search history": "འཚོལ་བཤེར་ལོ་རྒྱུས་གཙང་སེལ།",
|
||||
"Tap to Toggle Footer": "ཞབས་མཇུག་སྒོ་འབྱེད་བྱེད་པར་གནོན།",
|
||||
"Show Current Time": "ད་ལྟའི་དུས་ཚོད་སྟོན་པ།",
|
||||
"Use 24 Hour Clock": "ཆུ་ཚོད་ ༢༤ ཅན་གྱི་ཟློས་འཁོར་བཀོལ་བ།",
|
||||
"Show Current Battery Status": "ད་ལྟའི་གློག་རྫས་གནས་བབ་སྟོན་པ།",
|
||||
"Exported successfully": "ཕྱིར་འདོན་ལེགས་གྲུབ་བྱུང་།",
|
||||
"Book exported successfully.": "དཔེ་དེབ་ཕྱིར་འདོན་ལེགས་གྲུབ་བྱུང་།",
|
||||
"Failed to export the book.": "དཔེ་དེབ་ཕྱིར་འདོན་མི་ཐུབ།",
|
||||
@@ -964,7 +965,6 @@
|
||||
"Unable to start RSVP": "RSVP འགོ་འཛུགས་མ་ཐུབ།",
|
||||
"RSVP not supported for PDF": "PDF ལ་ RSVP རྒྱབ་སྐྱོར་མེད།",
|
||||
"Select Chapter": "ལེའུ་འདེམས་པ།",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "བརྗོད་དོན།",
|
||||
"Ready": "གྲ་སྒྲིག་ཡོད།",
|
||||
"Chapter Progress": "ལེའུའི་འཕེལ་རིམ།",
|
||||
@@ -978,10 +978,8 @@
|
||||
"Play (Space)": "གཏོང་བ། (Space)",
|
||||
"Skip forward 15 words": "ཚིག་ ༡༥ མདུན་ལ་བཤུད་པ།",
|
||||
"Forward 15 words (Shift+Right)": "ཚིག་ ༡༥ མདུན་ལ་བཤུད་པ། (Shift+Right)",
|
||||
"Pause:": "མཚམས་འཇོག་པ།",
|
||||
"Decrease speed": "མགྱོགས་ཚད་འགོར་དུ་གཏོང་བ།",
|
||||
"Slower (Left/Down)": "འགོར་བ། (Left/Down)",
|
||||
"Current speed": "ད་ལྟའི་མགྱོགས་ཚད།",
|
||||
"Increase speed": "མགྱོགས་ཚད་མྱུར་དུ་གཏོང་བ།",
|
||||
"Faster (Right/Up)": "མྱུར་བ། (Right/Up)",
|
||||
"Start RSVP Reading": "RSVP ཀློག་འགོ་འཛུགས་པ།",
|
||||
@@ -1019,5 +1017,77 @@
|
||||
"Authors": "རྩོམ་པ་པོ།",
|
||||
"Books": "དཔེ་ཆ།",
|
||||
"Groups": "ཚོགས་པ།",
|
||||
"Back to TTS Location": "TTS གནས་སར་ཕྱིར་ལོག་པ།"
|
||||
"Back to TTS Location": "TTS གནས་སར་ཕྱིར་ལོག་པ།",
|
||||
"Metadata": "གནད་སྨིན་གོ་དོན།",
|
||||
"Image viewer": "པར་རིས་ལྟ་བྱེད།",
|
||||
"Previous Image": "སྔོན་མའི་པར་རིས།",
|
||||
"Next Image": "རྗེས་མའི་པར་རིས།",
|
||||
"Zoomed": "ཆེར་བསྐྱེད་ཟིན།",
|
||||
"Zoom level": "ཆེར་བསྐྱེད་རིམ་པ།",
|
||||
"Table viewer": "རེའུ་མིག་ལྟ་བྱེད།",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Readwise ལ་མཐུད་ཐུབ་མ་སོང་། ཁྱེད་ཀྱི་དྲ་རྒྱའི་མཐུད་ལམ་ལ་བརྟག་དཔྱད་གནང་རོགས།",
|
||||
"Invalid Readwise access token": "Readwise འཛུལ་སྤྱོད་ལག་ཁྱེར་ནོར་འདུག",
|
||||
"Disconnected from Readwise": "Readwise ནས་མཐུད་ལམ་བཅད་ཟིན།",
|
||||
"Never": "ནམ་ཡང་མིན།",
|
||||
"Readwise Settings": "Readwise སྒྲིག་བཀོད།",
|
||||
"Connected to Readwise": "Readwise ལ་མཐུད་ཟིན།",
|
||||
"Last synced: {{time}}": "མཐའ་མའི་མཉམ་བྱུང་དུས་ཚོད། {{time}}",
|
||||
"Sync Enabled": "མཉམ་བྱུང་ནུས་པ་སྤར་ཟིན།",
|
||||
"Disconnect": "མཐུད་ལམ་གཅོད་པ།",
|
||||
"Connect your Readwise account to sync highlights.": "ཁྱེད་ཀྱི་ Readwise རྩིས་ཐོ་མཐུད་ནས་བཀོད་མཆན་མཉམ་བྱུང་གནང་རོགས།",
|
||||
"Get your access token at": "འཛུལ་སྤྱོད་ལག་ཁྱེར་འདི་ནས་ལེན་རོགས།",
|
||||
"Access Token": "འཛུལ་སྤྱོད་ལག་ཁྱེེར།",
|
||||
"Paste your Readwise access token": "Readwise འཛུལ་སྤྱོད་ལག་ཁྱེར་འདིར་སྦྱོར་རོགས།",
|
||||
"Config": "སྒྲིག་བཀོད།",
|
||||
"Readwise Sync": "Readwise མཉམ་བྱུང་།",
|
||||
"Push Highlights": "བཀོད་མཆན་སྐྱེལ་བ།",
|
||||
"Highlights synced to Readwise": "བཀོད་མཆན་ Readwise ལ་མཉམ་བྱུང་བྱས་ཟིན།",
|
||||
"Readwise sync failed: no internet connection": "Readwise མཉམ་བྱུང་མ་ཐུབ། དྲ་རྒྱའི་མཐུད་ལམ་མི་འདུག",
|
||||
"Readwise sync failed: {{error}}": "Readwise མཉམ་བྱུང་མ་ཐུབ། {{error}}",
|
||||
"System Screen Brightness": "མ་ལག་ཤེལ་སྒོའི་གསལ་ཚད།",
|
||||
"Page:": "ཤོག་ལྷེ།:",
|
||||
"Page: {{number}}": "ཤོག་ལྷེ།: {{number}}",
|
||||
"Annotation page number": "མཆན་འགྲེལ་ཤོག་ཨང་།",
|
||||
"Translating...": "ཡིག་བསྒྱུར་བྱེད་བཞིན་པ།...",
|
||||
"Show Battery Percentage": "གློག་རྫས་བརྒྱ་ཆ་སྟོན།",
|
||||
"Hide Scrollbar": "འགྲིལ་ཤིང་སྦེད་པ།",
|
||||
"Skip to last reading position": "མཐའ་མའི་ཀློག་གནས་སུ་མཆོང་",
|
||||
"Show context": "སྐབས་དོན་མངོན་པ",
|
||||
"Hide context": "སྐབས་དོན་སྦས་པ",
|
||||
"Decrease font size": "ཡིག་གཟུགས་ཆུང་དུ་གཏོང་བ",
|
||||
"Increase font size": "ཡིག་གཟུགས་ཆེ་རུ་གཏོང་བ",
|
||||
"Focus": "དམིགས་གཏད",
|
||||
"Theme color": "བརྗོད་དོན་ཚོན་མདོག",
|
||||
"Import failed": "ནང་འདྲེན་མི་ཐུབ།",
|
||||
"Available Formatters:": "སྒྲིག་བཟོ་ཆས་ཡོད་པ།:",
|
||||
"Format date": "ཚེས་གྲངས་སྒྲིག་བཟོ",
|
||||
"Markdown block quote (> per line)": "Markdown དྲིལ་བསྡུ་འདྲེན་ (མི་ལྟར་ > )",
|
||||
"Newlines to <br>": "གསར་བརྗེ་ <br> ལ་བསྒྱུར",
|
||||
"Change case": "ཡིག་གཟུགས་བསྒྱུར",
|
||||
"Trim whitespace": "སྟོང་ཆ་གཙང་བཟོ",
|
||||
"Truncate to n characters": "n ཡི་གེར་ཐུང་དུ་བཅད",
|
||||
"Replace text": "ཡི་གེ་བརྗེ་བ",
|
||||
"Fallback value": "ཚབ་ཀྱི་གྲངས་ཀ",
|
||||
"Get length": "རིང་ཚད་ལེན",
|
||||
"First/last element": "དང་པོ/མཇུག་གི་རྒྱུ་ཆ",
|
||||
"Join array": "ཚོ་སྒྲིག་སྦྲེལ",
|
||||
"Backup failed: {{error}}": "གྲབས་ཉར་བྱེད་མ་ཐུབ: {{error}}",
|
||||
"Select Backup": "གྲབས་ཉར་འདེམས་པ",
|
||||
"Restore failed: {{error}}": "སླར་གསོ་བྱེད་མ་ཐུབ: {{error}}",
|
||||
"Backup & Restore": "གྲབས་ཉར་དང་སླར་གསོ",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "ཁྱོད་ཀྱི་དཔེ་མཛོད་ཀྱི་གྲབས་ཉར་བཟོ་བའམ་སྔོན་གྱི་གྲབས་ཉར་ནས་སླར་གསོ་བྱོས། སླར་གསོ་བྱས་ན་ཁྱོད་ཀྱི་ད་ལྟའི་དཔེ་མཛོད་དང་སྤྲོད་རེས་བྱེད།",
|
||||
"Backup Library": "དཔེ་མཛོད་གྲབས་ཉར",
|
||||
"Restore Library": "དཔེ་མཛོད་སླར་གསོ",
|
||||
"Creating backup...": "གྲབས་ཉར་བཟོ་བཞིན་པ...",
|
||||
"Restoring library...": "དཔེ་མཛོད་སླར་གསོ་བྱེད་བཞིན་པ...",
|
||||
"{{current}} of {{total}} items": "{{current}} / {{total}} རྣམ་གྲངས",
|
||||
"Backup completed successfully!": "གྲབས་ཉར་ལེགས་གྲུབ་བྱུང་!",
|
||||
"Restore completed successfully!": "སླར་གསོ་ལེགས་གྲུབ་བྱུང་!",
|
||||
"Your library has been saved to the selected location.": "ཁྱོད་ཀྱི་དཔེ་མཛོད་འདེམས་ས་དེར་ཉར་ཟིན།",
|
||||
"{{added}} books added, {{updated}} books updated.": "དཔེ་དེབ {{added}} བསྣན་ཟིན, {{updated}} གསར་བསྒྱུར་བྱས་ཟིན།",
|
||||
"Operation failed": "བཀོལ་སྤྱོད་བྱེད་མ་ཐུབ",
|
||||
"Loading library...": "དཔེ་མཛོད་འཇུག་བཞིན་པ...",
|
||||
"{{count}} books refreshed_other": "تم تحديث {{count}} كتب",
|
||||
"Failed to refresh metadata": "མེ་ཊ་ཌེ་ཊ་གསར་བསྒྱུར་བྱེད་མ་ཐུབ",
|
||||
"Refresh Metadata": "མེ་ཊ་ཌེ་ཊ་གསར་བསྒྱུར"
|
||||
}
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
"Wikipedia": "Wikipedia",
|
||||
"Writing Mode": "Schreibmodus",
|
||||
"Your Library": "Ihre Bibliothek",
|
||||
"TTS not supported for PDF": "TTS wird für PDF nicht unterstützt",
|
||||
"Override Book Font": "Buch-Schriftart überschreiben",
|
||||
"Apply to All Books": "Auf alle Bücher anwenden",
|
||||
"Apply to This Book": "Auf dieses Buch anwenden",
|
||||
@@ -334,8 +333,8 @@
|
||||
"Fit": "Anpassen",
|
||||
"Reset {{settings}}": "{{settings}} zurücksetzen",
|
||||
"Reset Settings": "Einstellungen zurücksetzen",
|
||||
"{{count}} pages left in chapter_one": "{{count}} Seite verbleibend im Kapitel",
|
||||
"{{count}} pages left in chapter_other": "{{count}} Seiten verbleibend im Kapitel",
|
||||
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> Seite verbleibend im Kapitel</1>",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> Seiten verbleibend im Kapitel</1>",
|
||||
"Show Remaining Pages": "Verbleibende Seiten anzeigen",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -599,11 +598,10 @@
|
||||
"Size": "Größe",
|
||||
"Cover": "Cover",
|
||||
"Contain": "Inhalt",
|
||||
"{{number}} pages left in chapter": "{{number}} Seiten verbleibend im Kapitel",
|
||||
"{{number}} pages left in chapter": "<0>{{number}}</0><1> Seiten verbleibend im Kapitel</1>",
|
||||
"Device": "Gerät",
|
||||
"E-Ink Mode": "E-Ink-Modus",
|
||||
"Highlight Colors": "Hervorhebungsfarben",
|
||||
"Auto Screen Brightness": "Automatische Bildschirmhelligkeit",
|
||||
"Pagination": "Seitenumbruch",
|
||||
"Disable Double Tap": "Doppeltippen deaktivieren",
|
||||
"Tap to Paginate": "Tippen zum Blättern",
|
||||
@@ -833,6 +831,9 @@
|
||||
"Clear search": "Suche löschen",
|
||||
"Clear search history": "Suchverlauf löschen",
|
||||
"Tap to Toggle Footer": "Tippen, um Fußzeile umzuschalten",
|
||||
"Show Current Time": "Aktuelle Uhrzeit anzeigen",
|
||||
"Use 24 Hour Clock": "24-Stunden-Format verwenden",
|
||||
"Show Current Battery Status": "Aktuellen Akkustand anzeigen",
|
||||
"Exported successfully": "Erfolgreich exportiert",
|
||||
"Book exported successfully.": "Buch erfolgreich exportiert.",
|
||||
"Failed to export the book.": "Buch konnte nicht exportiert werden.",
|
||||
@@ -976,7 +977,6 @@
|
||||
"Unable to start RSVP": "RSVP kann nicht gestartet werden",
|
||||
"RSVP not supported for PDF": "RSVP wird für PDF nicht unterstützt",
|
||||
"Select Chapter": "Kapitel auswählen",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Kontext",
|
||||
"Ready": "Bereit",
|
||||
"Chapter Progress": "Kapitelfortschritt",
|
||||
@@ -990,10 +990,8 @@
|
||||
"Play (Space)": "Abspielen (Leertaste)",
|
||||
"Skip forward 15 words": "15 Wörter vor",
|
||||
"Forward 15 words (Shift+Right)": "15 Wörter vor (Shift+Rechts)",
|
||||
"Pause:": "Pause:",
|
||||
"Decrease speed": "Geschwindigkeit verringern",
|
||||
"Slower (Left/Down)": "Langsamer (Links/Unten)",
|
||||
"Current speed": "Aktuelle Geschwindigkeit",
|
||||
"Increase speed": "Geschwindigkeit erhöhen",
|
||||
"Faster (Right/Up)": "Schneller (Rechts/Oben)",
|
||||
"Start RSVP Reading": "RSVP-Lesen starten",
|
||||
@@ -1031,5 +1029,78 @@
|
||||
"Authors": "Autoren",
|
||||
"Books": "Bücher",
|
||||
"Groups": "Gruppen",
|
||||
"Back to TTS Location": "Zurück zur TTS-Position"
|
||||
"Back to TTS Location": "Zurück zur TTS-Position",
|
||||
"Metadata": "Metadaten",
|
||||
"Image viewer": "Bildbetrachter",
|
||||
"Previous Image": "Vorheriges Bild",
|
||||
"Next Image": "Nächstes Bild",
|
||||
"Zoomed": "Gezoomt",
|
||||
"Zoom level": "Zoomstufe",
|
||||
"Table viewer": "Tabellenbetrachter",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Verbindung zu Readwise nicht möglich. Bitte überprüfen Sie Ihre Netzwerkverbindung.",
|
||||
"Invalid Readwise access token": "Ungültiges Readwise-Zugriffstoken",
|
||||
"Disconnected from Readwise": "Von Readwise getrennt",
|
||||
"Never": "Nie",
|
||||
"Readwise Settings": "Readwise-Einstellungen",
|
||||
"Connected to Readwise": "Mit Readwise verbunden",
|
||||
"Last synced: {{time}}": "Zuletzt synchronisiert: {{time}}",
|
||||
"Sync Enabled": "Synchronisierung aktiviert",
|
||||
"Disconnect": "Trennen",
|
||||
"Connect your Readwise account to sync highlights.": "Verbinden Sie Ihr Readwise-Konto, um Highlights zu synchronisieren.",
|
||||
"Get your access token at": "Holen Sie sich Ihr Zugriffstoken unter",
|
||||
"Access Token": "Zugriffstoken",
|
||||
"Paste your Readwise access token": "Fügen Sie Ihr Readwise-Zugriffstoken ein",
|
||||
"Config": "Konfiguration",
|
||||
"Readwise Sync": "Readwise-Synchronisierung",
|
||||
"Push Highlights": "Highlights übertragen",
|
||||
"Highlights synced to Readwise": "Highlights mit Readwise synchronisiert",
|
||||
"Readwise sync failed: no internet connection": "Readwise-Synchronisierung fehlgeschlagen: Keine Internetverbindung",
|
||||
"Readwise sync failed: {{error}}": "Readwise-Synchronisierung fehlgeschlagen: {{error}}",
|
||||
"System Screen Brightness": "System-Bildschirmhelligkeit",
|
||||
"Page:": "Seite:",
|
||||
"Page: {{number}}": "Seite: {{number}}",
|
||||
"Annotation page number": "Seitenzahl der Anmerkung",
|
||||
"Translating...": "Übersetzen...",
|
||||
"Show Battery Percentage": "Akkustand in Prozent anzeigen",
|
||||
"Hide Scrollbar": "Scrollleiste ausblenden",
|
||||
"Skip to last reading position": "Zur letzten Leseposition springen",
|
||||
"Show context": "Kontext anzeigen",
|
||||
"Hide context": "Kontext ausblenden",
|
||||
"Decrease font size": "Schriftgröße verkleinern",
|
||||
"Increase font size": "Schriftgröße vergrößern",
|
||||
"Focus": "Fokus",
|
||||
"Theme color": "Themenfarbe",
|
||||
"Import failed": "Import fehlgeschlagen",
|
||||
"Available Formatters:": "Verfügbare Formatierer:",
|
||||
"Format date": "Datum formatieren",
|
||||
"Markdown block quote (> per line)": "Markdown-Blockzitat (> pro Zeile)",
|
||||
"Newlines to <br>": "Zeilenumbrüche zu <br>",
|
||||
"Change case": "Groß-/Kleinschreibung ändern",
|
||||
"Trim whitespace": "Leerzeichen entfernen",
|
||||
"Truncate to n characters": "Auf n Zeichen kürzen",
|
||||
"Replace text": "Text ersetzen",
|
||||
"Fallback value": "Ersatzwert",
|
||||
"Get length": "Länge ermitteln",
|
||||
"First/last element": "Erstes/letztes Element",
|
||||
"Join array": "Array verbinden",
|
||||
"Backup failed: {{error}}": "Sicherung fehlgeschlagen: {{error}}",
|
||||
"Select Backup": "Sicherung auswählen",
|
||||
"Restore failed: {{error}}": "Wiederherstellung fehlgeschlagen: {{error}}",
|
||||
"Backup & Restore": "Sichern & Wiederherstellen",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Erstellen Sie eine Sicherung Ihrer Bibliothek oder stellen Sie eine frühere Sicherung wieder her. Die Wiederherstellung wird mit Ihrer aktuellen Bibliothek zusammengeführt.",
|
||||
"Backup Library": "Bibliothek sichern",
|
||||
"Restore Library": "Bibliothek wiederherstellen",
|
||||
"Creating backup...": "Sicherung wird erstellt...",
|
||||
"Restoring library...": "Bibliothek wird wiederhergestellt...",
|
||||
"{{current}} of {{total}} items": "{{current}} von {{total}} Elementen",
|
||||
"Backup completed successfully!": "Sicherung erfolgreich abgeschlossen!",
|
||||
"Restore completed successfully!": "Wiederherstellung erfolgreich abgeschlossen!",
|
||||
"Your library has been saved to the selected location.": "Ihre Bibliothek wurde am ausgewählten Ort gespeichert.",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}} Bücher hinzugefügt, {{updated}} Bücher aktualisiert.",
|
||||
"Operation failed": "Vorgang fehlgeschlagen",
|
||||
"Loading library...": "Bibliothek wird geladen...",
|
||||
"{{count}} books refreshed_one": "{{count}} Buch aktualisiert",
|
||||
"{{count}} books refreshed_other": "{{count}} Bücher aktualisiert",
|
||||
"Failed to refresh metadata": "Metadaten-Aktualisierung fehlgeschlagen",
|
||||
"Refresh Metadata": "Metadaten aktualisieren"
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
"Wikipedia": "Βικιπαίδεια",
|
||||
"Writing Mode": "Λειτουργία γραφής",
|
||||
"Your Library": "Η βιβλιοθήκη σας",
|
||||
"TTS not supported for PDF": "Η μετατροπή κειμένου σε ομιλία δεν υποστηρίζεται για αρχεία PDF",
|
||||
"Override Book Font": "Παράκαμψη γραμματοσειράς βιβλίου",
|
||||
"Apply to All Books": "Εφαρμογή σε όλα τα βιβλία",
|
||||
"Apply to This Book": "Εφαρμογή σε αυτό το βιβλίο",
|
||||
@@ -335,8 +334,8 @@
|
||||
"Fit": "Προσαρμογή",
|
||||
"Reset {{settings}}": "Επαναφορά {{settings}}",
|
||||
"Reset Settings": "Επαναφορά ρυθμίσεων",
|
||||
"{{count}} pages left in chapter_one": "Μένει {{count}} σελίδα",
|
||||
"{{count}} pages left in chapter_other": "Μένουν {{count}} σελίδες",
|
||||
"{{count}} pages left in chapter_one": "<1>Μένει </1><0>{{count}}</0><1> σελίδα</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>Μένουν </1><0>{{count}}</0><1> σελίδες</1>",
|
||||
"Show Remaining Pages": "Εμφάνιση υπολοίπων",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -599,11 +598,10 @@
|
||||
"Size": "Μέγεθος",
|
||||
"Cover": "Εξώφυλλο",
|
||||
"Contain": "Περιέχει",
|
||||
"{{number}} pages left in chapter": "Μένουν {{number}} σελίδες στο κεφάλαιο",
|
||||
"{{number}} pages left in chapter": "<1>Μένουν </1><0>{{number}}</0><1> σελίδες στο κεφάλαιο</1>",
|
||||
"Device": "Συσκευή",
|
||||
"E-Ink Mode": "Λειτουργία E-Ink",
|
||||
"Highlight Colors": "Χρώματα επισήμανσης",
|
||||
"Auto Screen Brightness": "Αυτόματη φωτεινότητα οθόνης",
|
||||
"Pagination": "Σελιδοποίηση",
|
||||
"Disable Double Tap": "Απενεργοποίηση διπλού ταπ",
|
||||
"Tap to Paginate": "Ταπ για σελιδοποίηση",
|
||||
@@ -833,6 +831,9 @@
|
||||
"Clear search": "Εκκαθάριση αναζήτησης",
|
||||
"Clear search history": "Εκκαθάριση ιστορικού αναζήτησης",
|
||||
"Tap to Toggle Footer": "Πατήστε για εναλλαγή υποσέλιδου",
|
||||
"Show Current Time": "Εμφάνιση τρέχουσας ώρας",
|
||||
"Use 24 Hour Clock": "Χρήση 24ωρης μορφής ώρας",
|
||||
"Show Current Battery Status": "Εμφάνιση τρέχουσας κατάστασης μπαταρίας",
|
||||
"Exported successfully": "Εξαγωγή επιτυχής",
|
||||
"Book exported successfully.": "Το βιβλίο εξήχθη επιτυχώς.",
|
||||
"Failed to export the book.": "Αποτυχία εξαγωγής του βιβλίου.",
|
||||
@@ -976,7 +977,6 @@
|
||||
"Unable to start RSVP": "Αδυναμία έναρξης RSVP",
|
||||
"RSVP not supported for PDF": "Το RSVP δεν υποστηρίζεται για PDF",
|
||||
"Select Chapter": "Επιλογή Κεφαλαίου",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Πλαίσιο",
|
||||
"Ready": "Έτοιμο",
|
||||
"Chapter Progress": "Πρόοδος Κεφαλαίου",
|
||||
@@ -990,10 +990,8 @@
|
||||
"Play (Space)": "Αναπαραγωγή (Διάστημα)",
|
||||
"Skip forward 15 words": "Προώθηση 15 λέξεων",
|
||||
"Forward 15 words (Shift+Right)": "Εμπρός 15 λέξεις (Shift+Δεξιά)",
|
||||
"Pause:": "Παύση:",
|
||||
"Decrease speed": "Μείωση ταχύτητας",
|
||||
"Slower (Left/Down)": "Πιο αργά (Αριστερά/Κάτω)",
|
||||
"Current speed": "Τρέχουσα ταχύτητα",
|
||||
"Increase speed": "Αύξηση ταχύτητας",
|
||||
"Faster (Right/Up)": "Πιο γρήγορα (Δεξιά/Πάνω)",
|
||||
"Start RSVP Reading": "Έναρξη ανάγνωσης RSVP",
|
||||
@@ -1031,5 +1029,78 @@
|
||||
"Authors": "Συγγραφείς",
|
||||
"Books": "Βιβλία",
|
||||
"Groups": "Ομάδες",
|
||||
"Back to TTS Location": "Επιστροφή στην τοποθεσία TTS"
|
||||
"Back to TTS Location": "Επιστροφή στην τοποθεσία TTS",
|
||||
"Metadata": "Μεταδεδομένα",
|
||||
"Image viewer": "Πρόγραμμα προβολής εικόνων",
|
||||
"Previous Image": "Προηγούμενη εικόνα",
|
||||
"Next Image": "Επόμενη εικόνα",
|
||||
"Zoomed": "Ζουμαρισμένο",
|
||||
"Zoom level": "Επίπεδο ζουμ",
|
||||
"Table viewer": "Πρόγραμμα προβολής πινάκων",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Αδυναμία σύνδεσης στο Readwise. Ελέγξτε τη σύνδεση του δικτύου σας.",
|
||||
"Invalid Readwise access token": "Μη έγκυρο διακριτικό πρόσβασης Readwise",
|
||||
"Disconnected from Readwise": "Αποσυνδέθηκε από το Readwise",
|
||||
"Never": "Ποτέ",
|
||||
"Readwise Settings": "Ρυθμίσεις Readwise",
|
||||
"Connected to Readwise": "Συνδέθηκε στο Readwise",
|
||||
"Last synced: {{time}}": "Τελευταίος συγχρονισμός: {{time}}",
|
||||
"Sync Enabled": "Ο συγχρονισμός ενεργοποιήθηκε",
|
||||
"Disconnect": "Αποσύνδεση",
|
||||
"Connect your Readwise account to sync highlights.": "Συνδέστε τον λογαριασμό σας Readwise για να συγχρονίσετε τις επισημάνσεις.",
|
||||
"Get your access token at": "Αποκτήστε το διακριτικό πρόσβασής σας στο",
|
||||
"Access Token": "Διακριτικό πρόσβασης",
|
||||
"Paste your Readwise access token": "Επικολλήστε το διακριτικό πρόσβασης Readwise",
|
||||
"Config": "Ρύθμιση παραμέτρων",
|
||||
"Readwise Sync": "Συγχρονισμός Readwise",
|
||||
"Push Highlights": "Προώθηση επισημάνσεων",
|
||||
"Highlights synced to Readwise": "Οι επισημάνσεις συγχρονίστηκαν στο Readwise",
|
||||
"Readwise sync failed: no internet connection": "Ο συγχρονισμός Readwise απέτυχε: δεν υπάρχει σύνδεση στο διαδίκτυο",
|
||||
"Readwise sync failed: {{error}}": "Ο συγχρονισμός Readwise απέτυχε: {{error}}",
|
||||
"System Screen Brightness": "Φωτεινότητα οθόνης συστήματος",
|
||||
"Page:": "Σελίδα:",
|
||||
"Page: {{number}}": "Σελίδα: {{number}}",
|
||||
"Annotation page number": "Αριθμός σελίδας σχολίου",
|
||||
"Translating...": "Μετάφραση...",
|
||||
"Show Battery Percentage": "Εμφάνιση ποσοστού μπαταρίας",
|
||||
"Hide Scrollbar": "Απόκρυψη γραμμής κύλισης",
|
||||
"Skip to last reading position": "Μετάβαση στην τελευταία θέση ανάγνωσης",
|
||||
"Show context": "Εμφάνιση πλαισίου",
|
||||
"Hide context": "Απόκρυψη πλαισίου",
|
||||
"Decrease font size": "Μείωση μεγέθους γραμματοσειράς",
|
||||
"Increase font size": "Αύξηση μεγέθους γραμματοσειράς",
|
||||
"Focus": "Εστίαση",
|
||||
"Theme color": "Χρώμα θέματος",
|
||||
"Import failed": "Η εισαγωγή απέτυχε",
|
||||
"Available Formatters:": "Διαθέσιμοι μορφοποιητές:",
|
||||
"Format date": "Μορφοποίηση ημερομηνίας",
|
||||
"Markdown block quote (> per line)": "Παράθεση Markdown (> ανά γραμμή)",
|
||||
"Newlines to <br>": "Αλλαγές γραμμής σε <br>",
|
||||
"Change case": "Αλλαγή πεζών/κεφαλαίων",
|
||||
"Trim whitespace": "Αφαίρεση κενών",
|
||||
"Truncate to n characters": "Περικοπή σε n χαρακτήρες",
|
||||
"Replace text": "Αντικατάσταση κειμένου",
|
||||
"Fallback value": "Εναλλακτική τιμή",
|
||||
"Get length": "Λήψη μήκους",
|
||||
"First/last element": "Πρώτο/τελευταίο στοιχείο",
|
||||
"Join array": "Ένωση πίνακα",
|
||||
"Backup failed: {{error}}": "Η δημιουργία αντιγράφου ασφαλείας απέτυχε: {{error}}",
|
||||
"Select Backup": "Επιλογή αντιγράφου ασφαλείας",
|
||||
"Restore failed: {{error}}": "Η επαναφορά απέτυχε: {{error}}",
|
||||
"Backup & Restore": "Αντίγραφα ασφαλείας & Επαναφορά",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Δημιουργήστε αντίγραφο ασφαλείας της βιβλιοθήκης σας ή επαναφέρετε από προηγούμενο αντίγραφο. Η επαναφορά θα συγχωνευθεί με την τρέχουσα βιβλιοθήκη σας.",
|
||||
"Backup Library": "Αντίγραφο βιβλιοθήκης",
|
||||
"Restore Library": "Επαναφορά βιβλιοθήκης",
|
||||
"Creating backup...": "Δημιουργία αντιγράφου ασφαλείας...",
|
||||
"Restoring library...": "Επαναφορά βιβλιοθήκης...",
|
||||
"{{current}} of {{total}} items": "{{current}} από {{total}} στοιχεία",
|
||||
"Backup completed successfully!": "Το αντίγραφο ασφαλείας ολοκληρώθηκε!",
|
||||
"Restore completed successfully!": "Η επαναφορά ολοκληρώθηκε!",
|
||||
"Your library has been saved to the selected location.": "Η βιβλιοθήκη σας αποθηκεύτηκε στην επιλεγμένη τοποθεσία.",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}} βιβλία προστέθηκαν, {{updated}} βιβλία ενημερώθηκαν.",
|
||||
"Operation failed": "Η λειτουργία απέτυχε",
|
||||
"Loading library...": "Φόρτωση βιβλιοθήκης...",
|
||||
"{{count}} books refreshed_one": "{{count}} βιβλίο ανανεώθηκε",
|
||||
"{{count}} books refreshed_other": "{{count}} βιβλία ανανεώθηκαν",
|
||||
"Failed to refresh metadata": "Αποτυχία ανανέωσης μεταδεδομένων",
|
||||
"Refresh Metadata": "Ανανέωση μεταδεδομένων"
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Are you sure to delete {{count}} selected books?",
|
||||
"Search in {{count}} Book(s)..._one": "Search in {{count}} book...",
|
||||
"Search in {{count}} Book(s)..._other": "Search in {{count}} books...",
|
||||
"{{count}} pages left in chapter_one": "{{count}} page left in chapter",
|
||||
"{{count}} pages left in chapter_other": "{{count}} pages left in chapter",
|
||||
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> page left in chapter</1>",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> pages left in chapter</1>",
|
||||
"Deleted {{count}} file(s)_one": "Deleted {{count}} file",
|
||||
"Deleted {{count}} file(s)_other": "Deleted {{count}} files",
|
||||
"Failed to delete {{count}} file(s)_one": "Failed to delete {{count}} file",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"Wikipedia": "Wikipedia",
|
||||
"Writing Mode": "Modo de escritura",
|
||||
"Your Library": "Tu biblioteca",
|
||||
"TTS not supported for PDF": "TTS no es compatible con PDF",
|
||||
"Override Book Font": "Sobrescribir fuente del libro",
|
||||
"Apply to All Books": "Aplicar a todos los libros",
|
||||
"Apply to This Book": "Aplicar a este libro",
|
||||
@@ -365,9 +364,9 @@
|
||||
"Fit": "Encajar",
|
||||
"Reset {{settings}}": "Restablecer {{settings}}",
|
||||
"Reset Settings": "Restablecer configuración",
|
||||
"{{count}} pages left in chapter_one": "{{count}} página restante en el capítulo",
|
||||
"{{count}} pages left in chapter_many": "{{count}} páginas restantes en el capítulo",
|
||||
"{{count}} pages left in chapter_other": "{{count}} páginas restantes en el capítulo",
|
||||
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> página restante en el capítulo</1>",
|
||||
"{{count}} pages left in chapter_many": "<0>{{count}}</0><1> páginas restantes en el capítulo</1>",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> páginas restantes en el capítulo</1>",
|
||||
"Show Remaining Pages": "Mostrar resto",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -603,11 +602,10 @@
|
||||
"Size": "Tamaño",
|
||||
"Cover": "Cubierta",
|
||||
"Contain": "Contener",
|
||||
"{{number}} pages left in chapter": "{{number}} páginas restantes en el capítulo",
|
||||
"{{number}} pages left in chapter": "<0>{{number}}</0><1> páginas restantes en el capítulo</1>",
|
||||
"Device": "Dispositivo",
|
||||
"E-Ink Mode": "Modo E-Ink",
|
||||
"Highlight Colors": "Colores de resaltado",
|
||||
"Auto Screen Brightness": "Brillo automático de pantalla",
|
||||
"Pagination": "Paginación",
|
||||
"Disable Double Tap": "Deshabilitar Doble Toque",
|
||||
"Tap to Paginate": "Tocar para Paginación",
|
||||
@@ -843,6 +841,9 @@
|
||||
"Clear search": "Borrar búsqueda",
|
||||
"Clear search history": "Borrar historial de búsqueda",
|
||||
"Tap to Toggle Footer": "Toca para alternar pie de página",
|
||||
"Show Current Time": "Mostrar hora actual",
|
||||
"Use 24 Hour Clock": "Usar formato de 24 horas",
|
||||
"Show Current Battery Status": "Mostrar el estado actual de la batería",
|
||||
"Exported successfully": "Exportado con éxito",
|
||||
"Book exported successfully.": "Libro exportado con éxito.",
|
||||
"Failed to export the book.": "Error al exportar el libro.",
|
||||
@@ -988,7 +989,6 @@
|
||||
"Unable to start RSVP": "No se puede iniciar RSVP",
|
||||
"RSVP not supported for PDF": "RSVP no es compatible con PDF",
|
||||
"Select Chapter": "Seleccionar capítulo",
|
||||
"{{number}} WPM": "{{number}} PPM",
|
||||
"Context": "Contexto",
|
||||
"Ready": "Listo",
|
||||
"Chapter Progress": "Progreso del capítulo",
|
||||
@@ -1002,10 +1002,8 @@
|
||||
"Play (Space)": "Reproducir (Espacio)",
|
||||
"Skip forward 15 words": "Adelantar 15 palabras",
|
||||
"Forward 15 words (Shift+Right)": "Adelantar 15 palabras (Shift+Derecha)",
|
||||
"Pause:": "Pausa:",
|
||||
"Decrease speed": "Disminuir velocidad",
|
||||
"Slower (Left/Down)": "Más lento (Izquierda/Abajo)",
|
||||
"Current speed": "Velocidad actual",
|
||||
"Increase speed": "Aumentar velocidad",
|
||||
"Faster (Right/Up)": "Más rápido (Derecha/Arriba)",
|
||||
"Start RSVP Reading": "Iniciar lectura RSVP",
|
||||
@@ -1043,5 +1041,79 @@
|
||||
"Authors": "Autores",
|
||||
"Books": "Libros",
|
||||
"Groups": "Grupos",
|
||||
"Back to TTS Location": "Volver a la ubicación de TTS"
|
||||
"Back to TTS Location": "Volver a la ubicación de TTS",
|
||||
"Metadata": "Metadatos",
|
||||
"Image viewer": "Visor de imágenes",
|
||||
"Previous Image": "Imagen anterior",
|
||||
"Next Image": "Imagen siguiente",
|
||||
"Zoomed": "Con zoom",
|
||||
"Zoom level": "Nivel de zoom",
|
||||
"Table viewer": "Visor de tablas",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "No se pudo conectar a Readwise. Por favor, comprueba tu conexión a la red.",
|
||||
"Invalid Readwise access token": "Token de acceso a Readwise no válido",
|
||||
"Disconnected from Readwise": "Desconectado de Readwise",
|
||||
"Never": "Nunca",
|
||||
"Readwise Settings": "Ajustes de Readwise",
|
||||
"Connected to Readwise": "Conectado a Readwise",
|
||||
"Last synced: {{time}}": "Última sincronización: {{time}}",
|
||||
"Sync Enabled": "Sincronización habilitada",
|
||||
"Disconnect": "Desconectar",
|
||||
"Connect your Readwise account to sync highlights.": "Conecta tu cuenta de Readwise para sincronizar los resaltados.",
|
||||
"Get your access token at": "Obtén tu token de acceso en",
|
||||
"Access Token": "Token de acceso",
|
||||
"Paste your Readwise access token": "Pega tu token de acceso de Readwise",
|
||||
"Config": "Configuración",
|
||||
"Readwise Sync": "Sincronización con Readwise",
|
||||
"Push Highlights": "Enviar resaltados",
|
||||
"Highlights synced to Readwise": "Resaltados sincronizados con Readwise",
|
||||
"Readwise sync failed: no internet connection": "Error en la sincronización con Readwise: sin conexión a internet",
|
||||
"Readwise sync failed: {{error}}": "Error en la sincronización con Readwise: {{error}}",
|
||||
"System Screen Brightness": "Brillo de pantalla del sistema",
|
||||
"Page:": "Página:",
|
||||
"Page: {{number}}": "Página: {{number}}",
|
||||
"Annotation page number": "Número de página de la anotación",
|
||||
"Translating...": "Traduciendo...",
|
||||
"Show Battery Percentage": "Mostrar porcentaje de batería",
|
||||
"Hide Scrollbar": "Ocultar barra de desplazamiento",
|
||||
"Skip to last reading position": "Ir a la última posición de lectura",
|
||||
"Show context": "Mostrar contexto",
|
||||
"Hide context": "Ocultar contexto",
|
||||
"Decrease font size": "Reducir tamaño de fuente",
|
||||
"Increase font size": "Aumentar tamaño de fuente",
|
||||
"Focus": "Enfoque",
|
||||
"Theme color": "Color del tema",
|
||||
"Import failed": "Error de importación",
|
||||
"Available Formatters:": "Formateadores disponibles:",
|
||||
"Format date": "Formatear fecha",
|
||||
"Markdown block quote (> per line)": "Cita en bloque Markdown (> por línea)",
|
||||
"Newlines to <br>": "Saltos de línea a <br>",
|
||||
"Change case": "Cambiar mayúsculas/minúsculas",
|
||||
"Trim whitespace": "Recortar espacios",
|
||||
"Truncate to n characters": "Truncar a n caracteres",
|
||||
"Replace text": "Reemplazar texto",
|
||||
"Fallback value": "Valor alternativo",
|
||||
"Get length": "Obtener longitud",
|
||||
"First/last element": "Primer/último elemento",
|
||||
"Join array": "Unir array",
|
||||
"Backup failed: {{error}}": "Error en la copia de seguridad: {{error}}",
|
||||
"Select Backup": "Seleccionar copia de seguridad",
|
||||
"Restore failed: {{error}}": "Error en la restauración: {{error}}",
|
||||
"Backup & Restore": "Copia de seguridad y restauración",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Crea una copia de seguridad de tu biblioteca o restaura desde una copia anterior. La restauración se fusionará con tu biblioteca actual.",
|
||||
"Backup Library": "Hacer copia de seguridad",
|
||||
"Restore Library": "Restaurar biblioteca",
|
||||
"Creating backup...": "Creando copia de seguridad...",
|
||||
"Restoring library...": "Restaurando biblioteca...",
|
||||
"{{current}} of {{total}} items": "{{current}} de {{total}} elementos",
|
||||
"Backup completed successfully!": "¡Copia de seguridad completada!",
|
||||
"Restore completed successfully!": "¡Restauración completada!",
|
||||
"Your library has been saved to the selected location.": "Tu biblioteca se ha guardado en la ubicación seleccionada.",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}} libros añadidos, {{updated}} libros actualizados.",
|
||||
"Operation failed": "La operación falló",
|
||||
"Loading library...": "Cargando biblioteca...",
|
||||
"{{count}} books refreshed_one": "{{count}} libro actualizado",
|
||||
"{{count}} books refreshed_many": "{{count}} libros actualizados",
|
||||
"{{count}} books refreshed_other": "{{count}} libros actualizados",
|
||||
"Failed to refresh metadata": "Error al actualizar metadatos",
|
||||
"Refresh Metadata": "Actualizar metadatos"
|
||||
}
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
"Wikipedia": "ویکیپدیا",
|
||||
"Writing Mode": "حالت نوشتن",
|
||||
"Your Library": "کتابخانهی شما",
|
||||
"TTS not supported for PDF": "قابلیت تبدیل متن به گفتار برای PDF پشتیبانی نمیشود",
|
||||
"Override Book Font": "جایگزینی فونت کتاب",
|
||||
"Apply to All Books": "اِعمال برای همهی کتابها",
|
||||
"Apply to This Book": "اِعمال برای این کتاب",
|
||||
@@ -334,8 +333,8 @@
|
||||
"Fit": "متناسب",
|
||||
"Reset {{settings}}": "بازنشانی {{settings}}",
|
||||
"Reset Settings": "بازنشانی تنظیمات",
|
||||
"{{count}} pages left in chapter_one": "{{count}} صفحه تا انتهای فصل",
|
||||
"{{count}} pages left in chapter_other": "{{count}} صفحه تا انتهای فصل",
|
||||
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> صفحه تا انتهای فصل</1>",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> صفحه تا انتهای فصل</1>",
|
||||
"Show Remaining Pages": "نمایش صفحات باقیمانده",
|
||||
"Source Han Serif CN": "Source Han Serif CN",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -599,11 +598,10 @@
|
||||
"Size": "اندازه",
|
||||
"Cover": "جلد",
|
||||
"Contain": "شامل",
|
||||
"{{number}} pages left in chapter": "{{number}} صفحه تا انتهای فصل",
|
||||
"{{number}} pages left in chapter": "<0>{{number}}</0><1> صفحه تا انتهای فصل</1>",
|
||||
"Device": "دستگاه",
|
||||
"E-Ink Mode": "حالت E-Ink",
|
||||
"Highlight Colors": "رنگهای هایلایت",
|
||||
"Auto Screen Brightness": "روشنایی خودکار صفحه",
|
||||
"Pagination": "صفحهبندی",
|
||||
"Disable Double Tap": "غیرفعال کردن کلیک دوبل",
|
||||
"Tap to Paginate": "ضربه برای صفحهبندی",
|
||||
@@ -833,6 +831,9 @@
|
||||
"Clear search": "پاک کردن جستجو",
|
||||
"Clear search history": "پاک کردن تاریخچه جستجو",
|
||||
"Tap to Toggle Footer": "برای تغییر پاورقی ضربه بزنید",
|
||||
"Show Current Time": "نمایش زمان فعلی",
|
||||
"Use 24 Hour Clock": "استفاده از ساعت ۲۴ ساعته",
|
||||
"Show Current Battery Status": "نمایش وضعیت فعلی باتری",
|
||||
"Exported successfully": "صادرات موفق",
|
||||
"Book exported successfully.": "کتاب با موفقیت صادر شد.",
|
||||
"Failed to export the book.": "صادر کردن کتاب ناموفق بود.",
|
||||
@@ -976,7 +977,6 @@
|
||||
"Unable to start RSVP": "قادر به شروع RSVP نیست",
|
||||
"RSVP not supported for PDF": "RSVP برای PDF پشتیبانی نمیشود",
|
||||
"Select Chapter": "انتخاب فصل",
|
||||
"{{number}} WPM": "{{number}} کلمه در دقیقه",
|
||||
"Context": "زمینه",
|
||||
"Ready": "آماده",
|
||||
"Chapter Progress": "پیشرفت فصل",
|
||||
@@ -990,10 +990,8 @@
|
||||
"Play (Space)": "پخش (Space)",
|
||||
"Skip forward 15 words": "۱۵ کلمه به جلو",
|
||||
"Forward 15 words (Shift+Right)": "۱۵ کلمه به جلو (Shift+Right)",
|
||||
"Pause:": "توقف:",
|
||||
"Decrease speed": "کاهش سرعت",
|
||||
"Slower (Left/Down)": "کندتر (Left/Down)",
|
||||
"Current speed": "سرعت فعلی",
|
||||
"Increase speed": "افزایش سرعت",
|
||||
"Faster (Right/Up)": "سریعتر (Right/Up)",
|
||||
"Start RSVP Reading": "شروع مطالعه RSVP",
|
||||
@@ -1031,5 +1029,78 @@
|
||||
"Authors": "نویسندگان",
|
||||
"Books": "کتابها",
|
||||
"Groups": "گروهها",
|
||||
"Back to TTS Location": "بازگشت به مکان TTS"
|
||||
"Back to TTS Location": "بازگشت به مکان TTS",
|
||||
"Metadata": "فراداده",
|
||||
"Image viewer": "نمایشگر تصویر",
|
||||
"Previous Image": "تصویر قبلی",
|
||||
"Next Image": "تصویر بعدی",
|
||||
"Zoomed": "بزرگنمایی شده",
|
||||
"Zoom level": "سطح بزرگنمایی",
|
||||
"Table viewer": "نمایشگر جدول",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "اتصال به Readwise امکانپذیر نیست. لطفاً اتصال شبکه خود را بررسی کنید.",
|
||||
"Invalid Readwise access token": "توکن دسترسی Readwise نامعتبر است",
|
||||
"Disconnected from Readwise": "اتصال از Readwise قطع شد",
|
||||
"Never": "هرگز",
|
||||
"Readwise Settings": "تنظیمات Readwise",
|
||||
"Connected to Readwise": "به Readwise متصل شد",
|
||||
"Last synced: {{time}}": "آخرین همگامسازی: {{time}}",
|
||||
"Sync Enabled": "همگامسازی فعال شد",
|
||||
"Disconnect": "قطع اتصال",
|
||||
"Connect your Readwise account to sync highlights.": "برای همگامسازی هایلایتها، حساب Readwise خود را متصل کنید.",
|
||||
"Get your access token at": "توکن دسترسی خود را از اینجا دریافت کنید:",
|
||||
"Access Token": "توکن دسترسی",
|
||||
"Paste your Readwise access token": "توکن دسترسی Readwise خود را جایگذاری کنید",
|
||||
"Config": "پیکربندی",
|
||||
"Readwise Sync": "همگامسازی Readwise",
|
||||
"Push Highlights": "ارسال هایلایتها",
|
||||
"Highlights synced to Readwise": "هایلایتها با Readwise همگامسازی شدند",
|
||||
"Readwise sync failed: no internet connection": "همگامسازی Readwise ناموفق بود: اتصال اینترنت برقرار نیست",
|
||||
"Readwise sync failed: {{error}}": "همگامسازی Readwise ناموفق بود: {{error}}",
|
||||
"System Screen Brightness": "روشنایی صفحه نمایش سیستم",
|
||||
"Page:": "صفحه:",
|
||||
"Page: {{number}}": "صفحه: {{number}}",
|
||||
"Annotation page number": "شماره صفحه یادداشت",
|
||||
"Translating...": "در حال ترجمه...",
|
||||
"Show Battery Percentage": "نمایش درصد باتری",
|
||||
"Hide Scrollbar": "مخفی کردن نوار پیمایش",
|
||||
"Skip to last reading position": "رفتن به آخرین موقعیت مطالعه",
|
||||
"Show context": "نمایش زمینه",
|
||||
"Hide context": "پنهان کردن زمینه",
|
||||
"Decrease font size": "کاهش اندازه قلم",
|
||||
"Increase font size": "افزایش اندازه قلم",
|
||||
"Focus": "تمرکز",
|
||||
"Theme color": "رنگ پوسته",
|
||||
"Import failed": "وارد کردن ناموفق بود",
|
||||
"Available Formatters:": "قالببندهای موجود:",
|
||||
"Format date": "قالببندی تاریخ",
|
||||
"Markdown block quote (> per line)": "نقلقول Markdown (> در هر خط)",
|
||||
"Newlines to <br>": "خطوط جدید به <br>",
|
||||
"Change case": "تغییر بزرگی/کوچکی حروف",
|
||||
"Trim whitespace": "حذف فاصلهها",
|
||||
"Truncate to n characters": "کوتاه کردن به n نویسه",
|
||||
"Replace text": "جایگزینی متن",
|
||||
"Fallback value": "مقدار پیشفرض",
|
||||
"Get length": "دریافت طول",
|
||||
"First/last element": "عنصر اول/آخر",
|
||||
"Join array": "پیوستن آرایه",
|
||||
"Backup failed: {{error}}": "پشتیبانگیری ناموفق: {{error}}",
|
||||
"Select Backup": "انتخاب پشتیبان",
|
||||
"Restore failed: {{error}}": "بازیابی ناموفق: {{error}}",
|
||||
"Backup & Restore": "پشتیبانگیری و بازیابی",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "از کتابخانه خود پشتیبان بگیرید یا از پشتیبان قبلی بازیابی کنید. بازیابی با کتابخانه فعلی شما ادغام خواهد شد.",
|
||||
"Backup Library": "پشتیبانگیری کتابخانه",
|
||||
"Restore Library": "بازیابی کتابخانه",
|
||||
"Creating backup...": "در حال ایجاد پشتیبان...",
|
||||
"Restoring library...": "در حال بازیابی کتابخانه...",
|
||||
"{{current}} of {{total}} items": "{{current}} از {{total}} مورد",
|
||||
"Backup completed successfully!": "پشتیبانگیری با موفقیت انجام شد!",
|
||||
"Restore completed successfully!": "بازیابی با موفقیت انجام شد!",
|
||||
"Your library has been saved to the selected location.": "کتابخانه شما در مکان انتخابشده ذخیره شد.",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}} کتاب اضافه شد، {{updated}} کتاب بهروزرسانی شد.",
|
||||
"Operation failed": "عملیات ناموفق",
|
||||
"Loading library...": "در حال بارگذاری کتابخانه...",
|
||||
"{{count}} books refreshed_one": "{{count}} کتاب بهروزرسانی شد",
|
||||
"{{count}} books refreshed_other": "{{count}} کتاب بهروزرسانی شد",
|
||||
"Failed to refresh metadata": "بهروزرسانی فراداده ناموفق",
|
||||
"Refresh Metadata": "بهروزرسانی فراداده"
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
"Wikipedia": "Wikipédia",
|
||||
"Writing Mode": "Mode écriture",
|
||||
"Your Library": "Votre bibliothèque",
|
||||
"TTS not supported for PDF": "La synthèse vocale n'est pas prise en charge pour les fichiers PDF",
|
||||
"Override Book Font": "Remplacer la police du livre",
|
||||
"Apply to All Books": "Appliquer à tous les livres",
|
||||
"Apply to This Book": "Appliquer à ce livre",
|
||||
@@ -337,9 +336,9 @@
|
||||
"Fit": "Adapter",
|
||||
"Reset {{settings}}": "Réinitialiser {{settings}}",
|
||||
"Reset Settings": "Réinitialiser les paramètres",
|
||||
"{{count}} pages left in chapter_one": "{{count}} page restant dans le chapitre",
|
||||
"{{count}} pages left in chapter_many": "{{count}} pages restantes dans le chapitre",
|
||||
"{{count}} pages left in chapter_other": "{{count}} pages restantes dans le chapitre",
|
||||
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> page restant dans le chapitre</1>",
|
||||
"{{count}} pages left in chapter_many": "<0>{{count}}</0><1> pages restantes dans le chapitre</1>",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> pages restantes dans le chapitre</1>",
|
||||
"Show Remaining Pages": "Voir restantes",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -603,11 +602,10 @@
|
||||
"Size": "Taille",
|
||||
"Cover": "Couverture",
|
||||
"Contain": "Contenir",
|
||||
"{{number}} pages left in chapter": "{{number}} pages restantes dans le chapitre",
|
||||
"{{number}} pages left in chapter": "<0>{{number}}</0><1> pages restantes dans le chapitre</1>",
|
||||
"Device": "Appareil",
|
||||
"E-Ink Mode": "Mode E-Ink",
|
||||
"Highlight Colors": "Couleurs de Surlignage",
|
||||
"Auto Screen Brightness": "Luminosité Automatique de l'Écran",
|
||||
"Pagination": "Pagination",
|
||||
"Disable Double Tap": "Désactiver le Double Tap",
|
||||
"Tap to Paginate": "Tapoter pour Paginater",
|
||||
@@ -843,6 +841,9 @@
|
||||
"Clear search": "Effacer la recherche",
|
||||
"Clear search history": "Effacer l'historique de recherche",
|
||||
"Tap to Toggle Footer": "Appuyez pour afficher/masquer le pied de page",
|
||||
"Show Current Time": "Afficher l'heure actuelle",
|
||||
"Use 24 Hour Clock": "Utiliser le format 24 heures",
|
||||
"Show Current Battery Status": "Afficher l'état actuel de la batterie",
|
||||
"Exported successfully": "Exporté avec succès",
|
||||
"Book exported successfully.": "Livre exporté avec succès.",
|
||||
"Failed to export the book.": "Échec de l'exportation du livre.",
|
||||
@@ -988,7 +989,6 @@
|
||||
"Unable to start RSVP": "Impossible de démarrer RSVP",
|
||||
"RSVP not supported for PDF": "RSVP non pris en charge pour PDF",
|
||||
"Select Chapter": "Sélectionner le chapitre",
|
||||
"{{number}} WPM": "{{number}} MPM",
|
||||
"Context": "Contexte",
|
||||
"Ready": "Prêt",
|
||||
"Chapter Progress": "Progression du chapitre",
|
||||
@@ -1002,10 +1002,8 @@
|
||||
"Play (Space)": "Lecture (Espace)",
|
||||
"Skip forward 15 words": "Avancer de 15 mots",
|
||||
"Forward 15 words (Shift+Right)": "Avancer de 15 mots (Shift+Right)",
|
||||
"Pause:": "Pause :",
|
||||
"Decrease speed": "Diminuer la vitesse",
|
||||
"Slower (Left/Down)": "Plus lent (Gauche/Bas)",
|
||||
"Current speed": "Vitesse actuelle",
|
||||
"Increase speed": "Augmenter la vitesse",
|
||||
"Faster (Right/Up)": "Plus rapide (Droite/Haut)",
|
||||
"Start RSVP Reading": "Démarrer la lecture RSVP",
|
||||
@@ -1043,5 +1041,79 @@
|
||||
"Authors": "Auteurs",
|
||||
"Books": "Livres",
|
||||
"Groups": "Groupes",
|
||||
"Back to TTS Location": "Retour à l'emplacement TTS"
|
||||
"Back to TTS Location": "Retour à l'emplacement TTS",
|
||||
"Metadata": "Métadonnées",
|
||||
"Image viewer": "Visionneuse d'images",
|
||||
"Previous Image": "Image précédente",
|
||||
"Next Image": "Image suivante",
|
||||
"Zoomed": "Zoomé",
|
||||
"Zoom level": "Niveau de zoom",
|
||||
"Table viewer": "Visionneuse de tableaux",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Impossible de se connecter à Readwise. Veuillez vérifier votre connexion réseau.",
|
||||
"Invalid Readwise access token": "Jeton d'accès Readwise invalide",
|
||||
"Disconnected from Readwise": "Déconnecté de Readwise",
|
||||
"Never": "Jamais",
|
||||
"Readwise Settings": "Paramètres Readwise",
|
||||
"Connected to Readwise": "Connecté à Readwise",
|
||||
"Last synced: {{time}}": "Dernière synchronisation : {{time}}",
|
||||
"Sync Enabled": "Synchronisation activée",
|
||||
"Disconnect": "Déconnecter",
|
||||
"Connect your Readwise account to sync highlights.": "Connectez votre compte Readwise pour synchroniser les surlignages.",
|
||||
"Get your access token at": "Obtenez votre jeton d'accès sur",
|
||||
"Access Token": "Jeton d'accès",
|
||||
"Paste your Readwise access token": "Collez votre jeton d'accès Readwise",
|
||||
"Config": "Configuration",
|
||||
"Readwise Sync": "Synchronisation Readwise",
|
||||
"Push Highlights": "Pousser les surlignages",
|
||||
"Highlights synced to Readwise": "Surlignages synchronisés avec Readwise",
|
||||
"Readwise sync failed: no internet connection": "Échec de la synchronisation Readwise : pas de connexion internet",
|
||||
"Readwise sync failed: {{error}}": "Échec de la synchronisation Readwise : {{error}}",
|
||||
"System Screen Brightness": "Luminosité de l'écran système",
|
||||
"Page:": "Page :",
|
||||
"Page: {{number}}": "Page : {{number}}",
|
||||
"Annotation page number": "Numéro de page de l'annotation",
|
||||
"Translating...": "Traduction en cours...",
|
||||
"Show Battery Percentage": "Afficher le pourcentage de batterie",
|
||||
"Hide Scrollbar": "Masquer la barre de défilement",
|
||||
"Skip to last reading position": "Aller à la dernière position de lecture",
|
||||
"Show context": "Afficher le contexte",
|
||||
"Hide context": "Masquer le contexte",
|
||||
"Decrease font size": "Réduire la taille de police",
|
||||
"Increase font size": "Augmenter la taille de police",
|
||||
"Focus": "Focus",
|
||||
"Theme color": "Couleur du thème",
|
||||
"Import failed": "Échec de l'importation",
|
||||
"Available Formatters:": "Formateurs disponibles :",
|
||||
"Format date": "Formater la date",
|
||||
"Markdown block quote (> per line)": "Citation Markdown (> par ligne)",
|
||||
"Newlines to <br>": "Retours à la ligne en <br>",
|
||||
"Change case": "Changer la casse",
|
||||
"Trim whitespace": "Supprimer les espaces",
|
||||
"Truncate to n characters": "Tronquer à n caractères",
|
||||
"Replace text": "Remplacer le texte",
|
||||
"Fallback value": "Valeur par défaut",
|
||||
"Get length": "Obtenir la longueur",
|
||||
"First/last element": "Premier/dernier élément",
|
||||
"Join array": "Joindre le tableau",
|
||||
"Backup failed: {{error}}": "Échec de la sauvegarde : {{error}}",
|
||||
"Select Backup": "Sélectionner une sauvegarde",
|
||||
"Restore failed: {{error}}": "Échec de la restauration : {{error}}",
|
||||
"Backup & Restore": "Sauvegarde et restauration",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Créez une sauvegarde de votre bibliothèque ou restaurez à partir d'une sauvegarde précédente. La restauration fusionnera avec votre bibliothèque actuelle.",
|
||||
"Backup Library": "Sauvegarder la bibliothèque",
|
||||
"Restore Library": "Restaurer la bibliothèque",
|
||||
"Creating backup...": "Création de la sauvegarde...",
|
||||
"Restoring library...": "Restauration de la bibliothèque...",
|
||||
"{{current}} of {{total}} items": "{{current}} sur {{total}} éléments",
|
||||
"Backup completed successfully!": "Sauvegarde terminée avec succès !",
|
||||
"Restore completed successfully!": "Restauration terminée avec succès !",
|
||||
"Your library has been saved to the selected location.": "Votre bibliothèque a été enregistrée à l'emplacement sélectionné.",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}} livres ajoutés, {{updated}} livres mis à jour.",
|
||||
"Operation failed": "L'opération a échoué",
|
||||
"Loading library...": "Chargement de la bibliothèque...",
|
||||
"{{count}} books refreshed_one": "{{count}} livre rafraîchi",
|
||||
"{{count}} books refreshed_many": "{{count}} livres rafraîchis",
|
||||
"{{count}} books refreshed_other": "{{count}} livres rafraîchis",
|
||||
"Failed to refresh metadata": "Échec du rafraîchissement des métadonnées",
|
||||
"Refresh Metadata": "Rafraîchir les métadonnées"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -106,7 +106,6 @@
|
||||
"Wikipedia": "विकिपीडिया",
|
||||
"Writing Mode": "लेखन मोड",
|
||||
"Your Library": "आपकी लाइब्रेरी",
|
||||
"TTS not supported for PDF": "PDF के लिए TTS समर्थित नहीं है",
|
||||
"Override Book Font": "पुस्तक फ़ॉन्ट ओवरराइड करें",
|
||||
"Apply to All Books": "सभी पुस्तकों पर लागू करें",
|
||||
"Apply to This Book": "इस पुस्तक पर लागू करें",
|
||||
@@ -335,8 +334,8 @@
|
||||
"Fit": "फिट",
|
||||
"Reset {{settings}}": "रीसेट {{settings}}",
|
||||
"Reset Settings": "सेटिंग्स रीसेट करें",
|
||||
"{{count}} pages left in chapter_one": "इस अध्याय में {{count}} पृष्ठ शेष हैं",
|
||||
"{{count}} pages left in chapter_other": "इस अध्याय में {{count}} पृष्ठ शेष हैं",
|
||||
"{{count}} pages left in chapter_one": "<1>इस अध्याय में </1><0>{{count}}</0><1> पृष्ठ शेष हैं</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>इस अध्याय में </1><0>{{count}}</0><1> पृष्ठ शेष हैं</1>",
|
||||
"Show Remaining Pages": "शेष पृष्ठ दिखाएँ",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -599,11 +598,10 @@
|
||||
"Size": "आकार",
|
||||
"Cover": "कवर",
|
||||
"Contain": "समाहित करें",
|
||||
"{{number}} pages left in chapter": "इस अध्याय में {{number}} पृष्ठ शेष हैं",
|
||||
"{{number}} pages left in chapter": "<1>इस अध्याय में </1><0>{{number}}</0><1> पृष्ठ शेष हैं</1>",
|
||||
"Device": "डिवाइस",
|
||||
"E-Ink Mode": "ई-इंक मोड",
|
||||
"Highlight Colors": "हाइलाइट रंग",
|
||||
"Auto Screen Brightness": "स्वचालित स्क्रीन ब्राइटनेस",
|
||||
"Pagination": "पृष्ठांकन",
|
||||
"Disable Double Tap": "डबल टैप अक्षम करें",
|
||||
"Tap to Paginate": "पृष्ठांकन के लिए टैप करें",
|
||||
@@ -833,6 +831,9 @@
|
||||
"Clear search": "खोज साफ़ करें",
|
||||
"Clear search history": "खोज इतिहास साफ़ करें",
|
||||
"Tap to Toggle Footer": "फुटर टॉगल करने के लिए टैप करें",
|
||||
"Show Current Time": "वर्तमान समय दिखाएं",
|
||||
"Use 24 Hour Clock": "24 घंटे की घड़ी का उपयोग करें",
|
||||
"Show Current Battery Status": "वर्तमान बैटरी स्थिति दिखाएं",
|
||||
"Exported successfully": "सफलतापूर्वक निर्यात किया गया",
|
||||
"Book exported successfully.": "पुस्तक सफलतापूर्वक निर्यात की गई।",
|
||||
"Failed to export the book.": "पुस्तक निर्यात करने में विफल।",
|
||||
@@ -976,7 +977,6 @@
|
||||
"Unable to start RSVP": "RSVP शुरू करने में असमर्थ",
|
||||
"RSVP not supported for PDF": "PDF के लिए RSVP समर्थित नहीं है",
|
||||
"Select Chapter": "अध्याय चुनें",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "संदर्भ",
|
||||
"Ready": "तैयार",
|
||||
"Chapter Progress": "अध्याय प्रगति",
|
||||
@@ -990,10 +990,8 @@
|
||||
"Play (Space)": "चलाएं (Space)",
|
||||
"Skip forward 15 words": "15 शब्द आगे",
|
||||
"Forward 15 words (Shift+Right)": "15 शब्द आगे (Shift+Right)",
|
||||
"Pause:": "विराम:",
|
||||
"Decrease speed": "गति कम करें",
|
||||
"Slower (Left/Down)": "धीमी (Left/Down)",
|
||||
"Current speed": "वर्तमान गति",
|
||||
"Increase speed": "गति बढ़ाएं",
|
||||
"Faster (Right/Up)": "तेज़ (Right/Up)",
|
||||
"Start RSVP Reading": "RSVP पठन शुरू करें",
|
||||
@@ -1031,5 +1029,78 @@
|
||||
"Authors": "लेखक",
|
||||
"Books": "पुस्तकें",
|
||||
"Groups": "समूह",
|
||||
"Back to TTS Location": "टीटीएस स्थान पर वापस जाएं"
|
||||
"Back to TTS Location": "टीटीएस स्थान पर वापस जाएं",
|
||||
"Metadata": "मेटाडाटा",
|
||||
"Image viewer": "छवि दर्शक",
|
||||
"Previous Image": "पिछली छवि",
|
||||
"Next Image": "अगली छवि",
|
||||
"Zoomed": "ज़ूम किया गया",
|
||||
"Zoom level": "ज़ूम स्तर",
|
||||
"Table viewer": "तालिका दर्शक",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Readwise से कनेक्ट करने में असमर्थ। कृपया अपना नेटवर्क कनेक्शन जांचें।",
|
||||
"Invalid Readwise access token": "अमान्य Readwise एक्सेस टोकन",
|
||||
"Disconnected from Readwise": "Readwise से डिस्कनेक्ट हो गया",
|
||||
"Never": "कभी नहीं",
|
||||
"Readwise Settings": "Readwise सेटिंग्स",
|
||||
"Connected to Readwise": "Readwise से जुड़ा हुआ",
|
||||
"Last synced: {{time}}": "पिछला सिंक: {{time}}",
|
||||
"Sync Enabled": "सिंक सक्षम",
|
||||
"Disconnect": "डिस्कनेक्ट करें",
|
||||
"Connect your Readwise account to sync highlights.": "हाइलाइट्स सिंक करने के लिए अपना Readwise खाता कनेक्ट करें।",
|
||||
"Get your access token at": "अपना एक्सेस टोकन यहाँ प्राप्त करें",
|
||||
"Access Token": "एक्सेस टोकन",
|
||||
"Paste your Readwise access token": "अपना Readwise एक्सेस टोकन पेस्ट करें",
|
||||
"Config": "कॉन्फिग",
|
||||
"Readwise Sync": "Readwise सिंक",
|
||||
"Push Highlights": "हाइलाइट्स भेजें",
|
||||
"Highlights synced to Readwise": "हाइलाइट्स Readwise में सिंक हो गए",
|
||||
"Readwise sync failed: no internet connection": "Readwise सिंक विफल रहा: कोई इंटरनेट कनेक्शन नहीं",
|
||||
"Readwise sync failed: {{error}}": "Readwise सिंक विफल रहा: {{error}}",
|
||||
"System Screen Brightness": "सिस्टम स्क्रीन चमक",
|
||||
"Page:": "पृष्ठ:",
|
||||
"Page: {{number}}": "पृष्ठ: {{number}}",
|
||||
"Annotation page number": "व्याख्या पृष्ठ संख्या",
|
||||
"Translating...": "अनुवाद हो रहा है...",
|
||||
"Show Battery Percentage": "बैटरी प्रतिशत दिखाएं",
|
||||
"Hide Scrollbar": "स्क्रॉलबार छुपाएँ",
|
||||
"Skip to last reading position": "अंतिम पढ़ने की स्थिति पर जाएँ",
|
||||
"Show context": "संदर्भ दिखाएँ",
|
||||
"Hide context": "संदर्भ छुपाएँ",
|
||||
"Decrease font size": "फ़ॉन्ट आकार घटाएँ",
|
||||
"Increase font size": "फ़ॉन्ट आकार बढ़ाएँ",
|
||||
"Focus": "फ़ोकस",
|
||||
"Theme color": "थीम रंग",
|
||||
"Import failed": "आयात विफल",
|
||||
"Available Formatters:": "उपलब्ध फ़ॉर्मेटर:",
|
||||
"Format date": "तारीख फ़ॉर्मेट करें",
|
||||
"Markdown block quote (> per line)": "Markdown ब्लॉक कोट (> प्रति पंक्ति)",
|
||||
"Newlines to <br>": "नई पंक्तियाँ <br> में",
|
||||
"Change case": "केस बदलें",
|
||||
"Trim whitespace": "रिक्त स्थान हटाएँ",
|
||||
"Truncate to n characters": "n अक्षरों तक छोटा करें",
|
||||
"Replace text": "टेक्स्ट बदलें",
|
||||
"Fallback value": "फ़ॉलबैक मान",
|
||||
"Get length": "लंबाई प्राप्त करें",
|
||||
"First/last element": "पहला/अंतिम तत्व",
|
||||
"Join array": "ऐरे जोड़ें",
|
||||
"Backup failed: {{error}}": "बैकअप विफल: {{error}}",
|
||||
"Select Backup": "बैकअप चुनें",
|
||||
"Restore failed: {{error}}": "पुनर्स्थापना विफल: {{error}}",
|
||||
"Backup & Restore": "बैकअप और पुनर्स्थापना",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "अपनी लाइब्रेरी का बैकअप बनाएं या पिछले बैकअप से पुनर्स्थापित करें। पुनर्स्थापना आपकी वर्तमान लाइब्रेरी के साथ मर्ज होगी।",
|
||||
"Backup Library": "लाइब्रेरी बैकअप",
|
||||
"Restore Library": "लाइब्रेरी पुनर्स्थापित करें",
|
||||
"Creating backup...": "बैकअप बनाया जा रहा है...",
|
||||
"Restoring library...": "लाइब्रेरी पुनर्स्थापित हो रही है...",
|
||||
"{{current}} of {{total}} items": "{{current}} / {{total}} आइटम",
|
||||
"Backup completed successfully!": "बैकअप सफलतापूर्वक पूर्ण!",
|
||||
"Restore completed successfully!": "पुनर्स्थापना सफलतापूर्वक पूर्ण!",
|
||||
"Your library has been saved to the selected location.": "आपकी लाइब्रेरी चयनित स्थान पर सहेजी गई है।",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}} पुस्तकें जोड़ी गईं, {{updated}} पुस्तकें अपडेट की गईं।",
|
||||
"Operation failed": "ऑपरेशन विफल",
|
||||
"Loading library...": "लाइब्रेरी लोड हो रही है...",
|
||||
"{{count}} books refreshed_one": "{{count}} पुस्तक रिफ्रेश हुई",
|
||||
"{{count}} books refreshed_other": "{{count}} पुस्तकें रिफ्रेश हुईं",
|
||||
"Failed to refresh metadata": "मेटाडेटा रिफ्रेश विफल",
|
||||
"Refresh Metadata": "मेटाडेटा रिफ्रेश करें"
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
"Wikipedia": "Wikipedia",
|
||||
"Writing Mode": "Mode Menulis",
|
||||
"Your Library": "Perpustakaan Anda",
|
||||
"TTS not supported for PDF": "TTS tidak didukung untuk PDF",
|
||||
"Override Book Font": "Ganti Font Buku",
|
||||
"Apply to All Books": "Terapkan ke semua buku",
|
||||
"Apply to This Book": "Terapkan ke buku ini",
|
||||
@@ -332,7 +331,7 @@
|
||||
"Fit": "Pas",
|
||||
"Reset {{settings}}": "Atur Ulang {{settings}}",
|
||||
"Reset Settings": "Atur Ulang Pengaturan",
|
||||
"{{count}} pages left in chapter_other": "{{count}} halaman tersisa di bab ini",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> halaman tersisa di bab ini</1>",
|
||||
"Show Remaining Pages": "Tampilkan halaman tersisa",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -595,11 +594,10 @@
|
||||
"Size": "Ukuran",
|
||||
"Cover": "Sampul",
|
||||
"Contain": "Mengandung",
|
||||
"{{number}} pages left in chapter": "{{number}} halaman tersisa di bab",
|
||||
"{{number}} pages left in chapter": "<0>{{number}}</0><1> halaman tersisa di bab</1>",
|
||||
"Device": "Perangkat",
|
||||
"E-Ink Mode": "Mode E-Ink",
|
||||
"Highlight Colors": "Warna Sorotan",
|
||||
"Auto Screen Brightness": "Auto Kecerahan Layar",
|
||||
"Pagination": "Paginasi",
|
||||
"Disable Double Tap": "Nonaktifkan Ketuk Ganda",
|
||||
"Tap to Paginate": "Ketuk untuk Paginasi",
|
||||
@@ -823,6 +821,9 @@
|
||||
"Clear search": "Hapus pencarian",
|
||||
"Clear search history": "Hapus riwayat pencarian",
|
||||
"Tap to Toggle Footer": "Ketuk untuk beralih footer",
|
||||
"Show Current Time": "Tampilkan Waktu Sekarang",
|
||||
"Use 24 Hour Clock": "Gunakan Format 24 Jam",
|
||||
"Show Current Battery Status": "Tampilkan Status Baterai Saat Ini",
|
||||
"Exported successfully": "Berhasil diekspor",
|
||||
"Book exported successfully.": "Buku berhasil diekspor.",
|
||||
"Failed to export the book.": "Gagal mengekspor buku.",
|
||||
@@ -964,7 +965,6 @@
|
||||
"Unable to start RSVP": "Tidak dapat memulai RSVP",
|
||||
"RSVP not supported for PDF": "RSVP tidak didukung untuk PDF",
|
||||
"Select Chapter": "Pilih Bab",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Konteks",
|
||||
"Ready": "Siap",
|
||||
"Chapter Progress": "Kemajuan Bab",
|
||||
@@ -978,10 +978,8 @@
|
||||
"Play (Space)": "Putar (Spasi)",
|
||||
"Skip forward 15 words": "Lompat maju 15 kata",
|
||||
"Forward 15 words (Shift+Right)": "Maju 15 kata (Shift+Kanan)",
|
||||
"Pause:": "Jeda:",
|
||||
"Decrease speed": "Kurangi kecepatan",
|
||||
"Slower (Left/Down)": "Lebih lambat (Kiri/Bawah)",
|
||||
"Current speed": "Kecepatan saat ini",
|
||||
"Increase speed": "Tambah kecepatan",
|
||||
"Faster (Right/Up)": "Lebih cepat (Kanan/Atas)",
|
||||
"Start RSVP Reading": "Mulai Membaca RSVP",
|
||||
@@ -1019,5 +1017,77 @@
|
||||
"Authors": "Penulis",
|
||||
"Books": "Buku",
|
||||
"Groups": "Grup",
|
||||
"Back to TTS Location": "Kembali ke Lokasi TTS"
|
||||
"Back to TTS Location": "Kembali ke Lokasi TTS",
|
||||
"Metadata": "Metadata",
|
||||
"Image viewer": "Penampil gambar",
|
||||
"Previous Image": "Gambar Sebelumnya",
|
||||
"Next Image": "Gambar Berikutnya",
|
||||
"Zoomed": "Diperbesar",
|
||||
"Zoom level": "Tingkat zoom",
|
||||
"Table viewer": "Penampil tabel",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Tidak dapat terhubung ke Readwise. Silakan periksa koneksi jaringan Anda.",
|
||||
"Invalid Readwise access token": "Token akses Readwise tidak valid",
|
||||
"Disconnected from Readwise": "Terputus dari Readwise",
|
||||
"Never": "Tidak pernah",
|
||||
"Readwise Settings": "Pengaturan Readwise",
|
||||
"Connected to Readwise": "Terhubung ke Readwise",
|
||||
"Last synced: {{time}}": "Terakhir disinkronkan: {{time}}",
|
||||
"Sync Enabled": "Sinkronisasi Diaktifkan",
|
||||
"Disconnect": "Putuskan sambungan",
|
||||
"Connect your Readwise account to sync highlights.": "Hubungkan akun Readwise Anda untuk menyinkronkan sorotan.",
|
||||
"Get your access token at": "Dapatkan token akses Anda di",
|
||||
"Access Token": "Token Akses",
|
||||
"Paste your Readwise access token": "Tempelkan token akses Readwise Anda",
|
||||
"Config": "Konfigurasi",
|
||||
"Readwise Sync": "Sinkronisasi Readwise",
|
||||
"Push Highlights": "Kirim Sorotan",
|
||||
"Highlights synced to Readwise": "Sorotan disinkronkan ke Readwise",
|
||||
"Readwise sync failed: no internet connection": "Sinkronisasi Readwise gagal: tidak ada koneksi internet",
|
||||
"Readwise sync failed: {{error}}": "Sinkronisasi Readwise gagal: {{error}}",
|
||||
"System Screen Brightness": "Kecerahan Layar Sistem",
|
||||
"Page:": "Halaman:",
|
||||
"Page: {{number}}": "Halaman: {{number}}",
|
||||
"Annotation page number": "Nomor halaman anotasi",
|
||||
"Translating...": "Menerjemahkan...",
|
||||
"Show Battery Percentage": "Tampilkan Persentase Baterai",
|
||||
"Hide Scrollbar": "Sembunyikan Bilah Gulir",
|
||||
"Skip to last reading position": "Lompat ke posisi baca terakhir",
|
||||
"Show context": "Tampilkan konteks",
|
||||
"Hide context": "Sembunyikan konteks",
|
||||
"Decrease font size": "Perkecil ukuran font",
|
||||
"Increase font size": "Perbesar ukuran font",
|
||||
"Focus": "Fokus",
|
||||
"Theme color": "Warna tema",
|
||||
"Import failed": "Impor gagal",
|
||||
"Available Formatters:": "Formatter Tersedia:",
|
||||
"Format date": "Format tanggal",
|
||||
"Markdown block quote (> per line)": "Kutipan blok Markdown (> per baris)",
|
||||
"Newlines to <br>": "Baris baru ke <br>",
|
||||
"Change case": "Ubah huruf besar/kecil",
|
||||
"Trim whitespace": "Pangkas spasi",
|
||||
"Truncate to n characters": "Potong ke n karakter",
|
||||
"Replace text": "Ganti teks",
|
||||
"Fallback value": "Nilai cadangan",
|
||||
"Get length": "Dapatkan panjang",
|
||||
"First/last element": "Elemen pertama/terakhir",
|
||||
"Join array": "Gabungkan array",
|
||||
"Backup failed: {{error}}": "Pencadangan gagal: {{error}}",
|
||||
"Select Backup": "Pilih Cadangan",
|
||||
"Restore failed: {{error}}": "Pemulihan gagal: {{error}}",
|
||||
"Backup & Restore": "Cadangkan & Pulihkan",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Buat cadangan perpustakaan Anda atau pulihkan dari cadangan sebelumnya. Pemulihan akan digabungkan dengan perpustakaan Anda saat ini.",
|
||||
"Backup Library": "Cadangkan Perpustakaan",
|
||||
"Restore Library": "Pulihkan Perpustakaan",
|
||||
"Creating backup...": "Membuat cadangan...",
|
||||
"Restoring library...": "Memulihkan perpustakaan...",
|
||||
"{{current}} of {{total}} items": "{{current}} dari {{total}} item",
|
||||
"Backup completed successfully!": "Pencadangan berhasil!",
|
||||
"Restore completed successfully!": "Pemulihan berhasil!",
|
||||
"Your library has been saved to the selected location.": "Perpustakaan Anda telah disimpan di lokasi yang dipilih.",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}} buku ditambahkan, {{updated}} buku diperbarui.",
|
||||
"Operation failed": "Operasi gagal",
|
||||
"Loading library...": "Memuat perpustakaan...",
|
||||
"{{count}} books refreshed_other": "{{count}} buku diperbarui",
|
||||
"Failed to refresh metadata": "Gagal memperbarui metadata",
|
||||
"Refresh Metadata": "Perbarui Metadata"
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
"Wikipedia": "Wikipedia",
|
||||
"Writing Mode": "Modalità scrittura",
|
||||
"Your Library": "La tua biblioteca",
|
||||
"TTS not supported for PDF": "TTS non supportato per PDF",
|
||||
"Override Book Font": "Sovrascrivi font libro",
|
||||
"Apply to All Books": "Applica a tutti i libri",
|
||||
"Apply to This Book": "Applica a questo libro",
|
||||
@@ -338,9 +337,9 @@
|
||||
"Fit": "Adatta",
|
||||
"Reset {{settings}}": "Reimposta {{settings}}",
|
||||
"Reset Settings": "Reimposta impostazioni",
|
||||
"{{count}} pages left in chapter_one": "{{count}} pagina rimasta nel capitolo",
|
||||
"{{count}} pages left in chapter_many": "{{count}} pagine rimaste nel capitolo",
|
||||
"{{count}} pages left in chapter_other": "{{count}} pagine rimaste nel capitolo",
|
||||
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> pagina rimasta nel capitolo</1>",
|
||||
"{{count}} pages left in chapter_many": "<0>{{count}}</0><1> pagine rimaste nel capitolo</1>",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> pagine rimaste nel capitolo</1>",
|
||||
"Show Remaining Pages": "Mostra pagine rimanenti",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -603,11 +602,10 @@
|
||||
"Size": "Dimensione",
|
||||
"Cover": "Copertura",
|
||||
"Contain": "Contenere",
|
||||
"{{number}} pages left in chapter": "{{number}} pagine rimaste nel capitolo",
|
||||
"{{number}} pages left in chapter": "<0>{{number}}</0><1> pagine rimaste nel capitolo</1>",
|
||||
"Device": "Dispositivo",
|
||||
"E-Ink Mode": "Modalità E-Ink",
|
||||
"Highlight Colors": "Colori Evidenziazione",
|
||||
"Auto Screen Brightness": "Luminosità schermo automatica",
|
||||
"Pagination": "Impaginazione",
|
||||
"Disable Double Tap": "Disabilita Doppio Tap",
|
||||
"Tap to Paginate": "Tocca per Impaginare",
|
||||
@@ -843,6 +841,9 @@
|
||||
"Clear search": "Cancella ricerca",
|
||||
"Clear search history": "Cancella cronologia ricerche",
|
||||
"Tap to Toggle Footer": "Tocca per mostrare/nascondere il piè di pagina",
|
||||
"Show Current Time": "Mostra ora attuale",
|
||||
"Use 24 Hour Clock": "Usa formato 24 ore",
|
||||
"Show Current Battery Status": "Mostra lo stato attuale della batteria",
|
||||
"Exported successfully": "Esportato con successo",
|
||||
"Book exported successfully.": "Libro esportato con successo.",
|
||||
"Failed to export the book.": "Impossibile esportare il libro.",
|
||||
@@ -988,7 +989,6 @@
|
||||
"Unable to start RSVP": "Impossibile avviare RSVP",
|
||||
"RSVP not supported for PDF": "RSVP non supportato per PDF",
|
||||
"Select Chapter": "Seleziona capitolo",
|
||||
"{{number}} WPM": "{{number}} PPM",
|
||||
"Context": "Contesto",
|
||||
"Ready": "Pronto",
|
||||
"Chapter Progress": "Progresso capitolo",
|
||||
@@ -1002,10 +1002,8 @@
|
||||
"Play (Space)": "Riproduci (Spazio)",
|
||||
"Skip forward 15 words": "Avanti di 15 parole",
|
||||
"Forward 15 words (Shift+Right)": "Avanti di 15 parole (Shift+Destra)",
|
||||
"Pause:": "Pausa:",
|
||||
"Decrease speed": "Diminuisci velocità",
|
||||
"Slower (Left/Down)": "Più lento (Sinistra/Giù)",
|
||||
"Current speed": "Velocità attuale",
|
||||
"Increase speed": "Aumenta velocità",
|
||||
"Faster (Right/Up)": "Più veloce (Destra/Su)",
|
||||
"Start RSVP Reading": "Avvia lettura RSVP",
|
||||
@@ -1043,5 +1041,79 @@
|
||||
"Authors": "Autori",
|
||||
"Books": "Libri",
|
||||
"Groups": "Gruppi",
|
||||
"Back to TTS Location": "Torna alla posizione TTS"
|
||||
"Back to TTS Location": "Torna alla posizione TTS",
|
||||
"Metadata": "Metadati",
|
||||
"Image viewer": "Visualizzatore immagini",
|
||||
"Previous Image": "Immagine precedente",
|
||||
"Next Image": "Immagine successiva",
|
||||
"Zoomed": "Zoomato",
|
||||
"Zoom level": "Livello di zoom",
|
||||
"Table viewer": "Visualizzatore tabelle",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Impossibile connettersi a Readwise. Controlla la tua connessione di rete.",
|
||||
"Invalid Readwise access token": "Token di accesso Readwise non valido",
|
||||
"Disconnected from Readwise": "Disconnesso da Readwise",
|
||||
"Never": "Mai",
|
||||
"Readwise Settings": "Impostazioni Readwise",
|
||||
"Connected to Readwise": "Connesso a Readwise",
|
||||
"Last synced: {{time}}": "Ultima sincronizzazione: {{time}}",
|
||||
"Sync Enabled": "Sincronizzazione abilitata",
|
||||
"Disconnect": "Disconnetti",
|
||||
"Connect your Readwise account to sync highlights.": "Connetti il tuo account Readwise per sincronizzare le evidenziazioni.",
|
||||
"Get your access token at": "Ottieni il tuo token di accesso su",
|
||||
"Access Token": "Token di accesso",
|
||||
"Paste your Readwise access token": "Incolla il tuo token di accesso Readwise",
|
||||
"Config": "Configurazione",
|
||||
"Readwise Sync": "Sincronizzazione Readwise",
|
||||
"Push Highlights": "Invia evidenziazioni",
|
||||
"Highlights synced to Readwise": "Evidenziazioni sincronizzate su Readwise",
|
||||
"Readwise sync failed: no internet connection": "Sincronizzazione Readwise fallita: nessuna connessione internet",
|
||||
"Readwise sync failed: {{error}}": "Sincronizzazione Readwise fallita: {{error}}",
|
||||
"System Screen Brightness": "Luminosità dello schermo del sistema",
|
||||
"Page:": "Pagina:",
|
||||
"Page: {{number}}": "Pagina: {{number}}",
|
||||
"Annotation page number": "Numero di pagina dell'annotazione",
|
||||
"Translating...": "Traduzione...",
|
||||
"Show Battery Percentage": "Mostra percentuale batteria",
|
||||
"Hide Scrollbar": "Nascondi barra di scorrimento",
|
||||
"Skip to last reading position": "Vai all'ultima posizione di lettura",
|
||||
"Show context": "Mostra contesto",
|
||||
"Hide context": "Nascondi contesto",
|
||||
"Decrease font size": "Riduci dimensione carattere",
|
||||
"Increase font size": "Aumenta dimensione carattere",
|
||||
"Focus": "Messa a fuoco",
|
||||
"Theme color": "Colore del tema",
|
||||
"Import failed": "Importazione fallita",
|
||||
"Available Formatters:": "Formattatori disponibili:",
|
||||
"Format date": "Formatta data",
|
||||
"Markdown block quote (> per line)": "Citazione Markdown (> per riga)",
|
||||
"Newlines to <br>": "A capo in <br>",
|
||||
"Change case": "Cambia maiuscole/minuscole",
|
||||
"Trim whitespace": "Rimuovi spazi",
|
||||
"Truncate to n characters": "Tronca a n caratteri",
|
||||
"Replace text": "Sostituisci testo",
|
||||
"Fallback value": "Valore predefinito",
|
||||
"Get length": "Ottieni lunghezza",
|
||||
"First/last element": "Primo/ultimo elemento",
|
||||
"Join array": "Unisci array",
|
||||
"Backup failed: {{error}}": "Backup fallito: {{error}}",
|
||||
"Select Backup": "Seleziona backup",
|
||||
"Restore failed: {{error}}": "Ripristino fallito: {{error}}",
|
||||
"Backup & Restore": "Backup e ripristino",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Crea un backup della tua libreria o ripristina da un backup precedente. Il ripristino verrà unito alla tua libreria attuale.",
|
||||
"Backup Library": "Backup libreria",
|
||||
"Restore Library": "Ripristina libreria",
|
||||
"Creating backup...": "Creazione backup...",
|
||||
"Restoring library...": "Ripristino libreria...",
|
||||
"{{current}} of {{total}} items": "{{current}} di {{total}} elementi",
|
||||
"Backup completed successfully!": "Backup completato con successo!",
|
||||
"Restore completed successfully!": "Ripristino completato con successo!",
|
||||
"Your library has been saved to the selected location.": "La tua libreria è stata salvata nella posizione selezionata.",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}} libri aggiunti, {{updated}} libri aggiornati.",
|
||||
"Operation failed": "Operazione fallita",
|
||||
"Loading library...": "Caricamento libreria...",
|
||||
"{{count}} books refreshed_one": "{{count}} libro aggiornato",
|
||||
"{{count}} books refreshed_many": "{{count}} libri aggiornati",
|
||||
"{{count}} books refreshed_other": "{{count}} libri aggiornati",
|
||||
"Failed to refresh metadata": "Aggiornamento metadati fallito",
|
||||
"Refresh Metadata": "Aggiorna metadati"
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
"Wikipedia": "ウィキペディア",
|
||||
"Writing Mode": "書き込みモード",
|
||||
"Your Library": "ライブラリー",
|
||||
"TTS not supported for PDF": "PDFではTTSがサポートされていません",
|
||||
"Override Book Font": "書籍フォントを上書き",
|
||||
"Apply to All Books": "すべての書籍に適用",
|
||||
"Apply to This Book": "この書籍に適用",
|
||||
@@ -332,7 +331,7 @@
|
||||
"Fit": "フィット",
|
||||
"Reset {{settings}}": "{{settings}}をリセット",
|
||||
"Reset Settings": "設定をリセット",
|
||||
"{{count}} pages left in chapter_other": "{{count}} ページ残り",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> ページ残り</1>",
|
||||
"Show Remaining Pages": "残りを表示",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -595,11 +594,10 @@
|
||||
"Size": "サイズ",
|
||||
"Cover": "カバー",
|
||||
"Contain": "含む",
|
||||
"{{number}} pages left in chapter": "章に{{number}}ページ残り",
|
||||
"{{number}} pages left in chapter": "<1>章に</1><0>{{number}}</0><1>ページ残り</1>",
|
||||
"Device": "デバイス",
|
||||
"E-Ink Mode": "E-Inkモード",
|
||||
"Highlight Colors": "ハイライトカラー",
|
||||
"Auto Screen Brightness": "自動画面の明るさ",
|
||||
"Pagination": "ページネーション",
|
||||
"Disable Double Tap": "ダブルタップを無効にする",
|
||||
"Tap to Paginate": "タップしてページを切り替える",
|
||||
@@ -823,6 +821,9 @@
|
||||
"Clear search": "検索をクリア",
|
||||
"Clear search history": "検索履歴をクリア",
|
||||
"Tap to Toggle Footer": "タップでフッターを切り替え",
|
||||
"Show Current Time": "現在時刻を表示",
|
||||
"Use 24 Hour Clock": "24時間表示を使用",
|
||||
"Show Current Battery Status": "バッテリー残量を表示",
|
||||
"Exported successfully": "エクスポート成功",
|
||||
"Book exported successfully.": "書籍のエクスポートに成功しました。",
|
||||
"Failed to export the book.": "書籍のエクスポートに失敗しました。",
|
||||
@@ -964,7 +965,6 @@
|
||||
"Unable to start RSVP": "RSVPを起動できません",
|
||||
"RSVP not supported for PDF": "PDFはRSVPに対応していません",
|
||||
"Select Chapter": "章を選択",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "コンテキスト",
|
||||
"Ready": "準備完了",
|
||||
"Chapter Progress": "章の進捗",
|
||||
@@ -978,10 +978,8 @@
|
||||
"Play (Space)": "再生 (Space)",
|
||||
"Skip forward 15 words": "15単語進む",
|
||||
"Forward 15 words (Shift+Right)": "15単語進む (Shift+右)",
|
||||
"Pause:": "一時停止:",
|
||||
"Decrease speed": "速度を下げる",
|
||||
"Slower (Left/Down)": "低速 (左/下)",
|
||||
"Current speed": "現在の速度",
|
||||
"Increase speed": "速度を上げる",
|
||||
"Faster (Right/Up)": "高速 (右/上)",
|
||||
"Start RSVP Reading": "RSVP読書を開始",
|
||||
@@ -1019,5 +1017,77 @@
|
||||
"Authors": "著者",
|
||||
"Books": "書籍",
|
||||
"Groups": "グループ",
|
||||
"Back to TTS Location": "TTSの位置に戻る"
|
||||
"Back to TTS Location": "TTSの位置に戻る",
|
||||
"Metadata": "メタデータ",
|
||||
"Image viewer": "画像ビューア",
|
||||
"Previous Image": "前の画像",
|
||||
"Next Image": "次の画像",
|
||||
"Zoomed": "ズーム済み",
|
||||
"Zoom level": "ズームレベル",
|
||||
"Table viewer": "テーブルビューア",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Readwise に接続できません。ネットワーク接続を確認してください。",
|
||||
"Invalid Readwise access token": "無効な Readwise アクセストークン",
|
||||
"Disconnected from Readwise": "Readwise から切断されました",
|
||||
"Never": "一度もなし",
|
||||
"Readwise Settings": "Readwise 設定",
|
||||
"Connected to Readwise": "Readwise に接続済み",
|
||||
"Last synced: {{time}}": "最終同期:{{time}}",
|
||||
"Sync Enabled": "同期有効",
|
||||
"Disconnect": "切断",
|
||||
"Connect your Readwise account to sync highlights.": "ハイライトを同期するには Readwise アカウントを接続してください。",
|
||||
"Get your access token at": "アクセストークンの取得先:",
|
||||
"Access Token": "アクセストークン",
|
||||
"Paste your Readwise access token": "Readwise アクセストークンを貼り付けてください",
|
||||
"Config": "設定",
|
||||
"Readwise Sync": "Readwise 同期",
|
||||
"Push Highlights": "ハイライトをプッシュ",
|
||||
"Highlights synced to Readwise": "ハイライトが Readwise に同期されました",
|
||||
"Readwise sync failed: no internet connection": "Readwise 同期失敗:インターネット接続がありません",
|
||||
"Readwise sync failed: {{error}}": "Readwise 同期失敗:{{error}}",
|
||||
"System Screen Brightness": "システムの画面の明るさ",
|
||||
"Page:": "ページ:",
|
||||
"Page: {{number}}": "ページ: {{number}}",
|
||||
"Annotation page number": "注釈のページ番号",
|
||||
"Translating...": "翻訳中...",
|
||||
"Show Battery Percentage": "バッテリー残量を表示",
|
||||
"Hide Scrollbar": "スクロールバーを非表示",
|
||||
"Skip to last reading position": "最後の読書位置へ移動",
|
||||
"Show context": "コンテキストを表示",
|
||||
"Hide context": "コンテキストを非表示",
|
||||
"Decrease font size": "文字サイズを縮小",
|
||||
"Increase font size": "文字サイズを拡大",
|
||||
"Focus": "フォーカス",
|
||||
"Theme color": "テーマカラー",
|
||||
"Import failed": "インポートに失敗しました",
|
||||
"Available Formatters:": "利用可能なフォーマッター:",
|
||||
"Format date": "日付のフォーマット",
|
||||
"Markdown block quote (> per line)": "Markdown引用ブロック (行ごとに >)",
|
||||
"Newlines to <br>": "改行を <br> に変換",
|
||||
"Change case": "大文字/小文字の変更",
|
||||
"Trim whitespace": "空白を除去",
|
||||
"Truncate to n characters": "n文字に切り詰め",
|
||||
"Replace text": "テキストを置換",
|
||||
"Fallback value": "フォールバック値",
|
||||
"Get length": "長さを取得",
|
||||
"First/last element": "最初/最後の要素",
|
||||
"Join array": "配列を結合",
|
||||
"Backup failed: {{error}}": "バックアップに失敗しました: {{error}}",
|
||||
"Select Backup": "バックアップを選択",
|
||||
"Restore failed: {{error}}": "復元に失敗しました: {{error}}",
|
||||
"Backup & Restore": "バックアップと復元",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "ライブラリのバックアップを作成するか、以前のバックアップから復元します。復元は現在のライブラリとマージされます。",
|
||||
"Backup Library": "ライブラリをバックアップ",
|
||||
"Restore Library": "ライブラリを復元",
|
||||
"Creating backup...": "バックアップを作成中...",
|
||||
"Restoring library...": "ライブラリを復元中...",
|
||||
"{{current}} of {{total}} items": "{{current}} / {{total}} 件",
|
||||
"Backup completed successfully!": "バックアップが完了しました!",
|
||||
"Restore completed successfully!": "復元が完了しました!",
|
||||
"Your library has been saved to the selected location.": "ライブラリが選択した場所に保存されました。",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}}冊追加、{{updated}}冊更新されました。",
|
||||
"Operation failed": "操作に失敗しました",
|
||||
"Loading library...": "ライブラリを読み込み中...",
|
||||
"{{count}} books refreshed_other": "{{count}}冊のメタデータを更新しました",
|
||||
"Failed to refresh metadata": "メタデータの更新に失敗しました",
|
||||
"Refresh Metadata": "メタデータを更新"
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
"Wikipedia": "위키피디아",
|
||||
"Writing Mode": "글쓰기 모드",
|
||||
"Your Library": "내 서재",
|
||||
"TTS not supported for PDF": "PDF에서 TTS가 지원되지 않음",
|
||||
"Override Book Font": "책 글꼴 덮어쓰기",
|
||||
"Apply to All Books": "모든 책에 적용",
|
||||
"Apply to This Book": "이 책에 적용",
|
||||
@@ -332,7 +331,7 @@
|
||||
"Fit": "맞춤",
|
||||
"Reset {{settings}}": "{{settings}} 재설정",
|
||||
"Reset Settings": "설정 재설정",
|
||||
"{{count}} pages left in chapter_other": "남은 페이지 {{count}}장",
|
||||
"{{count}} pages left in chapter_other": "<1>남은 페이지 </1><0>{{count}}</0><1>장</1>",
|
||||
"Show Remaining Pages": "남은 페이지 보기",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -595,11 +594,10 @@
|
||||
"Size": "크기",
|
||||
"Cover": "표지",
|
||||
"Contain": "포함",
|
||||
"{{number}} pages left in chapter": "남은 페이지 {{number}}장",
|
||||
"{{number}} pages left in chapter": "<1>남은 페이지 </1><0>{{number}}</0><1>장</1>",
|
||||
"Device": "장치",
|
||||
"E-Ink Mode": "E-Ink 모드",
|
||||
"Highlight Colors": "하이라이트 색상",
|
||||
"Auto Screen Brightness": "자동 화면 밝기",
|
||||
"Pagination": "페이지 매김",
|
||||
"Disable Double Tap": "더블 탭 비활성화",
|
||||
"Tap to Paginate": "탭하여 페이지 매김",
|
||||
@@ -823,6 +821,9 @@
|
||||
"Clear search": "검색 지우기",
|
||||
"Clear search history": "검색 기록 지우기",
|
||||
"Tap to Toggle Footer": "탭하여 바닥글 전환",
|
||||
"Show Current Time": "현재 시간 표시",
|
||||
"Use 24 Hour Clock": "24시간제 사용",
|
||||
"Show Current Battery Status": "현재 배터리 상태 표시",
|
||||
"Exported successfully": "내보내기 성공",
|
||||
"Book exported successfully.": "책을 성공적으로 내보냈습니다.",
|
||||
"Failed to export the book.": "책 내보내기에 실패했습니다.",
|
||||
@@ -964,7 +965,6 @@
|
||||
"Unable to start RSVP": "RSVP를 시작할 수 없습니다",
|
||||
"RSVP not supported for PDF": "PDF는 RSVP를 지원하지 않습니다",
|
||||
"Select Chapter": "챕터 선택",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "문맥",
|
||||
"Ready": "준비됨",
|
||||
"Chapter Progress": "챕터 진행 상황",
|
||||
@@ -978,10 +978,8 @@
|
||||
"Play (Space)": "재생 (스페이스바)",
|
||||
"Skip forward 15 words": "15단어 앞으로",
|
||||
"Forward 15 words (Shift+Right)": "15단어 앞으로 (Shift+오른쪽 화살표)",
|
||||
"Pause:": "일시정지:",
|
||||
"Decrease speed": "속도 줄이기",
|
||||
"Slower (Left/Down)": "느리게 (왼쪽/아래 화살표)",
|
||||
"Current speed": "현재 속도",
|
||||
"Increase speed": "속도 높이기",
|
||||
"Faster (Right/Up)": "빠르게 (오른쪽/위 화살표)",
|
||||
"Start RSVP Reading": "RSVP 독서 시작",
|
||||
@@ -1019,5 +1017,77 @@
|
||||
"Authors": "저자",
|
||||
"Books": "도서",
|
||||
"Groups": "그룹",
|
||||
"Back to TTS Location": "TTS 위치로 돌아가기"
|
||||
"Back to TTS Location": "TTS 위치로 돌아가기",
|
||||
"Metadata": "메타데이터",
|
||||
"Image viewer": "이미지 뷰어",
|
||||
"Previous Image": "이전 이미지",
|
||||
"Next Image": "다음 이미지",
|
||||
"Zoomed": "확대됨",
|
||||
"Zoom level": "확대 수준",
|
||||
"Table viewer": "테이블 뷰어",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Readwise에 연결할 수 없습니다. 네트워크 연결을 확인해 주세요.",
|
||||
"Invalid Readwise access token": "유효하지 않은 Readwise 액세스 토큰입니다",
|
||||
"Disconnected from Readwise": "Readwise와 연결이 끊어졌습니다",
|
||||
"Never": "안 함",
|
||||
"Readwise Settings": "Readwise 설정",
|
||||
"Connected to Readwise": "Readwise에 연결됨",
|
||||
"Last synced: {{time}}": "마지막 동기화: {{time}}",
|
||||
"Sync Enabled": "동기화 활성화됨",
|
||||
"Disconnect": "연결 해제",
|
||||
"Connect your Readwise account to sync highlights.": "하이라이트를 동기화하려면 Readwise 계정렬 연결하세요.",
|
||||
"Get your access token at": "액세스 토큰 받기:",
|
||||
"Access Token": "액세스 토큰",
|
||||
"Paste your Readwise access token": "Readwise 액세스 토큰을 붙여넣으세요",
|
||||
"Config": "구성",
|
||||
"Readwise Sync": "Readwise 동기화",
|
||||
"Push Highlights": "하이라이트 푸시",
|
||||
"Highlights synced to Readwise": "하이라이트가 Readwise에 동기화되었습니다",
|
||||
"Readwise sync failed: no internet connection": "Readwise 동기화 실패: 인터넷 연결 없음",
|
||||
"Readwise sync failed: {{error}}": "Readwise 동기화 실패: {{error}}",
|
||||
"System Screen Brightness": "시스템 화면 밝기",
|
||||
"Page:": "페이지:",
|
||||
"Page: {{number}}": "페이지: {{number}}",
|
||||
"Annotation page number": "주석 페이지 번호",
|
||||
"Translating...": "번역 중...",
|
||||
"Show Battery Percentage": "배터리 백분율 표시",
|
||||
"Hide Scrollbar": "스크롤바 숨기기",
|
||||
"Skip to last reading position": "마지막 읽기 위치로 이동",
|
||||
"Show context": "맥락 표시",
|
||||
"Hide context": "맥락 숨기기",
|
||||
"Decrease font size": "글꼴 크기 줄이기",
|
||||
"Increase font size": "글꼴 크기 늘리기",
|
||||
"Focus": "초점",
|
||||
"Theme color": "테마 색상",
|
||||
"Import failed": "가져오기 실패",
|
||||
"Available Formatters:": "사용 가능한 포맷터:",
|
||||
"Format date": "날짜 형식",
|
||||
"Markdown block quote (> per line)": "Markdown 인용 블록 (줄마다 >)",
|
||||
"Newlines to <br>": "줄바꿈을 <br>로 변환",
|
||||
"Change case": "대소문자 변경",
|
||||
"Trim whitespace": "공백 제거",
|
||||
"Truncate to n characters": "n자로 자르기",
|
||||
"Replace text": "텍스트 바꾸기",
|
||||
"Fallback value": "기본값",
|
||||
"Get length": "길이 가져오기",
|
||||
"First/last element": "첫 번째/마지막 요소",
|
||||
"Join array": "배열 합치기",
|
||||
"Backup failed: {{error}}": "백업 실패: {{error}}",
|
||||
"Select Backup": "백업 선택",
|
||||
"Restore failed: {{error}}": "복원 실패: {{error}}",
|
||||
"Backup & Restore": "백업 및 복원",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "라이브러리를 백업하거나 이전 백업에서 복원하세요. 복원 시 현재 라이브러리와 병합됩니다.",
|
||||
"Backup Library": "라이브러리 백업",
|
||||
"Restore Library": "라이브러리 복원",
|
||||
"Creating backup...": "백업 생성 중...",
|
||||
"Restoring library...": "라이브러리 복원 중...",
|
||||
"{{current}} of {{total}} items": "{{current}} / {{total}} 항목",
|
||||
"Backup completed successfully!": "백업이 완료되었습니다!",
|
||||
"Restore completed successfully!": "복원이 완료되었습니다!",
|
||||
"Your library has been saved to the selected location.": "라이브러리가 선택한 위치에 저장되었습니다.",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}}권 추가, {{updated}}권 업데이트되었습니다.",
|
||||
"Operation failed": "작업 실패",
|
||||
"Loading library...": "라이브러리 로딩 중...",
|
||||
"{{count}} books refreshed_other": "{{count}}권 새로고침됨",
|
||||
"Failed to refresh metadata": "메타데이터 새로고침 실패",
|
||||
"Refresh Metadata": "메타데이터 새로고침"
|
||||
}
|
||||
|
||||
@@ -248,8 +248,8 @@
|
||||
"Add your notes here...": "Tambah nota anda di sini...",
|
||||
"Search notes and excerpts...": "Cari nota dan petikan...",
|
||||
"{{time}} min left in chapter": "{{time}} min lagi dalam bab",
|
||||
"{{number}} pages left in chapter": "{{number}} halaman lagi dalam bab",
|
||||
"{{count}} pages left in chapter_other": "{{count}} halaman lagi dalam bab",
|
||||
"{{number}} pages left in chapter": "<0>{{number}}</0><1> halaman lagi dalam bab</1>",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> halaman lagi dalam bab</1>",
|
||||
"On {{current}} of {{total}} page": "Di halaman {{current}} daripada {{total}}",
|
||||
"Unable to open book": "Tidak dapat membuka buku",
|
||||
"Section Title": "Tajuk Seksyen",
|
||||
@@ -293,7 +293,6 @@
|
||||
"Next Paragraph": "Perenggan Seterusnya",
|
||||
"Read Aloud": "Baca Dengan Kuat",
|
||||
"Ready to read aloud": "Bersedia untuk baca dengan kuat",
|
||||
"TTS not supported for PDF": "TTS tidak disokong untuk PDF",
|
||||
"TTS not supported for this document": "TTS tidak disokong untuk dokumen ini",
|
||||
"No Timeout": "Tiada Had Masa",
|
||||
"{{value}} minute": "{{value}} minit",
|
||||
@@ -517,7 +516,6 @@
|
||||
"Paging Animation": "Animasi Halaman",
|
||||
"Device": "Peranti",
|
||||
"E-Ink Mode": "Mod E-Ink",
|
||||
"Auto Screen Brightness": "Kecerahan Skrin Auto",
|
||||
"Security": "Keselamatan",
|
||||
"Allow JavaScript": "Benarkan JavaScript",
|
||||
"Enable only if you trust the file.": "Aktifkan hanya jika anda mempercayai fail.",
|
||||
@@ -823,6 +821,9 @@
|
||||
"Clear search": "Kosongkan carian",
|
||||
"Clear search history": "Kosongkan sejarah carian",
|
||||
"Tap to Toggle Footer": "Ketik untuk togol pengaki",
|
||||
"Show Current Time": "Tunjukkan Waktu Sekarang",
|
||||
"Use 24 Hour Clock": "Gunakan Format 24 Jam",
|
||||
"Show Current Battery Status": "Paparkan Status Bateri Semasa",
|
||||
"Exported successfully": "Berjaya dieksport",
|
||||
"Book exported successfully.": "Buku berjaya dieksport.",
|
||||
"Failed to export the book.": "Gagal mengeksport buku.",
|
||||
@@ -964,7 +965,6 @@
|
||||
"Unable to start RSVP": "Tidak dapat memulakan RSVP",
|
||||
"RSVP not supported for PDF": "RSVP tidak disokong untuk PDF",
|
||||
"Select Chapter": "Pilih Bab",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Konteks",
|
||||
"Ready": "Sedia",
|
||||
"Chapter Progress": "Kemajuan Bab",
|
||||
@@ -978,10 +978,8 @@
|
||||
"Play (Space)": "Main (Ruang)",
|
||||
"Skip forward 15 words": "Langkau depan 15 perkataan",
|
||||
"Forward 15 words (Shift+Right)": "Depan 15 perkataan (Shift+Kanan)",
|
||||
"Pause:": "Jeda:",
|
||||
"Decrease speed": "Kurangkan kelajuan",
|
||||
"Slower (Left/Down)": "Lebih perlahan (Kiri/Bawah)",
|
||||
"Current speed": "Kelajuan semasa",
|
||||
"Increase speed": "Tingkatkan kelajuan",
|
||||
"Faster (Right/Up)": "Lebih cepat (Kanan/Atas)",
|
||||
"Start RSVP Reading": "Mulakan Pembacaan RSVP",
|
||||
@@ -1019,5 +1017,77 @@
|
||||
"Authors": "Penulis",
|
||||
"Books": "Buku",
|
||||
"Groups": "Kumpulan",
|
||||
"Back to TTS Location": "Kembali ke Lokasi TTS"
|
||||
"Back to TTS Location": "Kembali ke Lokasi TTS",
|
||||
"Metadata": "Metadata",
|
||||
"Image viewer": "Penyorot imej",
|
||||
"Previous Image": "Imej Sebelumnya",
|
||||
"Next Image": "Imej Seterusnya",
|
||||
"Zoomed": "Dizum",
|
||||
"Zoom level": "Tahap zum",
|
||||
"Table viewer": "Penyorot jadual",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Tidak dapat menyambung ke Readwise. Sila periksa sambungan rangkaian anda.",
|
||||
"Invalid Readwise access token": "Token akses Readwise tidak sah",
|
||||
"Disconnected from Readwise": "Terputus sambungan daripada Readwise",
|
||||
"Never": "Tidak pernah",
|
||||
"Readwise Settings": "Tetapan Readwise",
|
||||
"Connected to Readwise": "Disambungkan ke Readwise",
|
||||
"Last synced: {{time}}": "Kali terakhir disinkronkan: {{time}}",
|
||||
"Sync Enabled": "Penyinkronan Didayakan",
|
||||
"Disconnect": "Putuskan sambungan",
|
||||
"Connect your Readwise account to sync highlights.": "Sambungkan akaun Readwise anda untuk menyinkronkan sorotan.",
|
||||
"Get your access token at": "Dapatkan token akses anda di",
|
||||
"Access Token": "Token Akses",
|
||||
"Paste your Readwise access token": "Tampal token akses Readwise anda",
|
||||
"Config": "Konfigurasi",
|
||||
"Readwise Sync": "Penyinkronan Readwise",
|
||||
"Push Highlights": "Hantar Sorotan",
|
||||
"Highlights synced to Readwise": "Sorotan disinkronkan ke Readwise",
|
||||
"Readwise sync failed: no internet connection": "Penyinkronan Readwise gagal: tiada sambungan internet",
|
||||
"Readwise sync failed: {{error}}": "Penyinkronan Readwise gagal: {{error}}",
|
||||
"System Screen Brightness": "Kecerahan Skrin Sistem",
|
||||
"Page:": "Halaman:",
|
||||
"Page: {{number}}": "Halaman: {{number}}",
|
||||
"Annotation page number": "Nombor halaman anotasi",
|
||||
"Translating...": "Menterjemah...",
|
||||
"Show Battery Percentage": "Tunjukkan Peratusan Bateri",
|
||||
"Hide Scrollbar": "Sembunyikan Bar Tatal",
|
||||
"Skip to last reading position": "Lompat ke posisi bacaan terakhir",
|
||||
"Show context": "Tunjukkan konteks",
|
||||
"Hide context": "Sembunyikan konteks",
|
||||
"Decrease font size": "Kecilkan saiz fon",
|
||||
"Increase font size": "Besarkan saiz fon",
|
||||
"Focus": "Fokus",
|
||||
"Theme color": "Warna tema",
|
||||
"Import failed": "Import gagal",
|
||||
"Available Formatters:": "Pemformat Tersedia:",
|
||||
"Format date": "Format tarikh",
|
||||
"Markdown block quote (> per line)": "Petikan blok Markdown (> setiap baris)",
|
||||
"Newlines to <br>": "Baris baharu ke <br>",
|
||||
"Change case": "Tukar huruf besar/kecil",
|
||||
"Trim whitespace": "Pangkas ruang kosong",
|
||||
"Truncate to n characters": "Potong kepada n aksara",
|
||||
"Replace text": "Ganti teks",
|
||||
"Fallback value": "Nilai lalai",
|
||||
"Get length": "Dapatkan panjang",
|
||||
"First/last element": "Elemen pertama/terakhir",
|
||||
"Join array": "Gabung tatasusunan",
|
||||
"Backup failed: {{error}}": "Sandaran gagal: {{error}}",
|
||||
"Select Backup": "Pilih Sandaran",
|
||||
"Restore failed: {{error}}": "Pemulihan gagal: {{error}}",
|
||||
"Backup & Restore": "Sandaran & Pemulihan",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Buat sandaran perpustakaan anda atau pulihkan daripada sandaran sebelumnya. Pemulihan akan digabungkan dengan perpustakaan semasa anda.",
|
||||
"Backup Library": "Sandarkan Perpustakaan",
|
||||
"Restore Library": "Pulihkan Perpustakaan",
|
||||
"Creating backup...": "Membuat sandaran...",
|
||||
"Restoring library...": "Memulihkan perpustakaan...",
|
||||
"{{current}} of {{total}} items": "{{current}} daripada {{total}} item",
|
||||
"Backup completed successfully!": "Sandaran berjaya!",
|
||||
"Restore completed successfully!": "Pemulihan berjaya!",
|
||||
"Your library has been saved to the selected location.": "Perpustakaan anda telah disimpan di lokasi yang dipilih.",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}} buku ditambah, {{updated}} buku dikemas kini.",
|
||||
"Operation failed": "Operasi gagal",
|
||||
"Loading library...": "Memuatkan perpustakaan...",
|
||||
"{{count}} books refreshed_other": "{{count}} buku dimuat semula",
|
||||
"Failed to refresh metadata": "Gagal memuat semula metadata",
|
||||
"Refresh Metadata": "Muat Semula Metadata"
|
||||
}
|
||||
|
||||
@@ -206,7 +206,6 @@
|
||||
"TOC": "Inhoudsopgave",
|
||||
"Table of Contents": "Inhoudsopgave",
|
||||
"Sidebar": "Zijbalk",
|
||||
"TTS not supported for PDF": "TTS niet ondersteund voor PDF",
|
||||
"No Timeout": "Geen time-out",
|
||||
"{{value}} minute": "{{value}} minuut",
|
||||
"{{value}} minutes": "{{value}} minuten",
|
||||
@@ -335,8 +334,8 @@
|
||||
"Fit": "Passend maken",
|
||||
"Reset {{settings}}": "Reset {{settings}}",
|
||||
"Reset Settings": "Instellingen resetten",
|
||||
"{{count}} pages left in chapter_one": "Nog {{count}} pagina in hoofdstuk",
|
||||
"{{count}} pages left in chapter_other": "Nog {{count}} pagina’s in hoofdstuk",
|
||||
"{{count}} pages left in chapter_one": "<1>Nog </1><0>{{count}}</0><1> pagina in hoofdstuk</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>Nog </1><0>{{count}}</0><1> pagina’s in hoofdstuk</1>",
|
||||
"Show Remaining Pages": "Toon resterende pagina’s",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -599,11 +598,10 @@
|
||||
"Size": "Grootte",
|
||||
"Cover": "Omslag",
|
||||
"Contain": "Bevatten",
|
||||
"{{number}} pages left in chapter": "Nog {{number}} pagina’s in hoofdstuk",
|
||||
"{{number}} pages left in chapter": "<1>Nog </1><0>{{number}}</0><1> pagina’s in hoofdstuk</1>",
|
||||
"Device": "Apparaat",
|
||||
"E-Ink Mode": "E-Ink Modus",
|
||||
"Highlight Colors": "Markeerkleuren",
|
||||
"Auto Screen Brightness": "Automatische Schermhelderheid",
|
||||
"Pagination": "Paginering",
|
||||
"Disable Double Tap": "Schakel Dubbel Tikken Uit",
|
||||
"Tap to Paginate": "Tik om te Pagineren",
|
||||
@@ -833,6 +831,9 @@
|
||||
"Clear search": "Zoekopdracht wissen",
|
||||
"Clear search history": "Zoekgeschiedenis wissen",
|
||||
"Tap to Toggle Footer": "Tik om voettekst te wisselen",
|
||||
"Show Current Time": "Huidige tijd weergeven",
|
||||
"Use 24 Hour Clock": "24-uurs klok gebruike",
|
||||
"Show Current Battery Status": "Toon huidige batterijstatus",
|
||||
"Exported successfully": "Succesvol geëxporteerd",
|
||||
"Book exported successfully.": "Boek succesvol geëxporteerd.",
|
||||
"Failed to export the book.": "Exporteren van het boek mislukt.",
|
||||
@@ -976,7 +977,6 @@
|
||||
"Unable to start RSVP": "Kan RSVP niet starten",
|
||||
"RSVP not supported for PDF": "RSVP niet ondersteund voor PDF",
|
||||
"Select Chapter": "Hoofdstuk selecteren",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Context",
|
||||
"Ready": "Klaar",
|
||||
"Chapter Progress": "Hoofdstukvoortgang",
|
||||
@@ -990,10 +990,8 @@
|
||||
"Play (Space)": "Afspelen (Spatie)",
|
||||
"Skip forward 15 words": "15 woorden vooruit",
|
||||
"Forward 15 words (Shift+Right)": "15 woorden vooruit (Shift+Rechts)",
|
||||
"Pause:": "Pauze:",
|
||||
"Decrease speed": "Snelheid verlagen",
|
||||
"Slower (Left/Down)": "Langzamer (Links/Omlaag)",
|
||||
"Current speed": "Huidige snelheid",
|
||||
"Increase speed": "Snelheid verhogen",
|
||||
"Faster (Right/Up)": "Sneller (Rechts/Omhoog)",
|
||||
"Start RSVP Reading": "Start RSVP-lezen",
|
||||
@@ -1031,5 +1029,78 @@
|
||||
"Authors": "Auteurs",
|
||||
"Books": "Boeken",
|
||||
"Groups": "Groepen",
|
||||
"Back to TTS Location": "Terug naar TTS-locatie"
|
||||
"Back to TTS Location": "Terug naar TTS-locatie",
|
||||
"Metadata": "Metadata",
|
||||
"Image viewer": "Afbeeldingenviewer",
|
||||
"Previous Image": "Vorige afbeelding",
|
||||
"Next Image": "Volgende afbeelding",
|
||||
"Zoomed": "Gezoomd",
|
||||
"Zoom level": "Zoomniveau",
|
||||
"Table viewer": "Tabelviewer",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Kan geen verbinding maken met Readwise. Controleer uw netwerkverbinding.",
|
||||
"Invalid Readwise access token": "Ongeldige Readwise-toegangstoken",
|
||||
"Disconnected from Readwise": "Verbinding met Readwise verbroken",
|
||||
"Never": "Nooit",
|
||||
"Readwise Settings": "Readwise-instellingen",
|
||||
"Connected to Readwise": "Verbonden met Readwise",
|
||||
"Last synced: {{time}}": "Laatst gesynchroniseerd: {{time}}",
|
||||
"Sync Enabled": "Synchronisatie ingeschakeld",
|
||||
"Disconnect": "Verbinding verbreken",
|
||||
"Connect your Readwise account to sync highlights.": "Verbind uw Readwise-account om markeringen te synchroniseren.",
|
||||
"Get your access token at": "Verkrijg uw toegangstoken op",
|
||||
"Access Token": "Toegangstoken",
|
||||
"Paste your Readwise access token": "Plak uw Readwise-toegangstoken",
|
||||
"Config": "Configuratie",
|
||||
"Readwise Sync": "Readwise-synchronisatie",
|
||||
"Push Highlights": "Markeringen verzenden",
|
||||
"Highlights synced to Readwise": "Markeringen gesynchroniseerd met Readwise",
|
||||
"Readwise sync failed: no internet connection": "Readwise-synchronisatie mislukt: geen internetverbinding",
|
||||
"Readwise sync failed: {{error}}": "Readwise-synchronisatie mislukt: {{error}}",
|
||||
"System Screen Brightness": "Systeemhelderheid van het scherm",
|
||||
"Page:": "Pagina:",
|
||||
"Page: {{number}}": "Pagina: {{number}}",
|
||||
"Annotation page number": "Annotatiepaginanummer",
|
||||
"Translating...": "Vertalen...",
|
||||
"Show Battery Percentage": "Batterijpercentage weergeven",
|
||||
"Hide Scrollbar": "Schuifbalk verbergen",
|
||||
"Skip to last reading position": "Ga naar laatste leespositie",
|
||||
"Show context": "Context tonen",
|
||||
"Hide context": "Context verbergen",
|
||||
"Decrease font size": "Lettergrootte verkleinen",
|
||||
"Increase font size": "Lettergrootte vergroten",
|
||||
"Focus": "Focus",
|
||||
"Theme color": "Themakleur",
|
||||
"Import failed": "Import mislukt",
|
||||
"Available Formatters:": "Beschikbare opmaak:",
|
||||
"Format date": "Datum opmaken",
|
||||
"Markdown block quote (> per line)": "Markdown-blokcitaat (> per regel)",
|
||||
"Newlines to <br>": "Regeleinden naar <br>",
|
||||
"Change case": "Hoofdlettergebruik wijzigen",
|
||||
"Trim whitespace": "Witruimte verwijderen",
|
||||
"Truncate to n characters": "Inkorten tot n tekens",
|
||||
"Replace text": "Tekst vervangen",
|
||||
"Fallback value": "Terugvalwaarde",
|
||||
"Get length": "Lengte ophalen",
|
||||
"First/last element": "Eerste/laatste element",
|
||||
"Join array": "Array samenvoegen",
|
||||
"Backup failed: {{error}}": "Back-up mislukt: {{error}}",
|
||||
"Select Backup": "Selecteer back-up",
|
||||
"Restore failed: {{error}}": "Herstel mislukt: {{error}}",
|
||||
"Backup & Restore": "Back-up & Herstel",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Maak een back-up van uw bibliotheek of herstel vanuit een eerdere back-up. Herstel wordt samengevoegd met uw huidige bibliotheek.",
|
||||
"Backup Library": "Bibliotheek back-uppen",
|
||||
"Restore Library": "Bibliotheek herstellen",
|
||||
"Creating backup...": "Back-up maken...",
|
||||
"Restoring library...": "Bibliotheek herstellen...",
|
||||
"{{current}} of {{total}} items": "{{current}} van {{total}} items",
|
||||
"Backup completed successfully!": "Back-up succesvol voltooid!",
|
||||
"Restore completed successfully!": "Herstel succesvol voltooid!",
|
||||
"Your library has been saved to the selected location.": "Uw bibliotheek is opgeslagen op de geselecteerde locatie.",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}} boeken toegevoegd, {{updated}} boeken bijgewerkt.",
|
||||
"Operation failed": "Bewerking mislukt",
|
||||
"Loading library...": "Bibliotheek laden...",
|
||||
"{{count}} books refreshed_one": "{{count}} boek vernieuwd",
|
||||
"{{count}} books refreshed_other": "{{count}} boeken vernieuwd",
|
||||
"Failed to refresh metadata": "Metadata vernieuwen mislukt",
|
||||
"Refresh Metadata": "Metadata vernieuwen"
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
"Wikipedia": "Wikipedia",
|
||||
"Writing Mode": "Tryb pisania",
|
||||
"Your Library": "Twoja biblioteka",
|
||||
"TTS not supported for PDF": "TTS nie jest obsługiwane dla plików PDF",
|
||||
"Override Book Font": "Nadpisz czcionkę książki",
|
||||
"Apply to All Books": "Zastosuj do wszystkich książek",
|
||||
"Apply to This Book": "Zastosuj do tej książki",
|
||||
@@ -341,10 +340,10 @@
|
||||
"Fit": "Pasuje",
|
||||
"Reset {{settings}}": "Zresetuj {{settings}}",
|
||||
"Reset Settings": "Zresetuj ustawienia",
|
||||
"{{count}} pages left in chapter_one": "Pozostała {{count}} strona w rozdziale",
|
||||
"{{count}} pages left in chapter_few": "Pozostały {{count}} strony w rozdziale",
|
||||
"{{count}} pages left in chapter_many": "Pozostało {{count}} stron w rozdziale",
|
||||
"{{count}} pages left in chapter_other": "Pozostało {{count}} stron w rozdziale",
|
||||
"{{count}} pages left in chapter_one": "<1>Pozostała </1><0>{{count}}</0><1> strona w rozdziale</1>",
|
||||
"{{count}} pages left in chapter_few": "<1>Pozostały </1><0>{{count}}</0><1> strony w rozdziale</1>",
|
||||
"{{count}} pages left in chapter_many": "<1>Pozostało </1><0>{{count}}</0><1> stron w rozdziale</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>Pozostało </1><0>{{count}}</0><1> stron w rozdziale</1>",
|
||||
"Show Remaining Pages": "Pokaż pozostałe strony",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -607,11 +606,10 @@
|
||||
"Size": "Rozmiar",
|
||||
"Cover": "Okładka",
|
||||
"Contain": "Zawierać",
|
||||
"{{number}} pages left in chapter": "Pozostało {{number}} stron w rozdziale",
|
||||
"{{number}} pages left in chapter": "<1>Pozostało </1><0>{{number}}</0><1> stron w rozdziale</1>",
|
||||
"Device": "Urządzenie",
|
||||
"E-Ink Mode": "Tryb E-Ink",
|
||||
"Highlight Colors": "Kolory wyróżnień",
|
||||
"Auto Screen Brightness": "Automatyczna jasność ekranu",
|
||||
"Pagination": "Stronicowanie",
|
||||
"Disable Double Tap": "Wyłącz podwójne dotknięcie",
|
||||
"Tap to Paginate": "Dotknij, aby stronicować",
|
||||
@@ -853,6 +851,9 @@
|
||||
"Clear search": "Wyczyść wyszukiwanie",
|
||||
"Clear search history": "Wyczyść historię wyszukiwania",
|
||||
"Tap to Toggle Footer": "Dotknij, aby przełączyć stopkę",
|
||||
"Show Current Time": "Pokaż aktualny czas",
|
||||
"Use 24 Hour Clock": "Używaj formatu 24-godzinnego",
|
||||
"Show Current Battery Status": "Pokaż aktualny stan baterii",
|
||||
"Exported successfully": "Wyeksportowano pomyślnie",
|
||||
"Book exported successfully.": "Książka została wyeksportowana pomyślnie.",
|
||||
"Failed to export the book.": "Nie udało się wyeksportować książki.",
|
||||
@@ -1000,7 +1001,6 @@
|
||||
"Unable to start RSVP": "Nie można uruchomić RSVP",
|
||||
"RSVP not supported for PDF": "RSVP nie jest obsługiwane dla PDF",
|
||||
"Select Chapter": "Wybierz rozdział",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Kontekst",
|
||||
"Ready": "Gotowy",
|
||||
"Chapter Progress": "Postęp rozdziału",
|
||||
@@ -1014,10 +1014,8 @@
|
||||
"Play (Space)": "Odtwarzaj (Spacja)",
|
||||
"Skip forward 15 words": "Naprzód o 15 słów",
|
||||
"Forward 15 words (Shift+Right)": "Naprzód o 15 słów (Shift+Prawo)",
|
||||
"Pause:": "Pauza:",
|
||||
"Decrease speed": "Zmniejsz prędkość",
|
||||
"Slower (Left/Down)": "Wolniej (Lewo/Dół)",
|
||||
"Current speed": "Aktualna prędkość",
|
||||
"Increase speed": "Zwiększ prędkość",
|
||||
"Faster (Right/Up)": "Szybciej (Prawo/Góra)",
|
||||
"Start RSVP Reading": "Uruchom czytanie RSVP",
|
||||
@@ -1055,5 +1053,80 @@
|
||||
"Authors": "Autorzy",
|
||||
"Books": "Książki",
|
||||
"Groups": "Grupy",
|
||||
"Back to TTS Location": "Powrót do lokalizacji TTS"
|
||||
"Back to TTS Location": "Powrót do lokalizacji TTS",
|
||||
"Metadata": "Metadane",
|
||||
"Image viewer": "Przeglądarka obrazów",
|
||||
"Previous Image": "Poprzedni obraz",
|
||||
"Next Image": "Następny obraz",
|
||||
"Zoomed": "Powiększone",
|
||||
"Zoom level": "Poziom powiększenia",
|
||||
"Table viewer": "Przeglądarka tabel",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Nie można połączyć się z Readwise. Sprawdź połączenie sieciowe.",
|
||||
"Invalid Readwise access token": "Nieprawidłowy token dostępu Readwise",
|
||||
"Disconnected from Readwise": "Rozłączono z Readwise",
|
||||
"Never": "Nigdy",
|
||||
"Readwise Settings": "Ustawienia Readwise",
|
||||
"Connected to Readwise": "Połączono z Readwise",
|
||||
"Last synced: {{time}}": "Ostatnia synchronizacja: {{time}}",
|
||||
"Sync Enabled": "Synchronizacja włączona",
|
||||
"Disconnect": "Rozłącz",
|
||||
"Connect your Readwise account to sync highlights.": "Połącz swoje konto Readwise, aby synchronizować wyróżnienia.",
|
||||
"Get your access token at": "Pobierz token dostępu pod adresem",
|
||||
"Access Token": "Token dostępu",
|
||||
"Paste your Readwise access token": "Wklej swój token dostępu Readwise",
|
||||
"Config": "Konfiguracja",
|
||||
"Readwise Sync": "Synchronizacja Readwise",
|
||||
"Push Highlights": "Wyślij wyróżnienia",
|
||||
"Highlights synced to Readwise": "Wyróżnienia zsynchronizowane z Readwise",
|
||||
"Readwise sync failed: no internet connection": "Synchronizacja Readwise nie powiodła się: brak połączenia z Internetem",
|
||||
"Readwise sync failed: {{error}}": "Synchronizacja Readwise nie powiodła się: {{error}}",
|
||||
"System Screen Brightness": "Jasność ekranu systemowego",
|
||||
"Page:": "Strona:",
|
||||
"Page: {{number}}": "Strona: {{number}}",
|
||||
"Annotation page number": "Numer strony adnotacji",
|
||||
"Translating...": "Tłumaczenie...",
|
||||
"Show Battery Percentage": "Pokaż procent baterii",
|
||||
"Hide Scrollbar": "Ukryj pasek przewijania",
|
||||
"Skip to last reading position": "Przejdź do ostatniej pozycji czytania",
|
||||
"Show context": "Pokaż kontekst",
|
||||
"Hide context": "Ukryj kontekst",
|
||||
"Decrease font size": "Zmniejsz rozmiar czcionki",
|
||||
"Increase font size": "Zwiększ rozmiar czcionki",
|
||||
"Focus": "Fokus",
|
||||
"Theme color": "Kolor motywu",
|
||||
"Import failed": "Import nie powiódł się",
|
||||
"Available Formatters:": "Dostępne formatery:",
|
||||
"Format date": "Formatuj datę",
|
||||
"Markdown block quote (> per line)": "Cytat blokowy Markdown (> na linię)",
|
||||
"Newlines to <br>": "Nowe linie na <br>",
|
||||
"Change case": "Zmień wielkość liter",
|
||||
"Trim whitespace": "Przytnij spacje",
|
||||
"Truncate to n characters": "Skróć do n znaków",
|
||||
"Replace text": "Zamień tekst",
|
||||
"Fallback value": "Wartość zastępcza",
|
||||
"Get length": "Pobierz długość",
|
||||
"First/last element": "Pierwszy/ostatni element",
|
||||
"Join array": "Połącz tablicę",
|
||||
"Backup failed: {{error}}": "Kopia zapasowa nie powiodła się: {{error}}",
|
||||
"Select Backup": "Wybierz kopię zapasową",
|
||||
"Restore failed: {{error}}": "Przywracanie nie powiodło się: {{error}}",
|
||||
"Backup & Restore": "Kopia zapasowa i przywracanie",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Utwórz kopię zapasową biblioteki lub przywróć z poprzedniej kopii. Przywracanie zostanie połączone z bieżącą biblioteką.",
|
||||
"Backup Library": "Kopia zapasowa biblioteki",
|
||||
"Restore Library": "Przywróć bibliotekę",
|
||||
"Creating backup...": "Tworzenie kopii zapasowej...",
|
||||
"Restoring library...": "Przywracanie biblioteki...",
|
||||
"{{current}} of {{total}} items": "{{current}} z {{total}} elementów",
|
||||
"Backup completed successfully!": "Kopia zapasowa ukończona!",
|
||||
"Restore completed successfully!": "Przywracanie ukończone!",
|
||||
"Your library has been saved to the selected location.": "Biblioteka została zapisana w wybranej lokalizacji.",
|
||||
"{{added}} books added, {{updated}} books updated.": "Dodano {{added}} książek, zaktualizowano {{updated}} książek.",
|
||||
"Operation failed": "Operacja nie powiodła się",
|
||||
"Loading library...": "Wczytywanie biblioteki...",
|
||||
"{{count}} books refreshed_one": "Odświeżono {{count}} książkę",
|
||||
"{{count}} books refreshed_few": "Odświeżono {{count}} książki",
|
||||
"{{count}} books refreshed_many": "Odświeżono {{count}} książek",
|
||||
"{{count}} books refreshed_other": "Odświeżono {{count}} książek",
|
||||
"Failed to refresh metadata": "Nie udało się odświeżyć metadanych",
|
||||
"Refresh Metadata": "Odśwież metadane"
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
"Wikipedia": "Wikipédia",
|
||||
"Writing Mode": "Modo de Escrita",
|
||||
"Your Library": "Sua Biblioteca",
|
||||
"TTS not supported for PDF": "TTS não suportado para PDF",
|
||||
"Override Book Font": "Sobrescrever Fonte do Livro",
|
||||
"Apply to All Books": "Aplicar a todos os livros",
|
||||
"Apply to This Book": "Aplicar a este livro",
|
||||
@@ -338,9 +337,9 @@
|
||||
"Fit": "Encaixar",
|
||||
"Reset {{settings}}": "Redefinir {{settings}}",
|
||||
"Reset Settings": "Redefinir Configurações",
|
||||
"{{count}} pages left in chapter_one": "Falta {{count}} página neste capítulo",
|
||||
"{{count}} pages left in chapter_many": "Faltam {{count}} páginas neste capítulo",
|
||||
"{{count}} pages left in chapter_other": "Faltam {{count}} páginas neste capítulo",
|
||||
"{{count}} pages left in chapter_one": "<1>Falta </1><0>{{count}}</0><1> página neste capítulo</1>",
|
||||
"{{count}} pages left in chapter_many": "<1>Faltam </1><0>{{count}}</0><1> páginas neste capítulo</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>Faltam </1><0>{{count}}</0><1> páginas neste capítulo</1>",
|
||||
"Show Remaining Pages": "Mostrar páginas restantes",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -603,11 +602,10 @@
|
||||
"Size": "Tamanho",
|
||||
"Cover": "Capa",
|
||||
"Contain": "Contém",
|
||||
"{{number}} pages left in chapter": "Faltam {{number}} páginas neste capítulo",
|
||||
"{{number}} pages left in chapter": "<1>Faltam </1><0>{{number}}</0><1> páginas neste capítulo</1>",
|
||||
"Device": "Dispositivo",
|
||||
"E-Ink Mode": "Modo E-Ink",
|
||||
"Highlight Colors": "Cores de Destaque",
|
||||
"Auto Screen Brightness": "Brilho Automático da Tela",
|
||||
"Pagination": "Paginação",
|
||||
"Disable Double Tap": "Desativar Toque Duplo",
|
||||
"Tap to Paginate": "Toque para Paginar",
|
||||
@@ -843,6 +841,9 @@
|
||||
"Clear search": "Limpar pesquisa",
|
||||
"Clear search history": "Limpar histórico de pesquisa",
|
||||
"Tap to Toggle Footer": "Toque para alternar rodapé",
|
||||
"Show Current Time": "Mostrar hora atual",
|
||||
"Use 24 Hour Clock": "Usar formato de 24 horas",
|
||||
"Show Current Battery Status": "Mostrar o estado atual da bateria",
|
||||
"Exported successfully": "Exportado com sucesso",
|
||||
"Book exported successfully.": "Livro exportado com sucesso.",
|
||||
"Failed to export the book.": "Falha ao exportar o livro.",
|
||||
@@ -988,7 +989,6 @@
|
||||
"Unable to start RSVP": "Não foi possível iniciar o RSVP",
|
||||
"RSVP not supported for PDF": "RSVP não suportado para PDF",
|
||||
"Select Chapter": "Selecionar capítulo",
|
||||
"{{number}} WPM": "{{number}} PPM",
|
||||
"Context": "Contexto",
|
||||
"Ready": "Pronto",
|
||||
"Chapter Progress": "Progresso do capítulo",
|
||||
@@ -1002,10 +1002,8 @@
|
||||
"Play (Space)": "Reproduzir (Espaço)",
|
||||
"Skip forward 15 words": "Avançar 15 palavras",
|
||||
"Forward 15 words (Shift+Right)": "Avançar 15 palavras (Shift+Direita)",
|
||||
"Pause:": "Pausa:",
|
||||
"Decrease speed": "Diminuir velocidade",
|
||||
"Slower (Left/Down)": "Mais lento (Esquerda/Baixo)",
|
||||
"Current speed": "Velocidade atual",
|
||||
"Increase speed": "Aumentar velocidade",
|
||||
"Faster (Right/Up)": "Mais rápido (Direita/Cima)",
|
||||
"Start RSVP Reading": "Iniciar leitura RSVP",
|
||||
@@ -1043,5 +1041,79 @@
|
||||
"Authors": "Autores",
|
||||
"Books": "Livros",
|
||||
"Groups": "Grupos",
|
||||
"Back to TTS Location": "Voltar para a localização do TTS"
|
||||
"Back to TTS Location": "Voltar para a localização do TTS",
|
||||
"Metadata": "Metadados",
|
||||
"Image viewer": "Visualizador de imagens",
|
||||
"Previous Image": "Imagem anterior",
|
||||
"Next Image": "Próxima imagem",
|
||||
"Zoomed": "Com zoom",
|
||||
"Zoom level": "Nível de zoom",
|
||||
"Table viewer": "Visualizador de tabelas",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Não foi possível conectar ao Readwise. Por favor, verifique sua conexão de rede.",
|
||||
"Invalid Readwise access token": "Token de acesso do Readwise inválido",
|
||||
"Disconnected from Readwise": "Desconectado do Readwise",
|
||||
"Never": "Nunca",
|
||||
"Readwise Settings": "Configurações do Readwise",
|
||||
"Connected to Readwise": "Conectado ao Readwise",
|
||||
"Last synced: {{time}}": "Última sincronização: {{time}}",
|
||||
"Sync Enabled": "Sincronização ativada",
|
||||
"Disconnect": "Desconectar",
|
||||
"Connect your Readwise account to sync highlights.": "Conecte sua conta do Readwise para sincronizar os destaques.",
|
||||
"Get your access token at": "Obtenha seu token de acesso em",
|
||||
"Access Token": "Token de acesso",
|
||||
"Paste your Readwise access token": "Cole seu token de acesso do Readwise",
|
||||
"Config": "Configuração",
|
||||
"Readwise Sync": "Sincronização com o Readwise",
|
||||
"Push Highlights": "Enviar destaques",
|
||||
"Highlights synced to Readwise": "Destaques sincronizados com o Readwise",
|
||||
"Readwise sync failed: no internet connection": "Sincronização com o Readwise falhou: sem conexão com a internet",
|
||||
"Readwise sync failed: {{error}}": "Sincronização com o Readwise falhou: {{error}}",
|
||||
"System Screen Brightness": "Brilho da tela do sistema",
|
||||
"Page:": "Página:",
|
||||
"Page: {{number}}": "Página: {{number}}",
|
||||
"Annotation page number": "Número de página da anotação",
|
||||
"Translating...": "Traduzindo...",
|
||||
"Show Battery Percentage": "Mostrar porcentagem da bateria",
|
||||
"Hide Scrollbar": "Ocultar barra de rolagem",
|
||||
"Skip to last reading position": "Ir para a última posição de leitura",
|
||||
"Show context": "Mostrar contexto",
|
||||
"Hide context": "Ocultar contexto",
|
||||
"Decrease font size": "Diminuir tamanho da fonte",
|
||||
"Increase font size": "Aumentar tamanho da fonte",
|
||||
"Focus": "Foco",
|
||||
"Theme color": "Cor do tema",
|
||||
"Import failed": "Falha na importação",
|
||||
"Available Formatters:": "Formatadores disponíveis:",
|
||||
"Format date": "Formatar data",
|
||||
"Markdown block quote (> per line)": "Citação Markdown (> por linha)",
|
||||
"Newlines to <br>": "Quebras de linha para <br>",
|
||||
"Change case": "Alterar maiúsculas/minúsculas",
|
||||
"Trim whitespace": "Remover espaços",
|
||||
"Truncate to n characters": "Truncar para n caracteres",
|
||||
"Replace text": "Substituir texto",
|
||||
"Fallback value": "Valor padrão",
|
||||
"Get length": "Obter comprimento",
|
||||
"First/last element": "Primeiro/último elemento",
|
||||
"Join array": "Juntar array",
|
||||
"Backup failed: {{error}}": "Falha no backup: {{error}}",
|
||||
"Select Backup": "Selecionar backup",
|
||||
"Restore failed: {{error}}": "Falha na restauração: {{error}}",
|
||||
"Backup & Restore": "Backup e restauração",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Crie um backup da sua biblioteca ou restaure a partir de um backup anterior. A restauração será mesclada com sua biblioteca atual.",
|
||||
"Backup Library": "Fazer backup da biblioteca",
|
||||
"Restore Library": "Restaurar biblioteca",
|
||||
"Creating backup...": "Criando backup...",
|
||||
"Restoring library...": "Restaurando biblioteca...",
|
||||
"{{current}} of {{total}} items": "{{current}} de {{total}} itens",
|
||||
"Backup completed successfully!": "Backup concluído com sucesso!",
|
||||
"Restore completed successfully!": "Restauração concluída com sucesso!",
|
||||
"Your library has been saved to the selected location.": "Sua biblioteca foi salva no local selecionado.",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}} livros adicionados, {{updated}} livros atualizados.",
|
||||
"Operation failed": "Operação falhou",
|
||||
"Loading library...": "Carregando biblioteca...",
|
||||
"{{count}} books refreshed_one": "{{count}} livro atualizado",
|
||||
"{{count}} books refreshed_many": "{{count}} livros atualizados",
|
||||
"{{count}} books refreshed_other": "{{count}} livros atualizados",
|
||||
"Failed to refresh metadata": "Falha ao atualizar metadados",
|
||||
"Refresh Metadata": "Atualizar metadados"
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
"Wikipedia": "Википедия",
|
||||
"Writing Mode": "Режим записи",
|
||||
"Your Library": "Ваша библиотека",
|
||||
"TTS not supported for PDF": "TTS не поддерживается для PDF",
|
||||
"Override Book Font": "Переопределить шрифт книги",
|
||||
"Apply to All Books": "Применить ко всем книгам",
|
||||
"Apply to This Book": "Применить к этой книге",
|
||||
@@ -341,10 +340,10 @@
|
||||
"Fit": "Подогнать",
|
||||
"Reset {{settings}}": "Сбросить {{settings}}",
|
||||
"Reset Settings": "Сбросить настройки",
|
||||
"{{count}} pages left in chapter_one": "Осталась {{count}} страница в главе",
|
||||
"{{count}} pages left in chapter_few": "Осталось {{count}} страницы в главе",
|
||||
"{{count}} pages left in chapter_many": "Осталось {{count}} страниц в главе",
|
||||
"{{count}} pages left in chapter_other": "Осталось {{count}} страниц в главе",
|
||||
"{{count}} pages left in chapter_one": "<1>Осталась </1><0>{{count}}</0><1> страница в главе</1>",
|
||||
"{{count}} pages left in chapter_few": "<1>Осталось </1><0>{{count}}</0><1> страницы в главе</1>",
|
||||
"{{count}} pages left in chapter_many": "<1>Осталось </1><0>{{count}}</0><1> страниц в главе</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>Осталось </1><0>{{count}}</0><1> страниц в главе</1>",
|
||||
"Show Remaining Pages": "Показать оставшиеся страницы",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -607,11 +606,10 @@
|
||||
"Size": "Размер",
|
||||
"Cover": "Обложка",
|
||||
"Contain": "Содержать",
|
||||
"{{number}} pages left in chapter": "Осталось {{number}} страниц в главе",
|
||||
"{{number}} pages left in chapter": "<1>Осталось </1><0>{{number}}</0><1> страниц в главе</1>",
|
||||
"Device": "Устройство",
|
||||
"E-Ink Mode": "E-Ink режим",
|
||||
"Highlight Colors": "Цвета выделения",
|
||||
"Auto Screen Brightness": "Автояркость экрана",
|
||||
"Pagination": "Пагинация",
|
||||
"Disable Double Tap": "Отключить двойной тап",
|
||||
"Tap to Paginate": "Тапните, чтобы разбить на страницы",
|
||||
@@ -853,6 +851,9 @@
|
||||
"Clear search": "Очистить поиск",
|
||||
"Clear search history": "Очистить историю поиска",
|
||||
"Tap to Toggle Footer": "Нажмите для переключения нижнего колонтитула",
|
||||
"Show Current Time": "Показать текущее время",
|
||||
"Use 24 Hour Clock": "Использовать 24-часовой формат",
|
||||
"Show Current Battery Status": "Показать текущее состояние батареи",
|
||||
"Exported successfully": "Успешно экспортировано",
|
||||
"Book exported successfully.": "Книга успешно экспортирована.",
|
||||
"Failed to export the book.": "Не удалось экспортировать книгу.",
|
||||
@@ -1000,7 +1001,6 @@
|
||||
"Unable to start RSVP": "Не удалось запустить RSVP",
|
||||
"RSVP not supported for PDF": "RSVP не поддерживается для PDF",
|
||||
"Select Chapter": "Выбрать главу",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Контекст",
|
||||
"Ready": "Готово",
|
||||
"Chapter Progress": "Прогресс главы",
|
||||
@@ -1014,10 +1014,8 @@
|
||||
"Play (Space)": "Воспроизведение (Пробел)",
|
||||
"Skip forward 15 words": "Вперед на 15 слов",
|
||||
"Forward 15 words (Shift+Right)": "Вперед на 15 слов (Shift+Вправо)",
|
||||
"Pause:": "Пауза:",
|
||||
"Decrease speed": "Уменьшить скорость",
|
||||
"Slower (Left/Down)": "Медленнее (Влево/Вниз)",
|
||||
"Current speed": "Текущая скорость",
|
||||
"Increase speed": "Увеличить скорость",
|
||||
"Faster (Right/Up)": "Быстрее (Вправо/Вверх)",
|
||||
"Start RSVP Reading": "Начать чтение RSVP",
|
||||
@@ -1055,5 +1053,80 @@
|
||||
"Authors": "Авторы",
|
||||
"Books": "Книги",
|
||||
"Groups": "Группы",
|
||||
"Back to TTS Location": "Вернуться к местоположению TTS"
|
||||
"Back to TTS Location": "Вернуться к местоположению TTS",
|
||||
"Metadata": "Метаданные",
|
||||
"Image viewer": "Просмотрщик изображений",
|
||||
"Previous Image": "Предыдущее изображение",
|
||||
"Next Image": "Следующее изображение",
|
||||
"Zoomed": "Увеличено",
|
||||
"Zoom level": "Уровень масштаба",
|
||||
"Table viewer": "Просмотрщик таблиц",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Не удалось подключиться к Readwise. Пожалуйста, проверьте ваше сетевое соединение.",
|
||||
"Invalid Readwise access token": "Недействительный токен доступа Readwise",
|
||||
"Disconnected from Readwise": "Отключено от Readwise",
|
||||
"Never": "Никогда",
|
||||
"Readwise Settings": "Настройки Readwise",
|
||||
"Connected to Readwise": "Подключено к Readwise",
|
||||
"Last synced: {{time}}": "Последняя синхронизация: {{time}}",
|
||||
"Sync Enabled": "Синхронизация включена",
|
||||
"Disconnect": "Отключить",
|
||||
"Connect your Readwise account to sync highlights.": "Подключите ваш аккаунт Readwise для синхронизации выделений.",
|
||||
"Get your access token at": "Получите ваш токен доступа на",
|
||||
"Access Token": "Токен доступа",
|
||||
"Paste your Readwise access token": "Вставьте ваш токен доступа Readwise",
|
||||
"Config": "Конфигурация",
|
||||
"Readwise Sync": "Синхронизация Readwise",
|
||||
"Push Highlights": "Отправить выделения",
|
||||
"Highlights synced to Readwise": "Выделения синхронизированы с Readwise",
|
||||
"Readwise sync failed: no internet connection": "Синхронизация Readwise не удалась: нет интернет-соединения",
|
||||
"Readwise sync failed: {{error}}": "Синхронизация Readwise не удалась: {{error}}",
|
||||
"System Screen Brightness": "Систেমная яркость экрана",
|
||||
"Page:": "Страница:",
|
||||
"Page: {{number}}": "Страница: {{number}}",
|
||||
"Annotation page number": "Номер страницы аннотации",
|
||||
"Translating...": "Перевод...",
|
||||
"Show Battery Percentage": "Показывать процент заряда батареи",
|
||||
"Hide Scrollbar": "Скрыть полосу прокрутки",
|
||||
"Skip to last reading position": "Перейти к последней позиции чтения",
|
||||
"Show context": "Показать контекст",
|
||||
"Hide context": "Скрыть контекст",
|
||||
"Decrease font size": "Уменьшить размер шрифта",
|
||||
"Increase font size": "Увеличить размер шрифта",
|
||||
"Focus": "Фокус",
|
||||
"Theme color": "Цвет темы",
|
||||
"Import failed": "Ошибка импорта",
|
||||
"Available Formatters:": "Доступные форматеры:",
|
||||
"Format date": "Форматировать дату",
|
||||
"Markdown block quote (> per line)": "Цитата Markdown (> для каждой строки)",
|
||||
"Newlines to <br>": "Переносы строк в <br>",
|
||||
"Change case": "Изменить регистр",
|
||||
"Trim whitespace": "Удалить пробелы",
|
||||
"Truncate to n characters": "Обрезать до n символов",
|
||||
"Replace text": "Заменить текст",
|
||||
"Fallback value": "Значение по умолчанию",
|
||||
"Get length": "Получить длину",
|
||||
"First/last element": "Первый/последний элемент",
|
||||
"Join array": "Объединить массив",
|
||||
"Backup failed: {{error}}": "Ошибка резервного копирования: {{error}}",
|
||||
"Select Backup": "Выбрать резервную копию",
|
||||
"Restore failed: {{error}}": "Ошибка восстановления: {{error}}",
|
||||
"Backup & Restore": "Резервное копирование и восстановление",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Создайте резервную копию библиотеки или восстановите из предыдущей копии. Восстановление будет объединено с текущей библиотекой.",
|
||||
"Backup Library": "Резервная копия библиотеки",
|
||||
"Restore Library": "Восстановить библиотеку",
|
||||
"Creating backup...": "Создание резервной копии...",
|
||||
"Restoring library...": "Восстановление библиотеки...",
|
||||
"{{current}} of {{total}} items": "{{current}} из {{total}} элементов",
|
||||
"Backup completed successfully!": "Резервное копирование завершено!",
|
||||
"Restore completed successfully!": "Восстановление завершено!",
|
||||
"Your library has been saved to the selected location.": "Ваша библиотека сохранена в выбранном месте.",
|
||||
"{{added}} books added, {{updated}} books updated.": "Добавлено {{added}} книг, обновлено {{updated}} книг.",
|
||||
"Operation failed": "Операция не удалась",
|
||||
"Loading library...": "Загрузка библиотеки...",
|
||||
"{{count}} books refreshed_one": "Обновлена {{count}} книга",
|
||||
"{{count}} books refreshed_few": "Обновлено {{count}} книги",
|
||||
"{{count}} books refreshed_many": "Обновлено {{count}} книг",
|
||||
"{{count}} books refreshed_other": "Обновлено {{count}} книг",
|
||||
"Failed to refresh metadata": "Не удалось обновить метаданные",
|
||||
"Refresh Metadata": "Обновить метаданные"
|
||||
}
|
||||
|
||||
@@ -186,8 +186,8 @@
|
||||
"Add your notes here...": "මෙහි ඔබේ සටහන් එක් කරන්න...",
|
||||
"Search notes and excerpts...": "සටහන් සහ උදාහරණ සොයන්න...",
|
||||
"{{time}} min left in chapter": "පරිච්ඡේදයේ මිනිත්තු {{time}} ක් ඉතිරි",
|
||||
"{{count}} pages left in chapter_one": "පරිච්ඡේදයේ පිටුව 1 ක් ඉතිරි",
|
||||
"{{count}} pages left in chapter_other": "පරිච්ඡේදයේ පිටු {{count}} ක් ඉතිරි",
|
||||
"{{count}} pages left in chapter_one": "<1>පරිච්ඡේදයේ පිටුව 1 ක් ඉතිරි</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>පරිච්ඡේදයේ පිටු </1><0>{{count}}</0><1> ක් ඉතිරි</1>",
|
||||
"Theme Mode": "තේමා ආකාරය",
|
||||
"Invert Image In Dark Mode": "අඳුරු ආකාරයේදී රූපය පරිවර්තනය කරන්න",
|
||||
"Override Book Color": "පොතේ වර්ණය අභිබවන්න",
|
||||
@@ -319,7 +319,6 @@
|
||||
"Table of Contents": "සූචිය",
|
||||
"Sidebar": "පැති තීරුව",
|
||||
"Disable Translation": "පරිවර්තනය අබල කරන්න",
|
||||
"TTS not supported for PDF": "PDF සඳහා TTS සහාය නොදක්වයි",
|
||||
"TTS not supported for this document": "මෙම ලේඛනය සඳහා TTS සහාය නොදක්වයි",
|
||||
"No Timeout": "කාල සීමාවක් නැත",
|
||||
"{{value}} minute": "මිනිත්තු {{value}}",
|
||||
@@ -599,11 +598,10 @@
|
||||
"Size": "ප්රමාණය",
|
||||
"Cover": "ආවරණය",
|
||||
"Contain": "අඩංගු වන්න",
|
||||
"{{number}} pages left in chapter": "පරිච්ඡේදයේ පිටු {{number}} ඉතිරිව ඇත",
|
||||
"{{number}} pages left in chapter": "<1>පරිච්ඡේදයේ පිටු </1><0>{{number}}</0><1> ඉතිරිව ඇත</1>",
|
||||
"Device": "උපකරණය",
|
||||
"E-Ink Mode": "E-Ink ආකාරය",
|
||||
"Highlight Colors": "ඉස්මතු වර්ණ",
|
||||
"Auto Screen Brightness": "ස්වයංක්රීය තිර දිදුලනුම",
|
||||
"Pagination": "පිටුගත කිරීම",
|
||||
"Disable Double Tap": "දෙවරක් ටැප් කිරීම අබල කරන්න",
|
||||
"Tap to Paginate": "පිටුගත කිරීමට ටැප් කරන්න",
|
||||
@@ -833,6 +831,9 @@
|
||||
"Clear search": "සෙවුම හිස් කරන්න",
|
||||
"Clear search history": "සෙවුම් ඉතිහාසය හිස් කරන්න",
|
||||
"Tap to Toggle Footer": "පාදකය ටොගල් කිරීමට තට්ටු කරන්න",
|
||||
"Show Current Time": "වත්මන් වේලාව පෙන්වන්න",
|
||||
"Use 24 Hour Clock": "පැය 24 ඔරලෝසුව භාවිතා කරන්න",
|
||||
"Show Current Battery Status": "වත්මන් බැටරි තත්ත්වය පෙන්වන්න",
|
||||
"Exported successfully": "සාර්ථකව අපනයනය කරන ලදී",
|
||||
"Book exported successfully.": "පොත සාර්ථකව අපනයනය කරන ලදී.",
|
||||
"Failed to export the book.": "පොත අපනයනය කිරීමට අසමත් විය.",
|
||||
@@ -976,7 +977,6 @@
|
||||
"Unable to start RSVP": "RSVP ආරම්භ කිරීමට නොහැක",
|
||||
"RSVP not supported for PDF": "PDF සඳහා RSVP සහාය නොදක්වයි",
|
||||
"Select Chapter": "පරිච්ඡේදය තෝරන්න",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "සන්දර්භය",
|
||||
"Ready": "සූදානම්",
|
||||
"Chapter Progress": "පරිච්ඡේදයේ ප්රගතිය",
|
||||
@@ -990,10 +990,8 @@
|
||||
"Play (Space)": "ධාවනය (Space)",
|
||||
"Skip forward 15 words": "වචන 15ක් ඉදිරියට",
|
||||
"Forward 15 words (Shift+Right)": "වචන 15ක් ඉදිරියට (Shift+Right)",
|
||||
"Pause:": "විරාමය:",
|
||||
"Decrease speed": "වේගය අඩු කරන්න",
|
||||
"Slower (Left/Down)": "මන්දගාමී (Left/Down)",
|
||||
"Current speed": "වත්මන් වේගය",
|
||||
"Increase speed": "වේගය වැඩි කරන්න",
|
||||
"Faster (Right/Up)": "වේගවත් (Right/Up)",
|
||||
"Start RSVP Reading": "RSVP කියවීම ආරම්භ කරන්න",
|
||||
@@ -1031,5 +1029,78 @@
|
||||
"Authors": "කතුවරුන්",
|
||||
"Books": "පොත්",
|
||||
"Groups": "කණ්ඩායම්",
|
||||
"Back to TTS Location": "TTS ස්ථානයට ආපසු යන්න"
|
||||
"Back to TTS Location": "TTS ස්ථානයට ආපසු යන්න",
|
||||
"Metadata": "මෙටාඩේටා",
|
||||
"Image viewer": "රූප නරඹන්නා",
|
||||
"Previous Image": "පූර්ව රූපය",
|
||||
"Next Image": "මීළඟ රූපය",
|
||||
"Zoomed": "විශාලනය කරන ලදී",
|
||||
"Zoom level": "විශාලන මට්ටම",
|
||||
"Table viewer": "වගු නරඹන්නා",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Readwise වෙත සම්බන්ධ වීමට නොහැක. කරුණාකර ඔබගේ ජාල සම්බන්ධතාවය පරීක්ෂා කරන්න.",
|
||||
"Invalid Readwise access token": "අවලංගු Readwise ප්රවේශ ටෝකනය",
|
||||
"Disconnected from Readwise": "Readwise වෙතින් විසන්ධි විය",
|
||||
"Never": "කවදාවත් නැහැ",
|
||||
"Readwise Settings": "Readwise සැකසුම්",
|
||||
"Connected to Readwise": "Readwise වෙත සම්බන්ධ විය",
|
||||
"Last synced: {{time}}": "අවසානයට සමමුහුර්ත කළේ: {{time}}",
|
||||
"Sync Enabled": "සමමුහුර්ත කිරීම සබල කර ඇත",
|
||||
"Disconnect": "විසන්ධි කරන්න",
|
||||
"Connect your Readwise account to sync highlights.": "විශේෂ අවස්ථා සමමුහුර්ත කිරීමට ඔබගේ Readwise ගිණුම සම්බන්ධ කරන්න.",
|
||||
"Get your access token at": "ඔබගේ ප්රවේශ ටෝකනය මෙතැනින් ලබා ගන්න",
|
||||
"Access Token": "ප්රවේශ ටෝකනය",
|
||||
"Paste your Readwise access token": "ඔබගේ Readwise ප්රවේශ ටෝකනය මෙහි අලවන්න",
|
||||
"Config": "වින්යාසය",
|
||||
"Readwise Sync": "Readwise සමමුහුර්තකරණය",
|
||||
"Push Highlights": "විශේෂ අවස්ථා යොමු කරන්න",
|
||||
"Highlights synced to Readwise": "විශේෂ අවස්ථා Readwise සමඟ සමමුහුර්ත විය",
|
||||
"Readwise sync failed: no internet connection": "Readwise සමමුහුර්තකරණය අසාර්ථක විය: අන්තර්ජාල සම්බන්ධතාවයක් නොමැත",
|
||||
"Readwise sync failed: {{error}}": "Readwise සමමුහුර්තකරණය අසාර්ථක විය: {{error}}",
|
||||
"System Screen Brightness": "පද්ධති තිර දීප්තිය",
|
||||
"Page:": "පිටුව:",
|
||||
"Page: {{number}}": "පිටුව: {{number}}",
|
||||
"Annotation page number": "විවරණ පිටු අංකය",
|
||||
"Translating...": "පරිවර්තනය වෙමින් පවතී...",
|
||||
"Show Battery Percentage": "බැටරි ප්රතිශතය පෙන්වන්න",
|
||||
"Hide Scrollbar": "අනුචලන තීරුව සඟවන්න",
|
||||
"Skip to last reading position": "අවසන් කියවීමේ ස්ථානයට යන්න",
|
||||
"Show context": "සන්දර්භය පෙන්වන්න",
|
||||
"Hide context": "සන්දර්භය සඟවන්න",
|
||||
"Decrease font size": "අකුරු ප්රමාණය අඩු කරන්න",
|
||||
"Increase font size": "අකුරු ප්රමාණය වැඩි කරන්න",
|
||||
"Focus": "අවධානය",
|
||||
"Theme color": "තේමා වර්ණය",
|
||||
"Import failed": "ආයාත කිරීම අසාර්ථකයි",
|
||||
"Available Formatters:": "ලබාගත හැකි ආකෘතිකරණ:",
|
||||
"Format date": "දිනය ආකෘතිකරණය",
|
||||
"Markdown block quote (> per line)": "Markdown බ්ලොක් උද්ධෘතය (පේළියකට >)",
|
||||
"Newlines to <br>": "නව පේළි <br> වෙත",
|
||||
"Change case": "අකුරු ප්රමාණය වෙනස් කරන්න",
|
||||
"Trim whitespace": "හිස්තැන් කපන්න",
|
||||
"Truncate to n characters": "n අක්ෂර දක්වා කෙටි කරන්න",
|
||||
"Replace text": "පෙළ ප්රතිස්ථාපනය",
|
||||
"Fallback value": "විකල්ප අගය",
|
||||
"Get length": "දිග ලබාගන්න",
|
||||
"First/last element": "පළමු/අවසාන මූලද්රව්යය",
|
||||
"Join array": "අරාව සම්බන්ධ කරන්න",
|
||||
"Backup failed: {{error}}": "උපස්ථ කිරීම අසාර්ථකයි: {{error}}",
|
||||
"Select Backup": "උපස්ථයක් තෝරන්න",
|
||||
"Restore failed: {{error}}": "ප්රතිසාධනය අසාර්ථකයි: {{error}}",
|
||||
"Backup & Restore": "උපස්ථ සහ ප්රතිසාධනය",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "ඔබේ පුස්තකාලයේ උපස්ථයක් සාදන්න හෝ පෙර උපස්ථයකින් ප්රතිසාධනය කරන්න. ප්රතිසාධනය ඔබේ වත්මන් පුස්තකාලය සමග ඒකාබද්ධ වේ.",
|
||||
"Backup Library": "පුස්තකාලය උපස්ථ කරන්න",
|
||||
"Restore Library": "පුස්තකාලය ප්රතිසාධනය කරන්න",
|
||||
"Creating backup...": "උපස්ථය සාදමින්...",
|
||||
"Restoring library...": "පුස්තකාලය ප්රතිසාධනය කරමින්...",
|
||||
"{{current}} of {{total}} items": "{{current}} / {{total}} අයිතම",
|
||||
"Backup completed successfully!": "උපස්ථය සාර්ථකව සම්පූර්ණයි!",
|
||||
"Restore completed successfully!": "ප්රතිසාධනය සාර්ථකව සම්පූර්ණයි!",
|
||||
"Your library has been saved to the selected location.": "ඔබේ පුස්තකාලය තෝරාගත් ස්ථානයේ සුරැකිණි.",
|
||||
"{{added}} books added, {{updated}} books updated.": "පොත් {{added}}ක් එකතු විය, {{updated}}ක් යාවත්කාලීන විය.",
|
||||
"Operation failed": "මෙහෙයුම අසාර්ථකයි",
|
||||
"Loading library...": "පුස්තකාලය පූරණය වෙමින්...",
|
||||
"{{count}} books refreshed_one": "පොත් {{count}}ක් යාවත්කාලීන විය",
|
||||
"{{count}} books refreshed_other": "පොත් {{count}}ක් යාවත්කාලීන විය",
|
||||
"Failed to refresh metadata": "පාරදත්ත යාවත්කාලීන කිරීම අසාර්ථකයි",
|
||||
"Refresh Metadata": "පාරදත්ත යාවත්කාලීන කරන්න"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -234,8 +234,8 @@
|
||||
"Add your notes here...": "Lägg till dina anteckningar här...",
|
||||
"Search notes and excerpts...": "Sök anteckningar och utdrag...",
|
||||
"{{time}} min left in chapter": "{{time}} min kvar i kapitlet",
|
||||
"{{count}} pages left in chapter_one": "{{count}} sida kvar i kapitlet",
|
||||
"{{count}} pages left in chapter_other": "{{count}} sidor kvar i kapitlet",
|
||||
"{{count}} pages left in chapter_one": "<0>{{count}}</0><1> sida kvar i kapitlet</1>",
|
||||
"{{count}} pages left in chapter_other": "<0>{{count}}</0><1> sidor kvar i kapitlet</1>",
|
||||
"On {{current}} of {{total}} page": "På {{current}} av {{total}} sidor",
|
||||
"Section Title": "Sektionstitel",
|
||||
"More Info": "Mer info",
|
||||
@@ -278,7 +278,6 @@
|
||||
"Next Paragraph": "Nästa stycke",
|
||||
"Read Aloud": "Läs upp",
|
||||
"Ready to read aloud": "Redo att läsa upp",
|
||||
"TTS not supported for PDF": "Uppläsning stöds inte för PDF",
|
||||
"TTS not supported for this document": "Uppläsning stöds inte för detta dokument",
|
||||
"No Timeout": "Ingen timeout",
|
||||
"{{value}} minute": "{{value}} minut",
|
||||
@@ -599,11 +598,10 @@
|
||||
"Size": "Storlek",
|
||||
"Cover": "Omslag",
|
||||
"Contain": "Innehålla",
|
||||
"{{number}} pages left in chapter": "{{number}} sidor kvar i kapitlet",
|
||||
"{{number}} pages left in chapter": "<0>{{number}}</0><1> sidor kvar i kapitlet</1>",
|
||||
"Device": "Enhet",
|
||||
"E-Ink Mode": "E-Ink-läge",
|
||||
"Highlight Colors": "Markeringsfärger",
|
||||
"Auto Screen Brightness": "Auto-skärmens ljusstyrka",
|
||||
"Pagination": "Pagination",
|
||||
"Disable Double Tap": "Inaktivera dubbeltryck",
|
||||
"Tap to Paginate": "Tryck för att bläddra",
|
||||
@@ -833,6 +831,9 @@
|
||||
"Clear search": "Rensa sökning",
|
||||
"Clear search history": "Rensa sökhistorik",
|
||||
"Tap to Toggle Footer": "Tryck för att växla sidfot",
|
||||
"Show Current Time": "Visa aktuell tid",
|
||||
"Use 24 Hour Clock": "Använd 24-timmarsklocka",
|
||||
"Show Current Battery Status": "Visa aktuell batteristatus",
|
||||
"Exported successfully": "Exporterad",
|
||||
"Book exported successfully.": "Boken har exporterats.",
|
||||
"Failed to export the book.": "Det gick inte att exportera boken.",
|
||||
@@ -976,7 +977,6 @@
|
||||
"Unable to start RSVP": "Kunde inte starta RSVP",
|
||||
"RSVP not supported for PDF": "RSVP stöds inte för PDF",
|
||||
"Select Chapter": "Välj kapitel",
|
||||
"{{number}} WPM": "{{number}} ord/min",
|
||||
"Context": "Sammanhang",
|
||||
"Ready": "Klar",
|
||||
"Chapter Progress": "Kapitelframsteg",
|
||||
@@ -990,10 +990,8 @@
|
||||
"Play (Space)": "Spela (Mellanslag)",
|
||||
"Skip forward 15 words": "Hoppa framåt 15 ord",
|
||||
"Forward 15 words (Shift+Right)": "Framåt 15 ord (Shift+Höger)",
|
||||
"Pause:": "Paus:",
|
||||
"Decrease speed": "Sänk hastigheten",
|
||||
"Slower (Left/Down)": "Långsammare (Vänster/Nedåt)",
|
||||
"Current speed": "Nuvarande hastighet",
|
||||
"Increase speed": "Höj hastigheten",
|
||||
"Faster (Right/Up)": "Snabbare (Höger/Uppåt)",
|
||||
"Start RSVP Reading": "Starta RSVP-läsning",
|
||||
@@ -1031,5 +1029,78 @@
|
||||
"Authors": "Författare",
|
||||
"Books": "Böcker",
|
||||
"Groups": "Grupper",
|
||||
"Back to TTS Location": "Tillbaka till TTS-plats"
|
||||
"Back to TTS Location": "Tillbaka till TTS-plats",
|
||||
"Metadata": "Metadata",
|
||||
"Image viewer": "Bildvisare",
|
||||
"Previous Image": "Föregående bild",
|
||||
"Next Image": "Nästa bild",
|
||||
"Zoomed": "Zoomad",
|
||||
"Zoom level": "Zoomnivå",
|
||||
"Table viewer": "Tabellvisare",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Kunde inte ansluta till Readwise. Kontrollera din nätverksanslutning.",
|
||||
"Invalid Readwise access token": "Ogiltig Readwise-åtkomsttoken",
|
||||
"Disconnected from Readwise": "Frånkopplad från Readwise",
|
||||
"Never": "Aldrig",
|
||||
"Readwise Settings": "Readwise-inställningar",
|
||||
"Connected to Readwise": "Ansluten till Readwise",
|
||||
"Last synced: {{time}}": "Senast synkroniserad: {{time}}",
|
||||
"Sync Enabled": "Synkronisering aktiverad",
|
||||
"Disconnect": "Koppla från",
|
||||
"Connect your Readwise account to sync highlights.": "Anslut ditt Readwise-konto för att synkronisera markeringar.",
|
||||
"Get your access token at": "Hämta din åtkomsttoken på",
|
||||
"Access Token": "Åtkomsttoken",
|
||||
"Paste your Readwise access token": "Klistra in din Readwise-åtkomsttoken",
|
||||
"Config": "Konfiguration",
|
||||
"Readwise Sync": "Readwise-synkronisering",
|
||||
"Push Highlights": "Skicka markeringar",
|
||||
"Highlights synced to Readwise": "Markeringar synkroniserade till Readwise",
|
||||
"Readwise sync failed: no internet connection": "Readwise-synkronisering misslyckades: ingen internetanslutning",
|
||||
"Readwise sync failed: {{error}}": "Readwise-synkronisering misslyckades: {{error}}",
|
||||
"System Screen Brightness": "Systemets skärmljusstyrka",
|
||||
"Page:": "Sida:",
|
||||
"Page: {{number}}": "Sida: {{number}}",
|
||||
"Annotation page number": "Annoteringssidnummer",
|
||||
"Translating...": "Översätter...",
|
||||
"Show Battery Percentage": "Visa batteriprocent",
|
||||
"Hide Scrollbar": "Dölj rullningslist",
|
||||
"Skip to last reading position": "Hoppa till senaste läsposition",
|
||||
"Show context": "Visa kontext",
|
||||
"Hide context": "Dölj kontext",
|
||||
"Decrease font size": "Minska teckenstorlek",
|
||||
"Increase font size": "Öka teckenstorlek",
|
||||
"Focus": "Fokus",
|
||||
"Theme color": "Temafärg",
|
||||
"Import failed": "Importen misslyckades",
|
||||
"Available Formatters:": "Tillgängliga formaterare:",
|
||||
"Format date": "Formatera datum",
|
||||
"Markdown block quote (> per line)": "Markdown-blockcitat (> per rad)",
|
||||
"Newlines to <br>": "Radbrytningar till <br>",
|
||||
"Change case": "Ändra skiftläge",
|
||||
"Trim whitespace": "Ta bort blanksteg",
|
||||
"Truncate to n characters": "Korta till n tecken",
|
||||
"Replace text": "Ersätt text",
|
||||
"Fallback value": "Standardvärde",
|
||||
"Get length": "Hämta längd",
|
||||
"First/last element": "Första/sista elementet",
|
||||
"Join array": "Sammanfoga array",
|
||||
"Backup failed: {{error}}": "Säkerhetskopiering misslyckades: {{error}}",
|
||||
"Select Backup": "Välj säkerhetskopia",
|
||||
"Restore failed: {{error}}": "Återställning misslyckades: {{error}}",
|
||||
"Backup & Restore": "Säkerhetskopiera & Återställ",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Skapa en säkerhetskopia av ditt bibliotek eller återställ från en tidigare kopia. Återställningen slås samman med ditt nuvarande bibliotek.",
|
||||
"Backup Library": "Säkerhetskopiera bibliotek",
|
||||
"Restore Library": "Återställ bibliotek",
|
||||
"Creating backup...": "Skapar säkerhetskopia...",
|
||||
"Restoring library...": "Återställer bibliotek...",
|
||||
"{{current}} of {{total}} items": "{{current}} av {{total}} objekt",
|
||||
"Backup completed successfully!": "Säkerhetskopieringen slutförd!",
|
||||
"Restore completed successfully!": "Återställningen slutförd!",
|
||||
"Your library has been saved to the selected location.": "Ditt bibliotek har sparats på den valda platsen.",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}} böcker tillagda, {{updated}} böcker uppdaterade.",
|
||||
"Operation failed": "Åtgärden misslyckades",
|
||||
"Loading library...": "Laddar bibliotek...",
|
||||
"{{count}} books refreshed_one": "{{count}} bok uppdaterad",
|
||||
"{{count}} books refreshed_other": "{{count}} böcker uppdaterade",
|
||||
"Failed to refresh metadata": "Kunde inte uppdatera metadata",
|
||||
"Refresh Metadata": "Uppdatera metadata"
|
||||
}
|
||||
|
||||
@@ -186,8 +186,8 @@
|
||||
"Add your notes here...": "உங்கள் குறிப்புகளை இங்கே சேர்க்கவும்...",
|
||||
"Search notes and excerpts...": "குறிப்புகள் மற்றும் உதாரணங்களைத் தேடவும்...",
|
||||
"{{time}} min left in chapter": "அத்தியாயத்தில் {{time}} நிமிடங்கள் மீதமுள்ளன",
|
||||
"{{count}} pages left in chapter_one": "அத்தியாயத்தில் 1 பக்கம் மீதமுள்ளது",
|
||||
"{{count}} pages left in chapter_other": "அத்தியாயத்தில் {{count}} பக்கங்கள் மீதமுள்ளன",
|
||||
"{{count}} pages left in chapter_one": "<1>அத்தியாயத்தில் 1 பக்கம் மீதமுள்ளது</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>அத்தியாயத்தில் </1><0>{{count}}</0><1> பக்கங்கள் மீதமுள்ளன</1>",
|
||||
"Theme Mode": "தீம் பயன்முறை",
|
||||
"Invert Image In Dark Mode": "இருட்டு பயன்முறையில் படத்தை தலைகீழாக மாற்றவும்",
|
||||
"Override Book Color": "புத்தக நிறத்தை மேலெழுதவும்",
|
||||
@@ -319,7 +319,6 @@
|
||||
"Table of Contents": "உள்ளடக்கம்",
|
||||
"Sidebar": "பக்கப்பட்டி",
|
||||
"Disable Translation": "மொழிபெயர்ப்பை முடக்கவும்",
|
||||
"TTS not supported for PDF": "PDF க்கு TTS ஆதரிக்கப்படவில்லை",
|
||||
"TTS not supported for this document": "இந்த ஆவணத்திற்கு TTS ஆதரிக்கப்படவில்லை",
|
||||
"No Timeout": "நேர வரம்பு இல்லை",
|
||||
"{{value}} minute": "{{value}} நிமிடம்",
|
||||
@@ -599,11 +598,10 @@
|
||||
"Size": "அளவு",
|
||||
"Cover": "மூடு",
|
||||
"Contain": "அடங்கும்",
|
||||
"{{number}} pages left in chapter": "அத்தியாயத்தில் {{number}} பக்கங்கள் மீதமுள்ளன",
|
||||
"{{number}} pages left in chapter": "<1>அத்தியாயத்தில் </1><0>{{number}}</0><1> பக்கங்கள் மீதமுள்ளன</1>",
|
||||
"Device": "சாதனம்",
|
||||
"E-Ink Mode": "E-Ink முறை",
|
||||
"Highlight Colors": "முத்திரை நிறங்கள்",
|
||||
"Auto Screen Brightness": "தானியங்கி திரை பிரகாசம்",
|
||||
"Pagination": "பக்கமிடுதல்",
|
||||
"Disable Double Tap": "இரட்டை தொடுதலை முடக்கு",
|
||||
"Tap to Paginate": "பக்கம் மாற்ற தொடுங்கள்",
|
||||
@@ -833,6 +831,9 @@
|
||||
"Clear search": "தேடலை அழி",
|
||||
"Clear search history": "தேடல் வரலாற்றை அழி",
|
||||
"Tap to Toggle Footer": "அடிக்குறிப்பை மாற்ற தட்டவும்",
|
||||
"Show Current Time": "தற்போதைய நேரத்தைக் காண்பி",
|
||||
"Use 24 Hour Clock": "24 மணிநேர கடிகாரத்தைப் பயன்படுத்து",
|
||||
"Show Current Battery Status": "தற்போதைய பேட்டரி நிலையைக் காண்பி",
|
||||
"Exported successfully": "ஏற்றுமதி வெற்றி",
|
||||
"Book exported successfully.": "புத்தகம் வெற்றிகரமாக ஏற்றுமதி செய்யப்பட்டது.",
|
||||
"Failed to export the book.": "புத்தகத்தை ஏற்றுமதி செய்ய இயலவில்லை.",
|
||||
@@ -976,7 +977,6 @@
|
||||
"Unable to start RSVP": "RSVP-ஐத் தொடங்க முடியவில்லை",
|
||||
"RSVP not supported for PDF": "PDF-க்கு RSVP ஆதரவு இல்லை",
|
||||
"Select Chapter": "அத்தியாயத்தைத் தேர்ந்தெடு",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "சூழல்",
|
||||
"Ready": "தயார்",
|
||||
"Chapter Progress": "அத்தியாய முன்னேற்றம்",
|
||||
@@ -990,10 +990,8 @@
|
||||
"Play (Space)": "இயக்கு (Space)",
|
||||
"Skip forward 15 words": "15 வார்த்தைகள் முன்னால் செல்",
|
||||
"Forward 15 words (Shift+Right)": "15 வார்த்தைகள் முன்னால் செல் (Shift+Right)",
|
||||
"Pause:": "நிறுத்து:",
|
||||
"Decrease speed": "வேகத்தைக் குறை",
|
||||
"Slower (Left/Down)": "மெதுவாக (Left/Down)",
|
||||
"Current speed": "தற்போதைய வேகம்",
|
||||
"Increase speed": "வேகத்தை அதிகரி",
|
||||
"Faster (Right/Up)": "வேகமாக (Right/Up)",
|
||||
"Start RSVP Reading": "RSVP வாசிப்பைத் தொடங்கு",
|
||||
@@ -1031,5 +1029,78 @@
|
||||
"Authors": "ஆசிரியர்கள்",
|
||||
"Books": "புத்தகங்கள்",
|
||||
"Groups": "குழுக்கள்",
|
||||
"Back to TTS Location": "TTS இடத்திற்குத் திரும்பு"
|
||||
"Back to TTS Location": "TTS இடத்திற்குத் திரும்பு",
|
||||
"Metadata": "மெட்டாடேட்டா",
|
||||
"Image viewer": "படக் காட்சிப்பவர்",
|
||||
"Previous Image": "முந்தைய படம்",
|
||||
"Next Image": "அடுத்த படம்",
|
||||
"Zoomed": "பெரிதாக்கப்பட்டது",
|
||||
"Zoom level": "பெரிதாக்கும் நிலை",
|
||||
"Table viewer": "அட்டவணை காட்சிப்பவர்",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Readwise உடன் இணைக்க முடியவில்லை. உங்கள் நெட்வொர்க் இணைப்பைச் சரிபார்க்கவும்.",
|
||||
"Invalid Readwise access token": "தவறான Readwise அணுகல் டோக்கன்",
|
||||
"Disconnected from Readwise": "Readwise இலிருந்து துண்டிக்கப்பட்டது",
|
||||
"Never": "ஒருபோதும் இல்லை",
|
||||
"Readwise Settings": "Readwise அமைப்புகள்",
|
||||
"Connected to Readwise": "Readwise உடன் இணைக்கப்பட்டது",
|
||||
"Last synced: {{time}}": "கடைசியாக ஒத்திசைக்கப்பட்டது: {{time}}",
|
||||
"Sync Enabled": "ஒத்திசைவு இயக்கப்பட்டது",
|
||||
"Disconnect": "துண்டி",
|
||||
"Connect your Readwise account to sync highlights.": "சிறப்பம்சங்களை ஒத்திசைக்க உங்கள் Readwise கணக்கை இணைக்கவும்.",
|
||||
"Get your access token at": "உங்கள் அணுகல் டோக்கனை இங்கே பெறவும்",
|
||||
"Access Token": "அணுகல் டோக்கன்",
|
||||
"Paste your Readwise access token": "உங்கள் Readwise அணுகல் டோக்கனை ஒட்டவும்",
|
||||
"Config": "கட்டமைப்பு",
|
||||
"Readwise Sync": "Readwise ஒத்திசைவு",
|
||||
"Push Highlights": "சிறப்பம்சங்களை அனுப்பு",
|
||||
"Highlights synced to Readwise": "சிறப்பம்சங்கள் Readwise உடன் ஒத்திசைக்கப்பட்டன",
|
||||
"Readwise sync failed: no internet connection": "Readwise ஒத்திசைவு தோல்வியடைந்தது: இணைய இணைப்பு இல்லை",
|
||||
"Readwise sync failed: {{error}}": "Readwise ஒத்திசைவு தோல்வியடைந்தது: {{error}}",
|
||||
"System Screen Brightness": "கணினித் திரை பிரகாசம்",
|
||||
"Page:": "பக்கம்:",
|
||||
"Page: {{number}}": "பக்கம்: {{number}}",
|
||||
"Annotation page number": "சிறுகுறிப்புப் பக்க எண்",
|
||||
"Translating...": "மொழிபெயர்க்கிறது...",
|
||||
"Show Battery Percentage": "பேட்டரி சதவீதத்தைக் காண்பி",
|
||||
"Hide Scrollbar": "உருள்பட்டையை மறை",
|
||||
"Skip to last reading position": "கடைசி வாசிப்பு நிலைக்குச் செல்",
|
||||
"Show context": "சூழலைக் காட்டு",
|
||||
"Hide context": "சூழலை மறை",
|
||||
"Decrease font size": "எழுத்துரு அளவைக் குறை",
|
||||
"Increase font size": "எழுத்துரு அளவை அதிகரி",
|
||||
"Focus": "குவியம்",
|
||||
"Theme color": "தீம் நிறம்",
|
||||
"Import failed": "இறக்குமதி தோல்வி",
|
||||
"Available Formatters:": "கிடைக்கும் வடிவமைப்பிகள்:",
|
||||
"Format date": "தேதி வடிவமைப்பு",
|
||||
"Markdown block quote (> per line)": "Markdown மேற்கோள் தொகுதி (ஒவ்வொரு வரிக்கும் >)",
|
||||
"Newlines to <br>": "புதிய வரிகளை <br> ஆக",
|
||||
"Change case": "எழுத்து வகை மாற்றம்",
|
||||
"Trim whitespace": "வெற்றிடங்களை நீக்கு",
|
||||
"Truncate to n characters": "n எழுத்துகளுக்கு சுருக்கு",
|
||||
"Replace text": "உரையை மாற்று",
|
||||
"Fallback value": "இயல்புநிலை மதிப்பு",
|
||||
"Get length": "நீளத்தைப் பெறு",
|
||||
"First/last element": "முதல்/கடைசி உறுப்பு",
|
||||
"Join array": "அணியை இணை",
|
||||
"Backup failed: {{error}}": "காப்புப்பிரதி தோல்வி: {{error}}",
|
||||
"Select Backup": "காப்புப்பிரதியைத் தேர்ந்தெடுக்கவும்",
|
||||
"Restore failed: {{error}}": "மீட்டமைப்பு தோல்வி: {{error}}",
|
||||
"Backup & Restore": "காப்புப்பிரதி & மீட்டமைப்பு",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "உங்கள் நூலகத்தின் காப்புப்பிரதியை உருவாக்கவும் அல்லது முந்தைய காப்புப்பிரதியிலிருந்து மீட்டமைக்கவும். மீட்டமைப்பு உங்கள் தற்போதைய நூலகத்துடன் இணைக்கப்படும்.",
|
||||
"Backup Library": "நூலகத்தை காப்புப்பிரதி எடு",
|
||||
"Restore Library": "நூலகத்தை மீட்டமை",
|
||||
"Creating backup...": "காப்புப்பிரதி உருவாக்கப்படுகிறது...",
|
||||
"Restoring library...": "நூலகம் மீட்டமைக்கப்படுகிறது...",
|
||||
"{{current}} of {{total}} items": "{{current}} / {{total}} உருப்படிகள்",
|
||||
"Backup completed successfully!": "காப்புப்பிரதி வெற்றிகரமாக முடிந்தது!",
|
||||
"Restore completed successfully!": "மீட்டமைப்பு வெற்றிகரமாக முடிந்தது!",
|
||||
"Your library has been saved to the selected location.": "உங்கள் நூலகம் தேர்ந்தெடுக்கப்பட்ட இடத்தில் சேமிக்கப்பட்டது.",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}} புத்தகங்கள் சேர்க்கப்பட்டன, {{updated}} புத்தகங்கள் புதுப்பிக்கப்பட்டன.",
|
||||
"Operation failed": "செயல்பாடு தோல்வி",
|
||||
"Loading library...": "நூலகம் ஏற்றப்படுகிறது...",
|
||||
"{{count}} books refreshed_one": "{{count}} புத்தகம் புதுப்பிக்கப்பட்டது",
|
||||
"{{count}} books refreshed_other": "{{count}} புத்தகங்கள் புதுப்பிக்கப்பட்டன",
|
||||
"Failed to refresh metadata": "மெட்டாடேட்டா புதுப்பிப்பு தோல்வி",
|
||||
"Refresh Metadata": "மெட்டாடேட்டா புதுப்பிக்கவும்"
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
"Add your notes here...": "เพิ่มบันทึกที่นี่...",
|
||||
"Search notes and excerpts...": "ค้นหาบันทึกและข้อความ...",
|
||||
"{{time}} min left in chapter": "เหลือ {{time}} นาทีในบท",
|
||||
"{{count}} pages left in chapter_other": "เหลือ {{count}} หน้าในบท",
|
||||
"{{count}} pages left in chapter_other": "<1>เหลือ </1><0>{{count}}</0><1> หน้าในบท</1>",
|
||||
"Theme Mode": "โหมดธีม",
|
||||
"Invert Image In Dark Mode": "กลับสีรูปในโหมดมืด",
|
||||
"Override Book Color": "เขียนทับสีหนังสือ",
|
||||
@@ -269,7 +269,6 @@
|
||||
"Table of Contents": "สารบัญ",
|
||||
"Sidebar": "แถบข้าง",
|
||||
"Disable Translation": "ปิดการแปล",
|
||||
"TTS not supported for PDF": "ไม่รองรับ TTS สำหรับ PDF",
|
||||
"No Timeout": "ไม่จำกัดเวลา",
|
||||
"{{value}} minute": "{{value}} นาที",
|
||||
"{{value}} minutes": "{{value}} นาที",
|
||||
@@ -595,11 +594,10 @@
|
||||
"Size": "ขนาด",
|
||||
"Cover": "ปก",
|
||||
"Contain": "บรรจุ",
|
||||
"{{number}} pages left in chapter": "เหลือ {{number}} หน้าในบท",
|
||||
"{{number}} pages left in chapter": "<1>เหลือ </1><0>{{number}}</0><1> หน้าในบท</1>",
|
||||
"Device": "อุปกรณ์",
|
||||
"E-Ink Mode": "โหมด E-Ink",
|
||||
"Highlight Colors": "สีไฮไลต์",
|
||||
"Auto Screen Brightness": "ความสว่างหน้าจออัตโนมัติ",
|
||||
"Pagination": "การแบ่งหน้า",
|
||||
"Disable Double Tap": "ปิดการแตะสองครั้ง",
|
||||
"Tap to Paginate": "แตะเพื่อแบ่งหน้า",
|
||||
@@ -823,6 +821,9 @@
|
||||
"Clear search": "ล้างการค้นหา",
|
||||
"Clear search history": "ล้างประวัติการค้นหา",
|
||||
"Tap to Toggle Footer": "แตะเพื่อสลับส่วนท้าย",
|
||||
"Show Current Time": "แสดงเวลาปัจจุบัน",
|
||||
"Use 24 Hour Clock": "ใช้รูปแบบเวลา 24 ชั่วโมง",
|
||||
"Show Current Battery Status": "แสดงสถานะแบตเตอรี่ปัจจุบัน",
|
||||
"Exported successfully": "ส่งออกสำเร็จ",
|
||||
"Book exported successfully.": "ส่งออกหนังสือสำเร็จ",
|
||||
"Failed to export the book.": "ส่งออกหนังสือล้มเหลว",
|
||||
@@ -964,7 +965,6 @@
|
||||
"Unable to start RSVP": "ไม่สามารถเริ่ม RSVP ได้",
|
||||
"RSVP not supported for PDF": "ไม่รองรับ RSVP สำหรับ PDF",
|
||||
"Select Chapter": "เลือกบท",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "บริบท",
|
||||
"Ready": "พร้อม",
|
||||
"Chapter Progress": "ความคืบหน้าของบท",
|
||||
@@ -978,10 +978,8 @@
|
||||
"Play (Space)": "เล่น (Space)",
|
||||
"Skip forward 15 words": "ไปข้างหน้า 15 คำ",
|
||||
"Forward 15 words (Shift+Right)": "ไปข้างหน้า 15 คำ (Shift+ขวา)",
|
||||
"Pause:": "หยุดชั่วคราว:",
|
||||
"Decrease speed": "ลดความเร็ว",
|
||||
"Slower (Left/Down)": "ช้าลง (ซ้าย/ลง)",
|
||||
"Current speed": "ความเร็วปัจจุบัน",
|
||||
"Increase speed": "เพิ่มความเร็ว",
|
||||
"Faster (Right/Up)": "เร็วขึ้น (ขวา/ขึ้น)",
|
||||
"Start RSVP Reading": "เริ่มการอ่านแบบ RSVP",
|
||||
@@ -1019,5 +1017,77 @@
|
||||
"Authors": "ผู้เขียน",
|
||||
"Books": "หนังสือ",
|
||||
"Groups": "กลุ่ม",
|
||||
"Back to TTS Location": "กลับไปยังตำแหน่ง TTS"
|
||||
"Back to TTS Location": "กลับไปยังตำแหน่ง TTS",
|
||||
"Metadata": "เมทาดาตา",
|
||||
"Image viewer": "ตัวดูรูปภาพ",
|
||||
"Previous Image": "รูปภาพก่อนหน้า",
|
||||
"Next Image": "รูปภาพถัดไป",
|
||||
"Zoomed": "ซูมแล้ว",
|
||||
"Zoom level": "ระดับการซูม",
|
||||
"Table viewer": "ตัวดูตาราง",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "ไม่สามารถเชื่อมต่อกับ Readwise ได้ โปรดตรวจสอบการเชื่อมต่ออินเทอร์เน็ตของคุณ",
|
||||
"Invalid Readwise access token": "โทเคนการเข้าถึง Readwise ไม่ถูกต้อง",
|
||||
"Disconnected from Readwise": "ยกเลิกการเชื่อมต่อกับ Readwise แล้ว",
|
||||
"Never": "ไม่เคย",
|
||||
"Readwise Settings": "การตั้งค่า Readwise",
|
||||
"Connected to Readwise": "เชื่อมต่อกับ Readwise แล้ว",
|
||||
"Last synced: {{time}}": "ซิงค์ล่าสุดเมื่อ: {{time}}",
|
||||
"Sync Enabled": "เปิดใช้งานการซิงค์",
|
||||
"Disconnect": "ยกเลิกการเชื่อมต่อ",
|
||||
"Connect your Readwise account to sync highlights.": "เชื่อมต่อบัญชี Readwise ของคุณเพื่อซิงค์ไฮไลต์",
|
||||
"Get your access token at": "รับโทเคนการเข้าถึงของคุณได้ที่",
|
||||
"Access Token": "โทเคนการเข้าถึง",
|
||||
"Paste your Readwise access token": "วางโทเคนการเข้าถึง Readwise ของคุณ",
|
||||
"Config": "การกำหนดค่า",
|
||||
"Readwise Sync": "การซิงค์ Readwise",
|
||||
"Push Highlights": "พุชไฮไลต์",
|
||||
"Highlights synced to Readwise": "ซิงค์ไฮไลต์ไปยัง Readwise แล้ว",
|
||||
"Readwise sync failed: no internet connection": "การซิงค์ Readwise ล้มเหลว: ไม่มีการเชื่อมต่ออินเทอร์เน็ต",
|
||||
"Readwise sync failed: {{error}}": "การซิงค์ Readwise ล้มเหลว: {{error}}",
|
||||
"System Screen Brightness": "ความสว่างหน้าจอระบบ",
|
||||
"Page:": "หน้า:",
|
||||
"Page: {{number}}": "หน้า: {{number}}",
|
||||
"Annotation page number": "หมายเลขหน้าคำอธิบายประกอบ",
|
||||
"Translating...": "กำลังแปล...",
|
||||
"Show Battery Percentage": "แสดงเปอร์เซ็นต์แบตเตอรี่",
|
||||
"Hide Scrollbar": "ซ่อนแถบเลื่อน",
|
||||
"Skip to last reading position": "ข้ามไปตำแหน่งอ่านล่าสุด",
|
||||
"Show context": "แสดงบริบท",
|
||||
"Hide context": "ซ่อนบริบท",
|
||||
"Decrease font size": "ลดขนาดตัวอักษร",
|
||||
"Increase font size": "เพิ่มขนาดตัวอักษร",
|
||||
"Focus": "โฟกัส",
|
||||
"Theme color": "สีธีม",
|
||||
"Import failed": "นำเข้าล้มเหลว",
|
||||
"Available Formatters:": "ตัวจัดรูปแบบที่ใช้ได้:",
|
||||
"Format date": "จัดรูปแบบวันที่",
|
||||
"Markdown block quote (> per line)": "บล็อกอ้างอิง Markdown (> ต่อบรรทัด)",
|
||||
"Newlines to <br>": "ขึ้นบรรทัดใหม่เป็น <br>",
|
||||
"Change case": "เปลี่ยนตัวพิมพ์",
|
||||
"Trim whitespace": "ตัดช่องว่าง",
|
||||
"Truncate to n characters": "ตัดให้เหลือ n ตัวอักษร",
|
||||
"Replace text": "แทนที่ข้อความ",
|
||||
"Fallback value": "ค่าสำรอง",
|
||||
"Get length": "ดึงความยาว",
|
||||
"First/last element": "องค์ประกอบแรก/สุดท้าย",
|
||||
"Join array": "รวมอาร์เรย์",
|
||||
"Backup failed: {{error}}": "การสำรองข้อมูลล้มเหลว: {{error}}",
|
||||
"Select Backup": "เลือกข้อมูลสำรอง",
|
||||
"Restore failed: {{error}}": "การกู้คืนล้มเหลว: {{error}}",
|
||||
"Backup & Restore": "สำรองและกู้คืน",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "สร้างข้อมูลสำรองของห้องสมุดหรือกู้คืนจากข้อมูลสำรองก่อนหน้า การกู้คืนจะรวมกับห้องสมุดปัจจุบันของคุณ",
|
||||
"Backup Library": "สำรองห้องสมุด",
|
||||
"Restore Library": "กู้คืนห้องสมุด",
|
||||
"Creating backup...": "กำลังสร้างข้อมูลสำรอง...",
|
||||
"Restoring library...": "กำลังกู้คืนห้องสมุด...",
|
||||
"{{current}} of {{total}} items": "{{current}} จาก {{total}} รายการ",
|
||||
"Backup completed successfully!": "สำรองข้อมูลสำเร็จ!",
|
||||
"Restore completed successfully!": "กู้คืนสำเร็จ!",
|
||||
"Your library has been saved to the selected location.": "ห้องสมุดของคุณถูกบันทึกไปยังตำแหน่งที่เลือก",
|
||||
"{{added}} books added, {{updated}} books updated.": "เพิ่ม {{added}} เล่ม, อัปเดต {{updated}} เล่ม",
|
||||
"Operation failed": "การดำเนินการล้มเหลว",
|
||||
"Loading library...": "กำลังโหลดห้องสมุด...",
|
||||
"{{count}} books refreshed_other": "รีเฟรชแล้ว {{count}} เล่ม",
|
||||
"Failed to refresh metadata": "รีเฟรชข้อมูลเมตาล้มเหลว",
|
||||
"Refresh Metadata": "รีเฟรชข้อมูลเมตา"
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
"Wikipedia": "Vikipedi",
|
||||
"Writing Mode": "Yazma Modu",
|
||||
"Your Library": "Kütüphaneniz",
|
||||
"TTS not supported for PDF": "PDF için TTS desteklenmiyor",
|
||||
"Override Book Font": "Kitap Yazı Tipini Geçersiz Kıl",
|
||||
"Apply to All Books": "Tüm kitaplara uygula",
|
||||
"Apply to This Book": "Bu kitaba uygula",
|
||||
@@ -335,8 +334,8 @@
|
||||
"Fit": "Uygun",
|
||||
"Reset {{settings}}": "{{settings}}'i Sıfırla",
|
||||
"Reset Settings": "Ayarları Sıfırla",
|
||||
"{{count}} pages left in chapter_one": "Bu bölümde {{count}} sayfa kaldı",
|
||||
"{{count}} pages left in chapter_other": "Bu bölümde {{count}} sayfa kaldı",
|
||||
"{{count}} pages left in chapter_one": "<1>Bu bölümde </1><0>{{count}}</0><1> sayfa kaldı</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>Bu bölümde </1><0>{{count}}</0><1> sayfa kaldı</1>",
|
||||
"Show Remaining Pages": "Kalan sayfaları göster",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -599,11 +598,10 @@
|
||||
"Size": "Boyut",
|
||||
"Cover": "Kapak",
|
||||
"Contain": "İçerir",
|
||||
"{{number}} pages left in chapter": "Bu bölümde {{number}} sayfa kaldı",
|
||||
"{{number}} pages left in chapter": "<1>Bu bölümde </1><0>{{number}}</0><1> sayfa kaldı</1>",
|
||||
"Device": "Cihaz",
|
||||
"E-Ink Mode": "E-Ink Modu",
|
||||
"Highlight Colors": "Vurgu Renkleri",
|
||||
"Auto Screen Brightness": "Otomatik Ekran Parlaklığı",
|
||||
"Pagination": "Sayfalama",
|
||||
"Disable Double Tap": "Çift Dokunmayı Devre Dışı Bırak",
|
||||
"Tap to Paginate": "Sayfalamak için Dokunun",
|
||||
@@ -833,6 +831,9 @@
|
||||
"Clear search": "Aramayı temizle",
|
||||
"Clear search history": "Arama geçmişini temizle",
|
||||
"Tap to Toggle Footer": "Altbilgiyi değiştirmek için dokunun",
|
||||
"Show Current Time": "Şu anki saati göster",
|
||||
"Use 24 Hour Clock": "24 Saatlik Saati Kullan",
|
||||
"Show Current Battery Status": "Mevcut Pil Durumunu Göster",
|
||||
"Exported successfully": "Başarıyla dışa aktarıldı",
|
||||
"Book exported successfully.": "Kitap başarıyla dışa aktarıldı.",
|
||||
"Failed to export the book.": "Kitap dışa aktarılamadı.",
|
||||
@@ -976,7 +977,6 @@
|
||||
"Unable to start RSVP": "RSVP başlatılamadı",
|
||||
"RSVP not supported for PDF": "PDF için RSVP desteklenmiyor",
|
||||
"Select Chapter": "Bölüm Seç",
|
||||
"{{number}} WPM": "{{number}} Kelime/Dakika",
|
||||
"Context": "Bağlam",
|
||||
"Ready": "Hazır",
|
||||
"Chapter Progress": "Bölüm İlerlemesi",
|
||||
@@ -990,10 +990,8 @@
|
||||
"Play (Space)": "Oynat (Boşluk)",
|
||||
"Skip forward 15 words": "15 kelime ileri atla",
|
||||
"Forward 15 words (Shift+Right)": "15 kelime ileri (Shift+Sağ)",
|
||||
"Pause:": "Duraklat:",
|
||||
"Decrease speed": "Hızı azalt",
|
||||
"Slower (Left/Down)": "Daha yavaş (Sol/Aşağı)",
|
||||
"Current speed": "Mevcut hız",
|
||||
"Increase speed": "Hızı artır",
|
||||
"Faster (Right/Up)": "Daha hızlı (Sağ/Yukarı)",
|
||||
"Start RSVP Reading": "RSVP Okumasını Başlat",
|
||||
@@ -1031,5 +1029,78 @@
|
||||
"Authors": "Yazarlar",
|
||||
"Books": "Kitaplar",
|
||||
"Groups": "Gruplar",
|
||||
"Back to TTS Location": "TTS Konumuna Geri Dön"
|
||||
"Back to TTS Location": "TTS Konumuna Geri Dön",
|
||||
"Metadata": "Meta veriler",
|
||||
"Image viewer": "Resim görüntüleyici",
|
||||
"Previous Image": "Önceki Resim",
|
||||
"Next Image": "Sonraki Resim",
|
||||
"Zoomed": "Yakınlaştırıldı",
|
||||
"Zoom level": "Yakınlaştırma düzeyi",
|
||||
"Table viewer": "Tablo görüntüleyici",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Readwise'a bağlanılamadı. Lütfen ağ bağlantınızı kontrol edin.",
|
||||
"Invalid Readwise access token": "Geçersiz Readwise erişim kodu",
|
||||
"Disconnected from Readwise": "Readwise bağlantısı kesildi",
|
||||
"Never": "Asla",
|
||||
"Readwise Settings": "Readwise Ayarları",
|
||||
"Connected to Readwise": "Readwise'a Bağlandı",
|
||||
"Last synced: {{time}}": "Son senkronizasyon: {{time}}",
|
||||
"Sync Enabled": "Senkronizasyon Etkin",
|
||||
"Disconnect": "Bağlantıyı Kes",
|
||||
"Connect your Readwise account to sync highlights.": "Vurguları senkronize etmek için Readwise hesabınızı bağlayın.",
|
||||
"Get your access token at": "Erişim kodunuzu şuradan alın:",
|
||||
"Access Token": "Erişim Kodu",
|
||||
"Paste your Readwise access token": "Readwise erişim kodunuzu yapıştırın",
|
||||
"Config": "Yapılandırma",
|
||||
"Readwise Sync": "Readwise Senkronizasyonu",
|
||||
"Push Highlights": "Vurguları Gönder",
|
||||
"Highlights synced to Readwise": "Vurgular Readwise ile senkronize edildi",
|
||||
"Readwise sync failed: no internet connection": "Readwise senkronizasyonu başarısız: internet bağlantısı yok",
|
||||
"Readwise sync failed: {{error}}": "Readwise senkronizasyonu başarısız: {{error}}",
|
||||
"System Screen Brightness": "Sistem Ekran Parlaklığı",
|
||||
"Page:": "Sayfa:",
|
||||
"Page: {{number}}": "Sayfa: {{number}}",
|
||||
"Annotation page number": "Açıklama sayfa numarası",
|
||||
"Translating...": "Çevriliyor...",
|
||||
"Show Battery Percentage": "Pil Yüzdesini Göster",
|
||||
"Hide Scrollbar": "Kaydırma çubuğunu gizle",
|
||||
"Skip to last reading position": "Son okuma konumuna atla",
|
||||
"Show context": "Bağlamı göster",
|
||||
"Hide context": "Bağlamı gizle",
|
||||
"Decrease font size": "Yazı tipi boyutunu küçült",
|
||||
"Increase font size": "Yazı tipi boyutunu büyüt",
|
||||
"Focus": "Odak",
|
||||
"Theme color": "Tema rengi",
|
||||
"Import failed": "İçe aktarma başarısız",
|
||||
"Available Formatters:": "Kullanılabilir Biçimlendiriciler:",
|
||||
"Format date": "Tarih biçimlendir",
|
||||
"Markdown block quote (> per line)": "Markdown blok alıntı (satır başına >)",
|
||||
"Newlines to <br>": "Satır sonlarını <br> yap",
|
||||
"Change case": "Büyük/küçük harf değiştir",
|
||||
"Trim whitespace": "Boşlukları kırp",
|
||||
"Truncate to n characters": "n karaktere kısalt",
|
||||
"Replace text": "Metni değiştir",
|
||||
"Fallback value": "Yedek değer",
|
||||
"Get length": "Uzunluk al",
|
||||
"First/last element": "İlk/son öğe",
|
||||
"Join array": "Diziyi birleştir",
|
||||
"Backup failed: {{error}}": "Yedekleme başarısız: {{error}}",
|
||||
"Select Backup": "Yedek Seç",
|
||||
"Restore failed: {{error}}": "Geri yükleme başarısız: {{error}}",
|
||||
"Backup & Restore": "Yedekleme & Geri Yükleme",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Kütüphanenizin yedeğini oluşturun veya önceki bir yedekten geri yükleyin. Geri yükleme mevcut kütüphanenizle birleştirilecektir.",
|
||||
"Backup Library": "Kütüphaneyi Yedekle",
|
||||
"Restore Library": "Kütüphaneyi Geri Yükle",
|
||||
"Creating backup...": "Yedek oluşturuluyor...",
|
||||
"Restoring library...": "Kütüphane geri yükleniyor...",
|
||||
"{{current}} of {{total}} items": "{{current}} / {{total}} öğe",
|
||||
"Backup completed successfully!": "Yedekleme başarıyla tamamlandı!",
|
||||
"Restore completed successfully!": "Geri yükleme başarıyla tamamlandı!",
|
||||
"Your library has been saved to the selected location.": "Kütüphaneniz seçilen konuma kaydedildi.",
|
||||
"{{added}} books added, {{updated}} books updated.": "{{added}} kitap eklendi, {{updated}} kitap güncellendi.",
|
||||
"Operation failed": "İşlem başarısız",
|
||||
"Loading library...": "Kütüphane yükleniyor...",
|
||||
"{{count}} books refreshed_one": "{{count}} kitap yenilendi",
|
||||
"{{count}} books refreshed_other": "{{count}} kitap yenilendi",
|
||||
"Failed to refresh metadata": "Meta veri yenileme başarısız",
|
||||
"Refresh Metadata": "Meta Verileri Yenile"
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
"Wikipedia": "Вікіпедія",
|
||||
"Writing Mode": "Режим письма",
|
||||
"Your Library": "Ваша бібліотека",
|
||||
"TTS not supported for PDF": "TTS не підтримується для PDF",
|
||||
"Override Book Font": "Перевизначити шрифт книги",
|
||||
"Apply to All Books": "Застосувати до всіх книг",
|
||||
"Apply to This Book": "Застосувати лише до цієї книги",
|
||||
@@ -341,10 +340,10 @@
|
||||
"Fit": "Припасувати",
|
||||
"Reset {{settings}}": "Скинути {{settings}}",
|
||||
"Reset Settings": "Скинути налаштування",
|
||||
"{{count}} pages left in chapter_one": "Залишилася {{count}} сторінка у розділі",
|
||||
"{{count}} pages left in chapter_few": "Залишилось {{count}} сторінки у розділі",
|
||||
"{{count}} pages left in chapter_many": "Залишилось {{count}} сторінок у розділі",
|
||||
"{{count}} pages left in chapter_other": "Залишилось {{count}} сторінок у розділі",
|
||||
"{{count}} pages left in chapter_one": "<1>Залишилася </1><0>{{count}}</0><1> сторінка у розділі</1>",
|
||||
"{{count}} pages left in chapter_few": "<1>Залишилось </1><0>{{count}}</0><1> сторінки у розділі</1>",
|
||||
"{{count}} pages left in chapter_many": "<1>Залишилось </1><0>{{count}}</0><1> сторінок у розділі</1>",
|
||||
"{{count}} pages left in chapter_other": "<1>Залишилось </1><0>{{count}}</0><1> сторінок у розділі</1>",
|
||||
"Show Remaining Pages": "Показати залишок сторінок",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -565,14 +564,14 @@
|
||||
"Minimize": "Згорнути",
|
||||
"Maximize or Restore": "Збільшити або відновити",
|
||||
"Exit Parallel Read": "Вийти з паралельного читання",
|
||||
"Enter Parallel Read": "Войти в параллельное чтение",
|
||||
"Enter Parallel Read": "Ввійти в паралельне читання",
|
||||
"Zoom Level": "Рівень масштабування",
|
||||
"Zoom Out": "Зменшити масштаб",
|
||||
"Reset Zoom": "Скинути масштаб",
|
||||
"Zoom In": "Збільшити масштаб",
|
||||
"Zoom Mode": "Режим масштабування",
|
||||
"Single Page": "Одна сторінка",
|
||||
"Auto Spread": "Автоматичне розповсюдження",
|
||||
"Auto Spread": "Автоматичне розтягування",
|
||||
"Fit Page": "Підганяти під сторінку",
|
||||
"Fit Width": "Підганяти під ширину",
|
||||
"Failed to select directory": "Не вдалося вибрати каталог",
|
||||
@@ -589,36 +588,35 @@
|
||||
"Copying: {{file}}": "Копіювання: {{file}}",
|
||||
"{{current}} of {{total}} files": "{{current}} з {{total}} файлів",
|
||||
"Migration completed successfully!": "Переміщення завершено успішно!",
|
||||
"Your data has been moved to the new location. Please restart the application to complete the process.": "Ваші дані були переміщені до нового розташування. Будь ласка, перезапустіть програму, щоб завершити процес.",
|
||||
"Your data has been moved to the new location. Please restart the application to complete the process.": "Ваші дані були переміщені до нового розташування. Будь ласка, перезапустіть застосунок, щоб завершити процес.",
|
||||
"Migration failed": "Переміщення не вдалося",
|
||||
"Important Notice": "Важливе повідомлення",
|
||||
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Це перемістить всі ваші дані програми до нового розташування. Переконайтеся, що в призначенні достатньо вільного місця.",
|
||||
"Restart App": "Перезапустити програму",
|
||||
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Це перемістить всі ваші дані до нового розташування. Переконайтеся, що у вибраному розташуванні достатньо вільного місця.",
|
||||
"Restart App": "Перезапустити застосунок",
|
||||
"Start Migration": "Почати переміщення",
|
||||
"Advanced Settings": "Розширені налаштування",
|
||||
"File count: {{size}}": "Кількість файлів: {{size}}",
|
||||
"Background Read Aloud": "Озвучування у фоновому режимі",
|
||||
"Ready to read aloud": "Готовий до читання вголос",
|
||||
"Ready to read aloud": "Готовність до читання вголос",
|
||||
"Read Aloud": "Читати вголос",
|
||||
"Screen Brightness": "Яскравість екрану",
|
||||
"Background Image": "Фонове зображення",
|
||||
"Import Image": "Імпортувати зображення",
|
||||
"Opacity": "Непрозорість",
|
||||
"Size": "Розмір",
|
||||
"Cover": "Покрити",
|
||||
"Cover": "Перекрити",
|
||||
"Contain": "Втиснути",
|
||||
"{{number}} pages left in chapter": "Залишилось {{number}} сторінок у розділі",
|
||||
"{{number}} pages left in chapter": "<1>Залишилось </1><0>{{number}}</0><1> сторінок у розділі</1>",
|
||||
"Device": "Пристрій",
|
||||
"E-Ink Mode": "E-Ink режим",
|
||||
"Highlight Colors": "Кольори виділення",
|
||||
"Auto Screen Brightness": "Автоматична яскравість екрану",
|
||||
"Pagination": "Розбиття на сторінки",
|
||||
"Disable Double Tap": "Вимкнути подвійне натискання",
|
||||
"Tap to Paginate": "Натисніть, щоб розбити на сторінки",
|
||||
"Click to Paginate": "Клікніть, щоб розбити на сторінки",
|
||||
"Tap Both Sides": "Натисніть обидві сторони",
|
||||
"Click Both Sides": "Клікніть обидві сторони",
|
||||
"Swap Tap Sides": "Змінити сторони дотику",
|
||||
"Tap Both Sides": "Натискати на обидві сторони",
|
||||
"Click Both Sides": "Клікати на обидві сторони",
|
||||
"Swap Tap Sides": "Змінити сторони натискання",
|
||||
"Swap Click Sides": "Змінити сторони кліку",
|
||||
"Source and Translated": "Джерело та переклад",
|
||||
"Translated Only": "Тільки переклад",
|
||||
@@ -637,7 +635,7 @@
|
||||
"Unlock All Customization Options": "Відкрити всі параметри налаштування",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Відкрийте додаткові теми, шрифти, параметри макета та функції читання вголос, перекладачі, послуги хмарного зберігання.",
|
||||
"Purchase Successful!": "Покупка успішна!",
|
||||
"lifetime": "довічний",
|
||||
"lifetime": "на все життя",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Дякуємо за вашу покупку! Ваш платіж успішно оброблено.",
|
||||
"Order ID:": "ID замовлення:",
|
||||
"TTS Highlighting": "Підсвічування TTS",
|
||||
@@ -718,8 +716,8 @@
|
||||
"Sample": "Зразок",
|
||||
"Download": "Завантажити",
|
||||
"Open & Read": "Відкрити та читати",
|
||||
"Tags": "Теги",
|
||||
"Tag": "Тег",
|
||||
"Tags": "Теґи",
|
||||
"Tag": "Теґ",
|
||||
"First": "Перша",
|
||||
"Previous": "Попередня",
|
||||
"Next": "Наступна",
|
||||
@@ -728,7 +726,7 @@
|
||||
"An error occurred": "Сталася помилка",
|
||||
"Online Library": "Онлайн бібліотека",
|
||||
"URL must start with http:// or https://": "URL повинен починатися з http:// або https://",
|
||||
"Title, Author, Tag, etc...": "Назва, автор, тег тощо...",
|
||||
"Title, Author, Tag, etc...": "Назва, автор, теґ тощо...",
|
||||
"Query": "Запит",
|
||||
"Subject": "Тема",
|
||||
"Enter {{terms}}": "Введіть {{terms}}",
|
||||
@@ -756,8 +754,8 @@
|
||||
"Oldest First": "Спершу старіші",
|
||||
"Largest First": "Спершу найбільші",
|
||||
"Smallest First": "Спершу найменші",
|
||||
"Name A-Z": "Ім'я A-Z",
|
||||
"Name Z-A": "Ім'я Z-A",
|
||||
"Name A-Z": "Ім'я А-Я",
|
||||
"Name Z-A": "Ім'я А-Я",
|
||||
"{{count}} selected_one": "Вибрано {{count}} файл",
|
||||
"{{count}} selected_few": "Вибрано {{count}} файли",
|
||||
"{{count}} selected_many": "Вибрано {{count}} файлів",
|
||||
@@ -777,21 +775,21 @@
|
||||
"From Directory": "З каталогу",
|
||||
"Successfully imported {{count}} book(s)_one": "Успішно імпортовано 1 книгу",
|
||||
"Successfully imported {{count}} book(s)_few": "Успішно імпортовано {{count}} книги",
|
||||
"Successfully imported {{count}} book(s)_many": "Успішно імпортовано {{count}} книг",
|
||||
"Successfully imported {{count}} book(s)_other": "Успішно імпортовано {{count}} книг",
|
||||
"Successfully imported {{count}} book(s)_many": "Успішно імпортовано {{count}} книги",
|
||||
"Successfully imported {{count}} book(s)_other": "Успішно імпортовано {{count}} книги",
|
||||
"Count": "Кількість",
|
||||
"Start Page": "Початкова сторінка",
|
||||
"Search in OPDS Catalog...": "Пошук у каталозі OPDS...",
|
||||
"Please log in to use advanced TTS features": "Будь ласка, увійдіть, щоб використовувати розширені функції TTS",
|
||||
"Word limit of 30 words exceeded.": "Перевищено ліміт у 30 слів.",
|
||||
"Proofread": "Вичитування",
|
||||
"Proofread": "Вичитка",
|
||||
"Current selection": "Поточне виділення",
|
||||
"All occurrences in this book": "Усі входження в цій книзі",
|
||||
"All occurrences in your library": "Усі входження у вашій бібліотеці",
|
||||
"All occurrences in this book": "Усі випадки в цій книзі",
|
||||
"All occurrences in your library": "Усі випадки у вашій бібліотеці",
|
||||
"Selected text:": "Виділений текст:",
|
||||
"Replace with:": "Замінити на:",
|
||||
"Enter text...": "Введіть текст…",
|
||||
"Case sensitive:": "З урахуванням регістру:",
|
||||
"Case sensitive:": "З урахуванням реґістру:",
|
||||
"Scope:": "Область застосування:",
|
||||
"Selection": "Виділення",
|
||||
"Library": "Бібліотека",
|
||||
@@ -804,7 +802,7 @@
|
||||
"No book-level replacement rules": "Немає правил заміни на рівні книги",
|
||||
"Disable Quick Action": "Вимкнути швидку дію",
|
||||
"Enable Quick Action on Selection": "Увімкнути швидку дію при виборі",
|
||||
"None": "Жоден",
|
||||
"None": "Нема",
|
||||
"Annotation Tools": "Інструменти анотацій",
|
||||
"Enable Quick Actions": "Увімкнути швидкі дії",
|
||||
"Quick Action": "Швидка дія",
|
||||
@@ -852,7 +850,10 @@
|
||||
"Show Results": "Показати результати",
|
||||
"Clear search": "Очистити пошук",
|
||||
"Clear search history": "Очистити історію пошуку",
|
||||
"Tap to Toggle Footer": "Торкніться, щоб перемкнути нижній колонтитул",
|
||||
"Tap to Toggle Footer": "Нижній колонтитул",
|
||||
"Show Current Time": "Показати поточний час",
|
||||
"Use 24 Hour Clock": "Використовувати 24-годинний формат",
|
||||
"Show Current Battery Status": "Показати поточний стан батареї",
|
||||
"Exported successfully": "Успішно експортовано",
|
||||
"Book exported successfully.": "Книгу успішно експортовано.",
|
||||
"Failed to export the book.": "Не вдалося експортувати книгу.",
|
||||
@@ -909,8 +910,8 @@
|
||||
"Toggle Sticky Bottom TTS Bar": "Перемкнути закріплену панель TTS",
|
||||
"Display what I'm reading on Discord": "Показувати книгу в Discord",
|
||||
"Show on Discord": "Показати в Discord",
|
||||
"Instant {{action}}": "Миттєва {{action}}",
|
||||
"Instant {{action}} Disabled": "Миттєву {{action}} вимкнено",
|
||||
"Instant {{action}}": "Швидка дія: {{action}}",
|
||||
"Instant {{action}} Disabled": "Миттєву дію {{action}} вимкнено",
|
||||
"Annotation": "Анотація",
|
||||
"Reset Template": "Скинути шаблон",
|
||||
"Annotation style": "Стиль анотації",
|
||||
@@ -933,7 +934,7 @@
|
||||
"Please enter a model ID": "Будь ласка, введіть ID моделі",
|
||||
"Model not available or invalid": "Модель недоступна або недійсна",
|
||||
"Failed to validate model": "Не вдалося перевірити модель",
|
||||
"Couldn't connect to Ollama. Is it running?": "Не вдалося підключитися до Ollama. Чи вона запущена?",
|
||||
"Couldn't connect to Ollama. Is it running?": "Не вдалося підключитися до Ollama. Вона запущена?",
|
||||
"Invalid API key or connection failed": "Недійсний API-ключ або помилка підключення",
|
||||
"Connection failed": "Помилка підключення",
|
||||
"AI Assistant": "ШІ-асистент",
|
||||
@@ -946,7 +947,7 @@
|
||||
"AI Model": "ШІ-модель",
|
||||
"No models detected": "Моделі не знайдено",
|
||||
"AI Gateway Configuration": "Налаштування ШІ-шлюзу",
|
||||
"Choose from a selection of high-quality, economical AI models. You can also bring your own model by selecting \"Custom Model\" below.": "Оберіть з високоякісних, економічних ШІ-моделей. Ви також можете використати власну модель, обравши \"Користувацька модель\" нижче.",
|
||||
"Choose from a selection of high-quality, economical AI models. You can also bring your own model by selecting \"Custom Model\" below.": "Оберіть з високоякісних або економних ШІ-моделей. Ви також можете використати власну модель, обравши \"Користувацька модель\" нижче.",
|
||||
"API Key": "API-ключ",
|
||||
"Get Key": "Отримати ключ",
|
||||
"Model": "Модель",
|
||||
@@ -962,7 +963,7 @@
|
||||
"Reading Ruler": "Лінійка для читання",
|
||||
"Enable Reading Ruler": "Увімкнути лінійку для читання",
|
||||
"Lines to Highlight": "Рядки для виділення",
|
||||
"Ruler Color": "Коліර් лінійки",
|
||||
"Ruler Color": "Колір лінійки",
|
||||
"Command Palette": "Палітра команд",
|
||||
"Search settings and actions...": "Пошук налаштувань та дій...",
|
||||
"No results found for": "Результатів не знайдено для",
|
||||
@@ -976,7 +977,7 @@
|
||||
"AI Provider": "Провайдер ШІ",
|
||||
"Ollama URL": "URL Ollama",
|
||||
"Ollama Model": "Модель Ollama",
|
||||
"AI Gateway Model": "Модель AI Gateway",
|
||||
"AI Gateway Model": "Модель ШІ-шлюзу",
|
||||
"Actions": "Дії",
|
||||
"Navigation": "Навігація",
|
||||
"Set status for {{count}} book(s)_one": "Встановити статус для {{count}} книги",
|
||||
@@ -1000,7 +1001,6 @@
|
||||
"Unable to start RSVP": "Не вдалося запустити RSVP",
|
||||
"RSVP not supported for PDF": "RSVP не підтримується для PDF",
|
||||
"Select Chapter": "Вибрати розділ",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Контекст",
|
||||
"Ready": "Готово",
|
||||
"Chapter Progress": "Прогрес розділу",
|
||||
@@ -1009,17 +1009,15 @@
|
||||
"Reading progress": "Прогрес читання",
|
||||
"Click to seek": "Натисніть для пошуку",
|
||||
"Skip back 15 words": "Назад на 15 слів",
|
||||
"Back 15 words (Shift+Left)": "Назад на 15 слів (Shift+Вліво)",
|
||||
"Back 15 words (Shift+Left)": "Назад на 15 слів (Shift+Стрілка вліво)",
|
||||
"Pause (Space)": "Пауза (Пробіл)",
|
||||
"Play (Space)": "Відтворити (Пробіл)",
|
||||
"Skip forward 15 words": "Вперед на 15 слів",
|
||||
"Forward 15 words (Shift+Right)": "Вперед на 15 слів (Shift+Вправо)",
|
||||
"Pause:": "Пауза:",
|
||||
"Forward 15 words (Shift+Right)": "Вперед на 15 слів (Shift+Стрілка вправо)",
|
||||
"Decrease speed": "Зменшити швидкість",
|
||||
"Slower (Left/Down)": "Повільніше (Вліво/Вниз)",
|
||||
"Current speed": "Поточна швидкість",
|
||||
"Slower (Left/Down)": "Повільніше (стрілки Вліво/Вниз)",
|
||||
"Increase speed": "Збільшити швидкість",
|
||||
"Faster (Right/Up)": "Швидше (Вправо/Вгору)",
|
||||
"Faster (Right/Up)": "Швидше (стрілки Вправо/Вгору)",
|
||||
"Start RSVP Reading": "Почати читання RSVP",
|
||||
"Choose where to start reading": "Виберіть, де почати читання",
|
||||
"From Chapter Start": "З початку розділу",
|
||||
@@ -1055,5 +1053,80 @@
|
||||
"Authors": "Автори",
|
||||
"Books": "Книги",
|
||||
"Groups": "Групи",
|
||||
"Back to TTS Location": "Повернутися до розташування TTS"
|
||||
"Back to TTS Location": "Повернутися до розташування TTS",
|
||||
"Metadata": "Метадані",
|
||||
"Image viewer": "Переглядач зображень",
|
||||
"Previous Image": "Попереднє зображення",
|
||||
"Next Image": "Наступне зображення",
|
||||
"Zoomed": "Збільшено",
|
||||
"Zoom level": "Рівень масштабу",
|
||||
"Table viewer": "Переглядач таблиць",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Не вдалося підключитися до Readwise. Будь ласка, перевірте мережеве з'єднання.",
|
||||
"Invalid Readwise access token": "Недійсний токен доступу Readwise",
|
||||
"Disconnected from Readwise": "Відключено від Readwise",
|
||||
"Never": "Ніколи",
|
||||
"Readwise Settings": "Налаштування Readwise",
|
||||
"Connected to Readwise": "Підключено до Readwise",
|
||||
"Last synced: {{time}}": "Остання синхронізація: {{time}}",
|
||||
"Sync Enabled": "Синхронізацію увімкнено",
|
||||
"Disconnect": "Відключити",
|
||||
"Connect your Readwise account to sync highlights.": "Підключіть свій обліковий запис Readwise, щоб синхронізувати виділення.",
|
||||
"Get your access token at": "Отримайте токен доступу за адресою",
|
||||
"Access Token": "Токен доступу",
|
||||
"Paste your Readwise access token": "Вставте ваш токен доступу Readwise",
|
||||
"Config": "Конфігурація",
|
||||
"Readwise Sync": "Синхронізація Readwise",
|
||||
"Push Highlights": "Надіслати виділення",
|
||||
"Highlights synced to Readwise": "Виділення синхронізовано з Readwise",
|
||||
"Readwise sync failed: no internet connection": "Синхронізація Readwise не вдалася: немає інтернет-з'єднання",
|
||||
"Readwise sync failed: {{error}}": "Синхронізація Readwise не вдалася: {{error}}",
|
||||
"System Screen Brightness": "Системна яскравість екрана",
|
||||
"Page:": "Сторінка:",
|
||||
"Page: {{number}}": "Сторінка: {{number}}",
|
||||
"Annotation page number": "Номер сторінки анотації",
|
||||
"Translating...": "Переклад...",
|
||||
"Show Battery Percentage": "Показувати відсоток заряду батареї",
|
||||
"Hide Scrollbar": "Сховати смугу прокрутки",
|
||||
"Skip to last reading position": "Перейти до останньої позиції читання",
|
||||
"Show context": "Показати контекст",
|
||||
"Hide context": "Сховати контекст",
|
||||
"Decrease font size": "Зменшити розмір шрифту",
|
||||
"Increase font size": "Збільшити розмір шрифту",
|
||||
"Focus": "Фокус",
|
||||
"Theme color": "Колір теми",
|
||||
"Import failed": "Помилка імпорту",
|
||||
"Available Formatters:": "Доступні форматери:",
|
||||
"Format date": "Форматувати дату",
|
||||
"Markdown block quote (> per line)": "Цитата Markdown (> для кожного рядка)",
|
||||
"Newlines to <br>": "Переноси рядків у <br>",
|
||||
"Change case": "Змінити регістр",
|
||||
"Trim whitespace": "Видалити пробіли",
|
||||
"Truncate to n characters": "Обрізати до n символів",
|
||||
"Replace text": "Замінити текст",
|
||||
"Fallback value": "Значення за замовчуванням",
|
||||
"Get length": "Отримати довжину",
|
||||
"First/last element": "Перший/останній елемент",
|
||||
"Join array": "Об'єднати масив",
|
||||
"Backup failed: {{error}}": "Помилка резервного копіювання: {{error}}",
|
||||
"Select Backup": "Обрати резервну копію",
|
||||
"Restore failed: {{error}}": "Помилка відновлення: {{error}}",
|
||||
"Backup & Restore": "Резервне копіювання та відновлення",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Створіть резервну копію бібліотеки або відновіть з попередньої копії. Відновлення буде об'єднано з поточною бібліотекою.",
|
||||
"Backup Library": "Резервна копія бібліотеки",
|
||||
"Restore Library": "Відновити бібліотеку",
|
||||
"Creating backup...": "Створення резервної копії...",
|
||||
"Restoring library...": "Відновлення бібліотеки...",
|
||||
"{{current}} of {{total}} items": "{{current}} з {{total}} елементів",
|
||||
"Backup completed successfully!": "Резервне копіювання завершено!",
|
||||
"Restore completed successfully!": "Відновлення завершено!",
|
||||
"Your library has been saved to the selected location.": "Вашу бібліотеку збережено у вибраному місці.",
|
||||
"{{added}} books added, {{updated}} books updated.": "Додано {{added}} книг, оновлено {{updated}} книг.",
|
||||
"Operation failed": "Операція не вдалася",
|
||||
"Loading library...": "Завантаження бібліотеки...",
|
||||
"{{count}} books refreshed_one": "Оновлено {{count}} книгу",
|
||||
"{{count}} books refreshed_few": "Оновлено {{count}} книги",
|
||||
"{{count}} books refreshed_many": "Оновлено {{count}} книг",
|
||||
"{{count}} books refreshed_other": "Оновлено {{count}} книг",
|
||||
"Failed to refresh metadata": "Не вдалося оновити метадані",
|
||||
"Refresh Metadata": "Оновити метадані"
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
"Wikipedia": "Wikipedia",
|
||||
"Writing Mode": "Chế độ viết",
|
||||
"Your Library": "Thư viện của bạn",
|
||||
"TTS not supported for PDF": "TTS không được hỗ trợ cho PDF",
|
||||
"Override Book Font": "Ghi đè phông chữ sách",
|
||||
"Apply to All Books": "Áp dụng cho tất cả sách",
|
||||
"Apply to This Book": "Áp dụng cho sách này",
|
||||
@@ -332,7 +331,7 @@
|
||||
"Fit": "Vừa vặn",
|
||||
"Reset {{settings}}": "Đặt lại {{settings}}",
|
||||
"Reset Settings": "Đặt lại cài đặt",
|
||||
"{{count}} pages left in chapter_other": "Còn {{count}} trang trong chương",
|
||||
"{{count}} pages left in chapter_other": "<1>Còn </1><0>{{count}}</0><1> trang trong chương</1>",
|
||||
"Show Remaining Pages": "Hiển thị trang còn lại",
|
||||
"Source Han Serif CN": "Source Han Serif",
|
||||
"Huiwen-MinchoGBK": "Huiwen Mincho",
|
||||
@@ -595,11 +594,10 @@
|
||||
"Size": "Kích thước",
|
||||
"Cover": "Bìa",
|
||||
"Contain": "Chứa",
|
||||
"{{number}} pages left in chapter": "Còn {{number}} trang trong chương",
|
||||
"{{number}} pages left in chapter": "<1>Còn </1><0>{{number}}</0><1> trang trong chương</1>",
|
||||
"Device": "Thiết bị",
|
||||
"E-Ink Mode": "Chế độ E-Ink",
|
||||
"Highlight Colors": "Màu nổi bật",
|
||||
"Auto Screen Brightness": "Độ sáng màn hình tự động",
|
||||
"Pagination": "Phân trang",
|
||||
"Disable Double Tap": "Vô hiệu hóa chạm hai lần",
|
||||
"Tap to Paginate": "Chạm để phân trang",
|
||||
@@ -823,6 +821,9 @@
|
||||
"Clear search": "Xóa tìm kiếm",
|
||||
"Clear search history": "Xóa lịch sử tìm kiếm",
|
||||
"Tap to Toggle Footer": "Nhấn để bật/tắt chân trang",
|
||||
"Show Current Time": "Hiển thị thời gian hiện tại",
|
||||
"Use 24 Hour Clock": "Sử dụng định dạng 24 giờ",
|
||||
"Show Current Battery Status": "Hiển thị trạng thái pin hiện tại",
|
||||
"Exported successfully": "Xuất thành công",
|
||||
"Book exported successfully.": "Sách đã được xuất thành công.",
|
||||
"Failed to export the book.": "Xuất sách thất bại.",
|
||||
@@ -964,7 +965,6 @@
|
||||
"Unable to start RSVP": "Không thể bắt đầu RSVP",
|
||||
"RSVP not supported for PDF": "RSVP không được hỗ trợ cho PDF",
|
||||
"Select Chapter": "Chọn chương",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Ngữ cảnh",
|
||||
"Ready": "Sẵn sàng",
|
||||
"Chapter Progress": "Tiến độ chương",
|
||||
@@ -978,10 +978,8 @@
|
||||
"Play (Space)": "Phát (Khoảng trắng)",
|
||||
"Skip forward 15 words": "Tiến tới 15 từ",
|
||||
"Forward 15 words (Shift+Right)": "Tiến tới 15 từ (Shift+Phải)",
|
||||
"Pause:": "Tạm dừng:",
|
||||
"Decrease speed": "Giảm tốc độ",
|
||||
"Slower (Left/Down)": "Chậm hơn (Trái/Xuống)",
|
||||
"Current speed": "Tốc độ hiện tại",
|
||||
"Increase speed": "Tăng tốc độ",
|
||||
"Faster (Right/Up)": "Nhanh hơn (Phải/Lên)",
|
||||
"Start RSVP Reading": "Bắt đầu đọc RSVP",
|
||||
@@ -1019,5 +1017,77 @@
|
||||
"Authors": "Tác giả",
|
||||
"Books": "Sách",
|
||||
"Groups": "Nhóm",
|
||||
"Back to TTS Location": "Quay lại vị trí TTS"
|
||||
"Back to TTS Location": "Quay lại vị trí TTS",
|
||||
"Metadata": "Siêu dữ liệu",
|
||||
"Image viewer": "Trình xem ảnh",
|
||||
"Previous Image": "Ảnh trước",
|
||||
"Next Image": "Ảnh sau",
|
||||
"Zoomed": "Đã thu phóng",
|
||||
"Zoom level": "Mức thu phóng",
|
||||
"Table viewer": "Trình xem bảng",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "Không thể kết nối với Readwise. Vui lòng kiểm tra kết nối mạng của bạn.",
|
||||
"Invalid Readwise access token": "Mã xác thực Readwise không hợp lệ",
|
||||
"Disconnected from Readwise": "Đã ngắt kết nối với Readwise",
|
||||
"Never": "Không bao giờ",
|
||||
"Readwise Settings": "Cài đặt Readwise",
|
||||
"Connected to Readwise": "Đã kết nối với Readwise",
|
||||
"Last synced: {{time}}": "Lần đồng bộ cuối: {{time}}",
|
||||
"Sync Enabled": "Đã bật đồng bộ",
|
||||
"Disconnect": "Ngắt kết nối",
|
||||
"Connect your Readwise account to sync highlights.": "Kết nối tài khoản Readwise của bạn để đồng bộ các phần đánh dấu.",
|
||||
"Get your access token at": "Lấy mã xác thực tại",
|
||||
"Access Token": "Mã xác thực",
|
||||
"Paste your Readwise access token": "Dán mã xác thực Readwise của bạn vào đây",
|
||||
"Config": "Cấu hình",
|
||||
"Readwise Sync": "Đồng bộ Readwise",
|
||||
"Push Highlights": "Đẩy các phần đánh dấu",
|
||||
"Highlights synced to Readwise": "Các phần đánh dấu đã được đồng bộ với Readwise",
|
||||
"Readwise sync failed: no internet connection": "Đồng bộ Readwise thất bại: không có kết nối internet",
|
||||
"Readwise sync failed: {{error}}": "Đồng bộ Readwise thất bại: {{error}}",
|
||||
"System Screen Brightness": "Độ sáng màn hình hệ thống",
|
||||
"Page:": "Trang:",
|
||||
"Page: {{number}}": "Trang: {{number}}",
|
||||
"Annotation page number": "Số trang chú thích",
|
||||
"Translating...": "Đang dịch...",
|
||||
"Show Battery Percentage": "Hiển thị phần trăm pin",
|
||||
"Hide Scrollbar": "Ẩn thanh cuộn",
|
||||
"Skip to last reading position": "Chuyển đến vị trí đọc cuối",
|
||||
"Show context": "Hiển thị ngữ cảnh",
|
||||
"Hide context": "Ẩn ngữ cảnh",
|
||||
"Decrease font size": "Giảm cỡ chữ",
|
||||
"Increase font size": "Tăng cỡ chữ",
|
||||
"Focus": "Tiêu điểm",
|
||||
"Theme color": "Màu chủ đề",
|
||||
"Import failed": "Nhập thất bại",
|
||||
"Available Formatters:": "Bộ định dạng có sẵn:",
|
||||
"Format date": "Định dạng ngày",
|
||||
"Markdown block quote (> per line)": "Trích dẫn khối Markdown (> mỗi dòng)",
|
||||
"Newlines to <br>": "Xuống dòng thành <br>",
|
||||
"Change case": "Đổi chữ hoa/thường",
|
||||
"Trim whitespace": "Cắt khoảng trắng",
|
||||
"Truncate to n characters": "Cắt ngắn còn n ký tự",
|
||||
"Replace text": "Thay thế văn bản",
|
||||
"Fallback value": "Giá trị dự phòng",
|
||||
"Get length": "Lấy độ dài",
|
||||
"First/last element": "Phần tử đầu/cuối",
|
||||
"Join array": "Nối mảng",
|
||||
"Backup failed: {{error}}": "Sao lưu thất bại: {{error}}",
|
||||
"Select Backup": "Chọn bản sao lưu",
|
||||
"Restore failed: {{error}}": "Khôi phục thất bại: {{error}}",
|
||||
"Backup & Restore": "Sao lưu & Khôi phục",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "Tạo bản sao lưu thư viện hoặc khôi phục từ bản sao lưu trước đó. Khôi phục sẽ hợp nhất với thư viện hiện tại của bạn.",
|
||||
"Backup Library": "Sao lưu thư viện",
|
||||
"Restore Library": "Khôi phục thư viện",
|
||||
"Creating backup...": "Đang tạo bản sao lưu...",
|
||||
"Restoring library...": "Đang khôi phục thư viện...",
|
||||
"{{current}} of {{total}} items": "{{current}} / {{total}} mục",
|
||||
"Backup completed successfully!": "Sao lưu thành công!",
|
||||
"Restore completed successfully!": "Khôi phục thành công!",
|
||||
"Your library has been saved to the selected location.": "Thư viện của bạn đã được lưu tại vị trí đã chọn.",
|
||||
"{{added}} books added, {{updated}} books updated.": "Đã thêm {{added}} sách, cập nhật {{updated}} sách.",
|
||||
"Operation failed": "Thao tác thất bại",
|
||||
"Loading library...": "Đang tải thư viện...",
|
||||
"{{count}} books refreshed_other": "Đã làm mới {{count}} sách",
|
||||
"Failed to refresh metadata": "Không thể làm mới siêu dữ liệu",
|
||||
"Refresh Metadata": "Làm mới siêu dữ liệu"
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"Sans-Serif Font": "无衬线字体",
|
||||
"Save": "保存",
|
||||
"Scrolled Mode": "滚动模式",
|
||||
"Hide Scrollbar": "隐藏滚动条",
|
||||
"Search": "搜索",
|
||||
"Search Books...": "搜索书籍...",
|
||||
"Search...": "搜索...",
|
||||
@@ -106,7 +107,6 @@
|
||||
"Wikipedia": "维基百科",
|
||||
"Writing Mode": "排版模式",
|
||||
"Your Library": "书库",
|
||||
"TTS not supported for PDF": "PDF 文档暂不支持 TTS",
|
||||
"Override Book Font": "覆盖书籍字体",
|
||||
"Apply to All Books": "应用于所有书籍",
|
||||
"Apply to This Book": "应用于此书籍",
|
||||
@@ -332,7 +332,7 @@
|
||||
"Fit": "适应",
|
||||
"Reset {{settings}}": "重置{{settings}}",
|
||||
"Reset Settings": "重置设置",
|
||||
"{{count}} pages left in chapter_other": "本章剩余 {{count}} 页",
|
||||
"{{count}} pages left in chapter_other": "<1>本章剩余 </1><0>{{count}}</0><1> 页</1>",
|
||||
"Show Remaining Pages": "显示剩余页数",
|
||||
"Source Han Serif CN": "思源宋体",
|
||||
"Huiwen-MinchoGBK": "汇文明朝体",
|
||||
@@ -595,11 +595,10 @@
|
||||
"Size": "大小",
|
||||
"Cover": "覆盖",
|
||||
"Contain": "适应",
|
||||
"{{number}} pages left in chapter": "本章剩余{{number}}页",
|
||||
"{{number}} pages left in chapter": "<1>本章剩余</1><0>{{number}}</0><1>页</1>",
|
||||
"Device": "设备",
|
||||
"E-Ink Mode": "E-Ink 模式",
|
||||
"Highlight Colors": "高亮颜色",
|
||||
"Auto Screen Brightness": "自动屏幕亮度",
|
||||
"Pagination": "翻页",
|
||||
"Disable Double Tap": "禁用双击",
|
||||
"Tap to Paginate": "点击翻页",
|
||||
@@ -823,6 +822,9 @@
|
||||
"Clear search": "清除搜索",
|
||||
"Clear search history": "清除搜索历史",
|
||||
"Tap to Toggle Footer": "点击切换页脚",
|
||||
"Show Current Time": "显示当前时间",
|
||||
"Use 24 Hour Clock": "使用 24 小时制",
|
||||
"Show Current Battery Status": "显示当前电量",
|
||||
"Exported successfully": "导出成功",
|
||||
"Book exported successfully.": "书籍导出成功。",
|
||||
"Failed to export the book.": "书籍导出失败。",
|
||||
@@ -964,7 +966,6 @@
|
||||
"Unable to start RSVP": "无法启动 RSVP",
|
||||
"RSVP not supported for PDF": "PDF 不支持 RSVP",
|
||||
"Select Chapter": "选择章节",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "上下文",
|
||||
"Ready": "准备就绪",
|
||||
"Chapter Progress": "章节进度",
|
||||
@@ -978,10 +979,8 @@
|
||||
"Play (Space)": "播放 (空格)",
|
||||
"Skip forward 15 words": "前进 15 词",
|
||||
"Forward 15 words (Shift+Right)": "前进 15 词 (Shift+Right)",
|
||||
"Pause:": "暂停:",
|
||||
"Decrease speed": "减速",
|
||||
"Slower (Left/Down)": "较慢 (左/下)",
|
||||
"Current speed": "当前速度",
|
||||
"Increase speed": "加速",
|
||||
"Faster (Right/Up)": "较快 (右/上)",
|
||||
"Start RSVP Reading": "开始 RSVP 阅读",
|
||||
@@ -1019,5 +1018,76 @@
|
||||
"Authors": "作者",
|
||||
"Books": "书籍",
|
||||
"Groups": "分组",
|
||||
"Back to TTS Location": "回到朗读位置"
|
||||
"Back to TTS Location": "回到朗读位置",
|
||||
"Metadata": "元数据",
|
||||
"Image viewer": "图片查看器",
|
||||
"Previous Image": "上一张图片",
|
||||
"Next Image": "下一张图片",
|
||||
"Zoomed": "已缩放",
|
||||
"Zoom level": "缩放级别",
|
||||
"Table viewer": "表格查看器",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "无法连接到 Readwise。请检查您的网络连接。",
|
||||
"Invalid Readwise access token": "无效的 Readwise 访问令牌",
|
||||
"Disconnected from Readwise": "已断开与 Readwise 的连接",
|
||||
"Never": "从不",
|
||||
"Readwise Settings": "Readwise 设置",
|
||||
"Connected to Readwise": "已连接到 Readwise",
|
||||
"Last synced: {{time}}": "上次同步时间:{{time}}",
|
||||
"Sync Enabled": "同步已启用",
|
||||
"Disconnect": "断开连接",
|
||||
"Connect your Readwise account to sync highlights.": "连接您的 Readwise 账户以同步高亮内容。",
|
||||
"Get your access token at": "获取您的访问令牌:",
|
||||
"Access Token": "访问令牌",
|
||||
"Paste your Readwise access token": "粘贴您的 Readwise 访问令牌",
|
||||
"Config": "配置",
|
||||
"Readwise Sync": "Readwise 同步",
|
||||
"Push Highlights": "推送高亮内容",
|
||||
"Highlights synced to Readwise": "高亮内容已同步到 Readwise",
|
||||
"Readwise sync failed: no internet connection": "Readwise 同步失败:无网络连接",
|
||||
"Readwise sync failed: {{error}}": "Readwise 同步失败:{{error}}",
|
||||
"System Screen Brightness": "系统屏幕亮度",
|
||||
"Page:": "页面:",
|
||||
"Page: {{number}}": "页面: {{number}}",
|
||||
"Annotation page number": "批注页码",
|
||||
"Translating...": "翻译中...",
|
||||
"Show Battery Percentage": "显示电量百分比",
|
||||
"Skip to last reading position": "跳转到上次阅读位置",
|
||||
"Show context": "显示上下文",
|
||||
"Hide context": "隐藏上下文",
|
||||
"Decrease font size": "缩小字号",
|
||||
"Increase font size": "放大字号",
|
||||
"Focus": "焦点",
|
||||
"Theme color": "主题颜色",
|
||||
"Import failed": "导入失败",
|
||||
"Available Formatters:": "可用格式化器:",
|
||||
"Format date": "格式化日期",
|
||||
"Markdown block quote (> per line)": "Markdown 引用块(每行 >)",
|
||||
"Newlines to <br>": "换行转为 <br>",
|
||||
"Change case": "更改大小写",
|
||||
"Trim whitespace": "去除空格",
|
||||
"Truncate to n characters": "截断为 n 个字符",
|
||||
"Replace text": "替换文本",
|
||||
"Fallback value": "默认值",
|
||||
"Get length": "获取长度",
|
||||
"First/last element": "第一个/最后一个元素",
|
||||
"Join array": "合并数组",
|
||||
"Backup failed: {{error}}": "备份失败:{{error}}",
|
||||
"Select Backup": "选择备份",
|
||||
"Restore failed: {{error}}": "恢复失败:{{error}}",
|
||||
"Backup & Restore": "备份与恢复",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "创建书库备份或从之前的备份恢复。恢复将与当前书库合并。",
|
||||
"Backup Library": "备份书库",
|
||||
"Restore Library": "恢复书库",
|
||||
"Creating backup...": "正在创建备份...",
|
||||
"Restoring library...": "正在恢复书库...",
|
||||
"{{current}} of {{total}} items": "{{current}} / {{total}} 项",
|
||||
"Backup completed successfully!": "备份成功!",
|
||||
"Restore completed successfully!": "恢复成功!",
|
||||
"Your library has been saved to the selected location.": "您的书库已保存到所选位置。",
|
||||
"{{added}} books added, {{updated}} books updated.": "新增 {{added}} 本书,更新 {{updated}} 本书。",
|
||||
"Operation failed": "操作失败",
|
||||
"Loading library...": "正在加载书库...",
|
||||
"{{count}} books refreshed_other": "已刷新 {{count}} 本书",
|
||||
"Failed to refresh metadata": "刷新元数据失败",
|
||||
"Refresh Metadata": "刷新元数据"
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
"Wikipedia": "維基百科",
|
||||
"Writing Mode": "排版模式",
|
||||
"Your Library": "書庫",
|
||||
"TTS not supported for PDF": "PDF 文檔暫不支持 TTS",
|
||||
"Override Book Font": "覆蓋書籍字體",
|
||||
"Apply to All Books": "應用於所有書籍",
|
||||
"Apply to This Book": "應用於此書籍",
|
||||
@@ -332,7 +331,7 @@
|
||||
"Fit": "適應",
|
||||
"Reset {{settings}}": "重置{{settings}}",
|
||||
"Reset Settings": "重置設置",
|
||||
"{{count}} pages left in chapter_other": "本章剩餘 {{count}} 頁",
|
||||
"{{count}} pages left in chapter_other": "<1>本章剩餘 </1><0>{{count}}</0><1> 頁</1>",
|
||||
"Show Remaining Pages": "顯示剩餘頁數",
|
||||
"Source Han Serif CN": "思源宋體",
|
||||
"Huiwen-MinchoGBK": "匯文明朝體",
|
||||
@@ -595,11 +594,10 @@
|
||||
"Size": "大小",
|
||||
"Cover": "覆蓋",
|
||||
"Contain": "適應",
|
||||
"{{number}} pages left in chapter": "本章剩餘{{number}}頁",
|
||||
"{{number}} pages left in chapter": "<1>本章剩餘</1><0>{{number}}</0><1>頁</1>",
|
||||
"Device": "設備",
|
||||
"E-Ink Mode": "E-Ink 模式",
|
||||
"Highlight Colors": "高亮顏色",
|
||||
"Auto Screen Brightness": "自動螢幕亮度",
|
||||
"Pagination": "翻頁",
|
||||
"Disable Double Tap": "禁用雙擊",
|
||||
"Tap to Paginate": "點擊翻頁",
|
||||
@@ -823,6 +821,9 @@
|
||||
"Clear search": "清除搜尋",
|
||||
"Clear search history": "清除搜尋歷史",
|
||||
"Tap to Toggle Footer": "點擊切換頁尾",
|
||||
"Show Current Time": "顯示目前時間",
|
||||
"Use 24 Hour Clock": "使用 24 小時制",
|
||||
"Show Current Battery Status": "顯示當前電池狀態",
|
||||
"Exported successfully": "匯出成功",
|
||||
"Book exported successfully.": "書籍匯出成功。",
|
||||
"Failed to export the book.": "書籍匯出失敗。",
|
||||
@@ -964,7 +965,6 @@
|
||||
"Unable to start RSVP": "無法啟動 RSVP",
|
||||
"RSVP not supported for PDF": "PDF 不支援 RSVP",
|
||||
"Select Chapter": "選擇章節",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "上下文",
|
||||
"Ready": "準備就緒",
|
||||
"Chapter Progress": "章節進度",
|
||||
@@ -978,10 +978,8 @@
|
||||
"Play (Space)": "播放 (空格)",
|
||||
"Skip forward 15 words": "前進 15 詞",
|
||||
"Forward 15 words (Shift+Right)": "前進 15 詞 (Shift+Right)",
|
||||
"Pause:": "暫停:",
|
||||
"Decrease speed": "減速",
|
||||
"Slower (Left/Down)": "較慢 (左/下)",
|
||||
"Current speed": "當前速度",
|
||||
"Increase speed": "加速",
|
||||
"Faster (Right/Up)": "較快 (右/上)",
|
||||
"Start RSVP Reading": "開始 RSVP 閱讀",
|
||||
@@ -1019,5 +1017,77 @@
|
||||
"Authors": "作者",
|
||||
"Books": "書籍",
|
||||
"Groups": "分組",
|
||||
"Back to TTS Location": "回到朗讀位置"
|
||||
"Back to TTS Location": "回到朗讀位置",
|
||||
"Metadata": "元數據",
|
||||
"Image viewer": "圖片檢視器",
|
||||
"Previous Image": "上一張圖片",
|
||||
"Next Image": "下一張圖片",
|
||||
"Zoomed": "已縮放",
|
||||
"Zoom level": "縮放級別",
|
||||
"Table viewer": "表格檢視器",
|
||||
"Unable to connect to Readwise. Please check your network connection.": "無法連接到 Readwise。請檢查您的網路連線。",
|
||||
"Invalid Readwise access token": "無效的 Readwise 存取權杖",
|
||||
"Disconnected from Readwise": "已斷開與 Readwise 的連線",
|
||||
"Never": "從不",
|
||||
"Readwise Settings": "Readwise 設定",
|
||||
"Connected to Readwise": "已連接到 Readwise",
|
||||
"Last synced: {{time}}": "上次同步時間:{{time}}",
|
||||
"Sync Enabled": "同步已啟用",
|
||||
"Disconnect": "斷開連線",
|
||||
"Connect your Readwise account to sync highlights.": "連接您的 Readwise 帳戶以同步高亮內容。",
|
||||
"Get your access token at": "獲取您的存取權杖:",
|
||||
"Access Token": "存取權杖",
|
||||
"Paste your Readwise access token": "貼上您的 Readwise 存取權杖",
|
||||
"Config": "配置",
|
||||
"Readwise Sync": "Readwise 同步",
|
||||
"Push Highlights": "推送高亮內容",
|
||||
"Highlights synced to Readwise": "高亮內容已同步到 Readwise",
|
||||
"Readwise sync failed: no internet connection": "Readwise 同步失敗:無網路連線",
|
||||
"Readwise sync failed: {{error}}": "Readwise 同步失敗:{{error}}",
|
||||
"System Screen Brightness": "系統螢幕亮度",
|
||||
"Page:": "頁面:",
|
||||
"Page: {{number}}": "頁面: {{number}}",
|
||||
"Annotation page number": "註解頁碼",
|
||||
"Translating...": "翻譯中...",
|
||||
"Show Battery Percentage": "顯示電池百分比",
|
||||
"Hide Scrollbar": "隱藏滾動條",
|
||||
"Skip to last reading position": "跳至上次閱讀位置",
|
||||
"Show context": "顯示上下文",
|
||||
"Hide context": "隱藏上下文",
|
||||
"Decrease font size": "縮小字型",
|
||||
"Increase font size": "放大字型",
|
||||
"Focus": "焦點",
|
||||
"Theme color": "主題顏色",
|
||||
"Import failed": "匯入失敗",
|
||||
"Available Formatters:": "可用格式化器:",
|
||||
"Format date": "格式化日期",
|
||||
"Markdown block quote (> per line)": "Markdown 引用區塊(每行 >)",
|
||||
"Newlines to <br>": "換行轉為 <br>",
|
||||
"Change case": "變更大小寫",
|
||||
"Trim whitespace": "去除空格",
|
||||
"Truncate to n characters": "截斷為 n 個字元",
|
||||
"Replace text": "取代文字",
|
||||
"Fallback value": "預設值",
|
||||
"Get length": "取得長度",
|
||||
"First/last element": "第一個/最後一個元素",
|
||||
"Join array": "合併陣列",
|
||||
"Backup failed: {{error}}": "備份失敗:{{error}}",
|
||||
"Select Backup": "選擇備份",
|
||||
"Restore failed: {{error}}": "還原失敗:{{error}}",
|
||||
"Backup & Restore": "備份與還原",
|
||||
"Create a backup of your library or restore from a previous backup. Restoring will merge with your current library.": "建立書庫備份或從先前的備份還原。還原將與目前的書庫合併。",
|
||||
"Backup Library": "備份書庫",
|
||||
"Restore Library": "還原書庫",
|
||||
"Creating backup...": "正在建立備份...",
|
||||
"Restoring library...": "正在還原書庫...",
|
||||
"{{current}} of {{total}} items": "{{current}} / {{total}} 項",
|
||||
"Backup completed successfully!": "備份成功!",
|
||||
"Restore completed successfully!": "還原成功!",
|
||||
"Your library has been saved to the selected location.": "您的書庫已儲存至所選位置。",
|
||||
"{{added}} books added, {{updated}} books updated.": "新增 {{added}} 本書,更新 {{updated}} 本書。",
|
||||
"Operation failed": "操作失敗",
|
||||
"Loading library...": "正在載入書庫...",
|
||||
"{{count}} books refreshed_other": "已重新整理 {{count}} 本書",
|
||||
"Failed to refresh metadata": "重新整理中繼資料失敗",
|
||||
"Refresh Metadata": "重新整理中繼資料"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.101": {
|
||||
"date": "2026-02-28",
|
||||
"notes": [
|
||||
"Text-to-Speech: Fixed an issue where TTS could not continue to the next chapter",
|
||||
"Reading: Fixed an issue importing very large TXT files on iOS",
|
||||
"Reading: Fixed the layout of the reading ruler in scrolled mode",
|
||||
"Reading: Smoother page-turn animations for a more fluid experience",
|
||||
"Reading: Added a gallery mode to zoom and browse images within books",
|
||||
"Annotations: Page numbers are now displayed and exported with highlights and notes",
|
||||
"Annotations: Added support for exporting highlights to Readwise",
|
||||
"OPDS: Fixed missing book descriptions in OPDS feeds",
|
||||
"Android: Improved compatibility with older WebView versions on Android"
|
||||
]
|
||||
},
|
||||
"0.9.100": {
|
||||
"date": "2026-02-13",
|
||||
"notes": [
|
||||
"Text-to-Speech: Automatically moves to the next chapter when the current chapter finishes",
|
||||
"Reading: Fixed the reading ruler on Android",
|
||||
"Reading: Improved RSVP layout and stability",
|
||||
"Reading: Fixed an issue where some EPUB books could not adjust font size",
|
||||
"Annotations: Added a magnifier (loupe) when adjusting text selection",
|
||||
"Annotations: Text selection now snaps to word boundaries for better accuracy",
|
||||
"Annotations: Custom highlight colors now apply correctly in the sidebar",
|
||||
"Dictionary: Added a back button for easier navigation",
|
||||
"Dictionary: Fixed an issue where Dictionary and Wikipedia could not open on Android",
|
||||
"Metadata: Added collapsible sections in Book Details",
|
||||
"Metadata: Added support for parsing series information from Calibre-exported EPUB files",
|
||||
"Eink: Improved component layout and colors in E-ink mode",
|
||||
"Layout: Sidebar toggle button position is now persistent"
|
||||
]
|
||||
},
|
||||
"0.9.99": {
|
||||
"date": "2026-02-07",
|
||||
"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"
|
||||
]
|
||||
|
||||
@@ -54,17 +54,6 @@
|
||||
|
||||
DetailPrint "Thumbnail provider registered successfully."
|
||||
|
||||
Delete "$DESKTOP\Readest.lnk"
|
||||
Delete "$SMPROGRAMS\Readest\Readest.lnk"
|
||||
RMDir "$SMPROGRAMS\Readest"
|
||||
|
||||
; Create new shortcuts pointing to current installation
|
||||
CreateShortcut "$DESKTOP\Readest.lnk" "$INSTDIR\Readest.exe"
|
||||
CreateDirectory "$SMPROGRAMS\Readest"
|
||||
CreateShortcut "$SMPROGRAMS\Readest\Readest.lnk" "$INSTDIR\Readest.exe"
|
||||
|
||||
DetailPrint "Shortcuts updated."
|
||||
|
||||
; Refresh shell to apply changes - SHCNE_ASSOCCHANGED
|
||||
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, p 0, p 0)'
|
||||
!macroend
|
||||
|
||||
+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())
|
||||
}
|
||||
|
||||
+34
-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
|
||||
@@ -889,6 +899,28 @@ class NativeBridgePlugin: Plugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func get_safe_area_insets(_ invoke: Invoke) {
|
||||
DispatchQueue.main.async {
|
||||
if let window = UIApplication.shared.windows.first {
|
||||
let insets = window.safeAreaInsets
|
||||
invoke.resolve([
|
||||
"top": insets.top,
|
||||
"left": insets.left,
|
||||
"bottom": insets.bottom,
|
||||
"right": insets.right
|
||||
])
|
||||
} else {
|
||||
invoke.resolve([
|
||||
"error": "No window found",
|
||||
"top": 0,
|
||||
"left": 0,
|
||||
"bottom": 0,
|
||||
"right": 0
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_cdecl("init_plugin_native_bridge")
|
||||
|
||||
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};
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://cdnjs.cloudflare.com https://storage.readest.com",
|
||||
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://cdnjs.cloudflare.com https://storage.readest.com",
|
||||
"frame-src": "'self' blob: asset: http://asset.localhost https://*.stripe.com",
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com"
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' data: blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com"
|
||||
},
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
|
||||
@@ -41,6 +41,7 @@ describe('ProofreadPopup Component', () => {
|
||||
key: 'test-book',
|
||||
text: 'test word',
|
||||
cfi: 'epubcfi(/6/2[chapter1]!/4/1:0)',
|
||||
index: 0,
|
||||
range: {
|
||||
deleteContents: vi.fn(),
|
||||
insertNode: vi.fn(),
|
||||
@@ -49,7 +50,7 @@ describe('ProofreadPopup Component', () => {
|
||||
startOffset: 5,
|
||||
endOffset: 9,
|
||||
} as unknown as Range,
|
||||
index: 0,
|
||||
page: 1,
|
||||
},
|
||||
position: { point: { x: 100, y: 100 } },
|
||||
trianglePosition: { point: { x: 100, y: 100 } },
|
||||
|
||||
@@ -68,6 +68,7 @@ vi.mock('@/services/environment', async (importOriginal) => {
|
||||
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { DEFAULT_SYSTEM_SETTINGS } from '@/services/constants';
|
||||
|
||||
function renderWithProviders(ui: React.ReactNode) {
|
||||
return render(
|
||||
@@ -81,12 +82,7 @@ describe('ProofreadRulesManager', () => {
|
||||
beforeEach(() => {
|
||||
// Reset stores
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
settings: DEFAULT_SYSTEM_SETTINGS,
|
||||
});
|
||||
(useReaderStore.setState as unknown as (state: unknown) => void)({ viewStates: {} });
|
||||
useSidebarStore.setState({ sideBarBookKey: null });
|
||||
@@ -101,6 +97,7 @@ describe('ProofreadRulesManager', () => {
|
||||
// Arrange: populate stores
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: {
|
||||
proofreadRules: [
|
||||
{
|
||||
@@ -127,7 +124,6 @@ describe('ProofreadRulesManager', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -164,8 +160,8 @@ describe('ProofreadRulesManager', () => {
|
||||
// Arrange: populate stores with a selection rule persisted in book config
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -284,10 +280,10 @@ describe('ProofreadRulesManager', () => {
|
||||
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: {
|
||||
proofreadRules: [libraryRule],
|
||||
},
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -364,8 +360,8 @@ describe('ProofreadRulesManager', () => {
|
||||
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -418,8 +414,8 @@ describe('ProofreadRulesManager', () => {
|
||||
// Arrange stores
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
(useReaderStore.setState as unknown as (state: unknown) => void)({
|
||||
@@ -453,8 +449,8 @@ describe('ProofreadRulesManager', () => {
|
||||
it('shows empty state messages when no rules exist', async () => {
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -509,10 +505,10 @@ describe('ProofreadRulesManager', () => {
|
||||
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: {
|
||||
proofreadRules: [libraryRule],
|
||||
},
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { StaticListRow } from '@/app/reader/components/sidebar/TOCItem';
|
||||
import { TOCItem } from '@/libs/document';
|
||||
|
||||
vi.mock('@/utils/misc', () => ({
|
||||
getContentMd5: (s: string) => s,
|
||||
}));
|
||||
|
||||
const makeLeafItem = (overrides?: Partial<TOCItem>): TOCItem => ({
|
||||
id: 1,
|
||||
label: 'Chapter 1',
|
||||
href: 'chapter1.html',
|
||||
index: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeParentItem = (overrides?: Partial<TOCItem>): TOCItem => ({
|
||||
id: 1,
|
||||
label: 'Part One',
|
||||
href: 'part1.html',
|
||||
index: 0,
|
||||
subitems: [{ id: 2, label: 'Chapter 1', href: 'chapter1.html', index: 1 }],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
bookKey: 'book1',
|
||||
activeHref: null,
|
||||
onToggleExpand: vi.fn(),
|
||||
onItemClick: vi.fn(),
|
||||
};
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('TOCItem accessibility', () => {
|
||||
it('treeitem has aria-label containing the chapter title', () => {
|
||||
const item = makeLeafItem({ label: 'Introduction' });
|
||||
render(<StaticListRow {...defaultProps} flatItem={{ item, depth: 0, index: 0 }} />);
|
||||
const treeitem = screen.getByRole('treeitem');
|
||||
expect(treeitem.getAttribute('aria-label')).toContain('Introduction');
|
||||
});
|
||||
|
||||
it('treeitem aria-label includes page number when location is available', () => {
|
||||
const item = makeLeafItem({
|
||||
label: 'Chapter 2',
|
||||
location: { current: 4, total: 100 } as TOCItem['location'],
|
||||
});
|
||||
render(<StaticListRow {...defaultProps} flatItem={{ item, depth: 0, index: 0 }} />);
|
||||
const treeitem = screen.getByRole('treeitem');
|
||||
// aria-label should include chapter title and page number (5 = current+1)
|
||||
expect(treeitem.getAttribute('aria-label')).toContain('Chapter 2');
|
||||
expect(treeitem.getAttribute('aria-label')).toContain('5');
|
||||
});
|
||||
|
||||
it('leaf treeitem does NOT have aria-expanded', () => {
|
||||
const item = makeLeafItem();
|
||||
render(<StaticListRow {...defaultProps} flatItem={{ item, depth: 0, index: 0 }} />);
|
||||
const treeitem = screen.getByRole('treeitem');
|
||||
expect(treeitem.hasAttribute('aria-expanded')).toBe(false);
|
||||
});
|
||||
|
||||
it('parent treeitem HAS aria-expanded set to false when collapsed', () => {
|
||||
const item = makeParentItem();
|
||||
render(
|
||||
<StaticListRow
|
||||
{...defaultProps}
|
||||
flatItem={{ item, depth: 0, index: 0, isExpanded: false }}
|
||||
/>,
|
||||
);
|
||||
const treeitem = screen.getByRole('treeitem');
|
||||
expect(treeitem.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('parent treeitem HAS aria-expanded set to true when expanded', () => {
|
||||
const item = makeParentItem();
|
||||
render(
|
||||
<StaticListRow {...defaultProps} flatItem={{ item, depth: 0, index: 0, isExpanded: true }} />,
|
||||
);
|
||||
const treeitem = screen.getByRole('treeitem');
|
||||
expect(treeitem.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('expand button has descriptive aria-label', () => {
|
||||
const item = makeParentItem();
|
||||
render(
|
||||
<StaticListRow
|
||||
{...defaultProps}
|
||||
flatItem={{ item, depth: 0, index: 0, isExpanded: false }}
|
||||
/>,
|
||||
);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button.getAttribute('aria-label')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('page number element is aria-hidden', () => {
|
||||
const item = makeLeafItem({ index: 2 });
|
||||
const { container } = render(
|
||||
<StaticListRow {...defaultProps} flatItem={{ item, depth: 0, index: 0 }} />,
|
||||
);
|
||||
// The page number div should be aria-hidden since it's included in aria-label
|
||||
const pageDiv = container.querySelector('[aria-hidden="true"]');
|
||||
expect(pageDiv).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('aria-current on active treeitem', () => {
|
||||
it('active treeitem has aria-current="page"', () => {
|
||||
const item = makeLeafItem({ href: 'chapter1.html' });
|
||||
render(
|
||||
<StaticListRow
|
||||
{...defaultProps}
|
||||
activeHref='chapter1.html'
|
||||
flatItem={{ item, depth: 0, index: 0 }}
|
||||
/>,
|
||||
);
|
||||
const treeitem = screen.getByRole('treeitem');
|
||||
expect(treeitem.getAttribute('aria-current')).toBe('page');
|
||||
});
|
||||
|
||||
it('inactive treeitem does NOT have aria-current', () => {
|
||||
const item = makeLeafItem({ href: 'chapter1.html' });
|
||||
render(
|
||||
<StaticListRow
|
||||
{...defaultProps}
|
||||
activeHref='chapter2.html'
|
||||
flatItem={{ item, depth: 0, index: 0 }}
|
||||
/>,
|
||||
);
|
||||
const treeitem = screen.getByRole('treeitem');
|
||||
expect(treeitem.hasAttribute('aria-current')).toBe(false);
|
||||
});
|
||||
|
||||
it('active treeitem is focusable (tabIndex=0)', () => {
|
||||
const item = makeLeafItem({ href: 'chapter1.html' });
|
||||
render(
|
||||
<StaticListRow
|
||||
{...defaultProps}
|
||||
activeHref='chapter1.html'
|
||||
flatItem={{ item, depth: 0, index: 0 }}
|
||||
/>,
|
||||
);
|
||||
const treeitem = screen.getByRole('treeitem');
|
||||
expect(treeitem.getAttribute('tabindex')).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TOCView tree role', () => {
|
||||
it('static list container has role="tree"', async () => {
|
||||
// This is tested indirectly via TOCView, but we verify the container role
|
||||
// by checking the structure rendered by StaticListRow's wrapper
|
||||
const item = makeLeafItem();
|
||||
render(
|
||||
<div role='tree'>
|
||||
<StaticListRow {...defaultProps} flatItem={{ item, depth: 0, index: 0 }} />
|
||||
</div>,
|
||||
);
|
||||
const tree = screen.getByRole('tree');
|
||||
expect(tree).toBeTruthy();
|
||||
expect(tree.querySelector('[role="treeitem"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { migrate, MigrationEntry } from '@/services/database/migrate';
|
||||
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
|
||||
|
||||
/**
|
||||
* In-memory DatabaseService for testing the migration runner.
|
||||
* Tracks tables, rows, and PRAGMA user_version.
|
||||
*/
|
||||
function createMockDb(): DatabaseService & {
|
||||
userVersion: number;
|
||||
tables: Map<string, DatabaseRow[]>;
|
||||
} {
|
||||
const tables = new Map<string, DatabaseRow[]>();
|
||||
let userVersion = 0;
|
||||
|
||||
const db: DatabaseService & { userVersion: number; tables: Map<string, DatabaseRow[]> } = {
|
||||
userVersion: 0,
|
||||
tables,
|
||||
|
||||
async execute(sql: string, params: unknown[] = []): Promise<DatabaseExecResult> {
|
||||
const trimmed = sql.trim();
|
||||
|
||||
if (/^CREATE TABLE/i.test(trimmed)) {
|
||||
const tableName = trimmed.match(/CREATE TABLE\s+(?:IF NOT EXISTS\s+)?(\w+)/i)?.[1];
|
||||
if (tableName && !tables.has(tableName)) {
|
||||
tables.set(tableName, []);
|
||||
}
|
||||
return { rowsAffected: 0, lastInsertId: 0 };
|
||||
}
|
||||
|
||||
if (/^INSERT INTO/i.test(trimmed)) {
|
||||
const tableName = trimmed.match(/INTO\s+(\w+)/i)?.[1] ?? '_default';
|
||||
const existing = tables.get(tableName) ?? [];
|
||||
const id = existing.length + 1;
|
||||
|
||||
// Parse VALUES for the migration tracking table
|
||||
const valuesMatch = trimmed.match(/VALUES\s*\(([^)]+)\)/i);
|
||||
if (valuesMatch) {
|
||||
const rawVal =
|
||||
params.length > 0 ? params[0] : valuesMatch[1]!.trim().replace(/^'|'$/g, '');
|
||||
existing.push({ id, name: rawVal });
|
||||
} else {
|
||||
existing.push({ id });
|
||||
}
|
||||
tables.set(tableName, existing);
|
||||
return { rowsAffected: 1, lastInsertId: id };
|
||||
}
|
||||
|
||||
return { rowsAffected: 0, lastInsertId: 0 };
|
||||
},
|
||||
|
||||
async select<T extends DatabaseRow = DatabaseRow>(sql: string): Promise<T[]> {
|
||||
const trimmed = sql.trim();
|
||||
|
||||
if (/^PRAGMA user_version/i.test(trimmed)) {
|
||||
return [{ user_version: userVersion } as unknown as T];
|
||||
}
|
||||
|
||||
const tableName = trimmed.match(/FROM\s+(\w+)/i)?.[1] ?? '_default';
|
||||
return (tables.get(tableName) ?? []) as T[];
|
||||
},
|
||||
|
||||
async batch(statements: string[]): Promise<void> {
|
||||
for (const stmt of statements) {
|
||||
const trimmed = stmt.trim();
|
||||
if (/^PRAGMA user_version\s*=/i.test(trimmed)) {
|
||||
const val = parseInt(trimmed.match(/=\s*(\d+)/)?.[1] ?? '0', 10);
|
||||
userVersion = val;
|
||||
db.userVersion = val;
|
||||
} else {
|
||||
await db.execute(trimmed);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async close(): Promise<void> {
|
||||
tables.clear();
|
||||
},
|
||||
};
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('migrate()', () => {
|
||||
let db: ReturnType<typeof createMockDb>;
|
||||
|
||||
const migrations: MigrationEntry[] = [
|
||||
{
|
||||
name: '2026030601_create_books',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS books (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT NOT NULL
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: '2026030602_create_annotations',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS annotations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
book_id INTEGER NOT NULL,
|
||||
text TEXT
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMockDb();
|
||||
});
|
||||
|
||||
it('applies all migrations on a fresh database', async () => {
|
||||
await migrate(db, migrations);
|
||||
|
||||
expect(db.tables.has('books')).toBe(true);
|
||||
expect(db.tables.has('annotations')).toBe(true);
|
||||
expect(db.tables.has('__migrations')).toBe(true);
|
||||
expect(db.userVersion).toBe(2);
|
||||
});
|
||||
|
||||
it('records migration names in the tracking table', async () => {
|
||||
await migrate(db, migrations);
|
||||
|
||||
const tracked = db.tables.get('__migrations') ?? [];
|
||||
const names = tracked.map((r) => r['name']);
|
||||
expect(names).toContain('2026030601_create_books');
|
||||
expect(names).toContain('2026030602_create_annotations');
|
||||
});
|
||||
|
||||
it('skips already-applied migrations (idempotent)', async () => {
|
||||
await migrate(db, migrations);
|
||||
const firstRunTracked = (db.tables.get('__migrations') ?? []).length;
|
||||
|
||||
// Run again — should be a no-op via PRAGMA user_version fast-path
|
||||
await migrate(db, migrations);
|
||||
const secondRunTracked = (db.tables.get('__migrations') ?? []).length;
|
||||
|
||||
expect(secondRunTracked).toBe(firstRunTracked);
|
||||
expect(db.userVersion).toBe(2);
|
||||
});
|
||||
|
||||
it('applies only new migrations when schema grows', async () => {
|
||||
// Apply first migration only
|
||||
await migrate(db, [migrations[0]!]);
|
||||
expect(db.userVersion).toBe(1);
|
||||
expect(db.tables.has('books')).toBe(true);
|
||||
expect(db.tables.has('annotations')).toBe(false);
|
||||
|
||||
// Now apply both — only second should run
|
||||
await migrate(db, migrations);
|
||||
expect(db.userVersion).toBe(2);
|
||||
expect(db.tables.has('annotations')).toBe(true);
|
||||
|
||||
const tracked = db.tables.get('__migrations') ?? [];
|
||||
expect(tracked).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does nothing when migrations array is empty', async () => {
|
||||
await migrate(db, []);
|
||||
expect(db.tables.size).toBe(0);
|
||||
expect(db.userVersion).toBe(0);
|
||||
});
|
||||
|
||||
it('uses custom tracking table name', async () => {
|
||||
await migrate(db, migrations, { table: '__custom_migrations' });
|
||||
|
||||
expect(db.tables.has('__custom_migrations')).toBe(true);
|
||||
expect(db.tables.has('__migrations')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles migration names with single quotes', async () => {
|
||||
const trickyMigrations: MigrationEntry[] = [
|
||||
{
|
||||
name: "2026030601_it's_tricky",
|
||||
sql: 'CREATE TABLE IF NOT EXISTS tricky (id INTEGER PRIMARY KEY);',
|
||||
},
|
||||
];
|
||||
|
||||
await migrate(db, trickyMigrations);
|
||||
expect(db.tables.has('tricky')).toBe(true);
|
||||
expect(db.userVersion).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMigrations()', () => {
|
||||
it('returns an array for defined schema types', async () => {
|
||||
const { getMigrations } = await import('@/services/database/migrations');
|
||||
const result = getMigrations('nonexistent_schema');
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock: NativeDatabaseService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('tauri-plugin-turso', () => {
|
||||
const rows = new Map<string, DatabaseRow[]>();
|
||||
|
||||
const mockDb = {
|
||||
execute: vi.fn(async (sql: string, params: unknown[] = []) => {
|
||||
const insertTable = sql.match(/INTO\s+(\w+)/i)?.[1];
|
||||
const fromTable = sql.match(/FROM\s+(\w+)/i)?.[1];
|
||||
const table = insertTable ?? fromTable ?? '_default';
|
||||
if (/^INSERT/i.test(sql.trim())) {
|
||||
const existing = rows.get(table) ?? [];
|
||||
const id = existing.length + 1;
|
||||
existing.push({ id, value: params[0] ?? null });
|
||||
rows.set(table, existing);
|
||||
return { rowsAffected: 1, lastInsertId: id };
|
||||
}
|
||||
if (/^DELETE/i.test(sql.trim())) {
|
||||
const existing = rows.get(table) ?? [];
|
||||
rows.set(table, []);
|
||||
return { rowsAffected: existing.length, lastInsertId: 0 };
|
||||
}
|
||||
return { rowsAffected: 0, lastInsertId: 0 };
|
||||
}),
|
||||
select: vi.fn(async (sql: string) => {
|
||||
const table = sql.match(/FROM\s+(\w+)/i)?.[1] ?? '_default';
|
||||
return rows.get(table) ?? [];
|
||||
}),
|
||||
batch: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {
|
||||
rows.clear();
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
Database: {
|
||||
load: vi.fn(async () => mockDb),
|
||||
},
|
||||
__mockDb: mockDb,
|
||||
__rows: rows,
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock: @tursodatabase/database-wasm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('@tursodatabase/database-wasm', () => {
|
||||
const rows = new Map<string, DatabaseRow[]>();
|
||||
|
||||
const mockDb = {
|
||||
prepare: vi.fn((sql: string) => ({
|
||||
run: vi.fn((...params: unknown[]) => {
|
||||
const table = sql.match(/INTO\s+(\w+)/i)?.[1] ?? '_default';
|
||||
if (/^INSERT/i.test(sql.trim())) {
|
||||
const existing = rows.get(table) ?? [];
|
||||
const id = existing.length + 1;
|
||||
existing.push({ id, value: params[0] ?? null });
|
||||
rows.set(table, existing);
|
||||
return { changes: 1, lastInsertRowid: id };
|
||||
}
|
||||
if (/^DELETE/i.test(sql.trim())) {
|
||||
const existing = rows.get(table) ?? [];
|
||||
rows.set(table, []);
|
||||
return { changes: existing.length, lastInsertRowid: 0 };
|
||||
}
|
||||
return { changes: 0, lastInsertRowid: 0 };
|
||||
}),
|
||||
all: vi.fn(() => {
|
||||
const table = sql.match(/FROM\s+(\w+)/i)?.[1] ?? '_default';
|
||||
return rows.get(table) ?? [];
|
||||
}),
|
||||
})),
|
||||
exec: vi.fn(),
|
||||
close: vi.fn(() => {
|
||||
rows.clear();
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
connect: vi.fn(() => mockDb),
|
||||
__mockDb: mockDb,
|
||||
__rows: rows,
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: NativeDatabaseService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('NativeDatabaseService', () => {
|
||||
let db: DatabaseService;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const mod = await import('tauri-plugin-turso');
|
||||
(mod as unknown as { __rows: Map<string, DatabaseRow[]> }).__rows.clear();
|
||||
|
||||
const { NativeDatabaseService } = await import('@/services/database/nativeDatabaseService');
|
||||
db = await NativeDatabaseService.open('sqlite:test.db');
|
||||
});
|
||||
|
||||
it('execute() returns DatabaseExecResult for INSERT', async () => {
|
||||
const result: DatabaseExecResult = await db.execute('INSERT INTO items (value) VALUES (?)', [
|
||||
'hello',
|
||||
]);
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
expect(result.lastInsertId).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('execute() returns DatabaseExecResult for DELETE', async () => {
|
||||
await db.execute('INSERT INTO items (value) VALUES (?)', ['a']);
|
||||
const result = await db.execute('DELETE FROM items');
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
});
|
||||
|
||||
it('select() returns typed row arrays', async () => {
|
||||
await db.execute('INSERT INTO items (value) VALUES (?)', ['alpha']);
|
||||
await db.execute('INSERT INTO items (value) VALUES (?)', ['beta']);
|
||||
|
||||
const rows = await db.select<{ id: number; value: string }>('SELECT * FROM items');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]!.id).toBe(1);
|
||||
expect(rows[0]!.value).toBe('alpha');
|
||||
expect(rows[1]!.id).toBe(2);
|
||||
expect(rows[1]!.value).toBe('beta');
|
||||
});
|
||||
|
||||
it('select() returns empty array when no rows', async () => {
|
||||
const rows = await db.select('SELECT * FROM empty_table');
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('batch() delegates to underlying db.batch()', async () => {
|
||||
await db.batch(['CREATE TABLE t (id INTEGER)', 'INSERT INTO t VALUES (1)']);
|
||||
const mod = await import('tauri-plugin-turso');
|
||||
const mockDb = (mod as unknown as { __mockDb: { batch: ReturnType<typeof vi.fn> } }).__mockDb;
|
||||
expect(mockDb.batch).toHaveBeenCalledWith([
|
||||
'CREATE TABLE t (id INTEGER)',
|
||||
'INSERT INTO t VALUES (1)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('close() delegates to underlying db.close()', async () => {
|
||||
await db.close();
|
||||
const mod = await import('tauri-plugin-turso');
|
||||
const mockDb = (mod as unknown as { __mockDb: { close: ReturnType<typeof vi.fn> } }).__mockDb;
|
||||
expect(mockDb.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: WebDatabaseService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('WebDatabaseService', () => {
|
||||
let db: DatabaseService;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const mod = await import('@tursodatabase/database-wasm');
|
||||
(mod as unknown as { __rows: Map<string, DatabaseRow[]> }).__rows.clear();
|
||||
|
||||
const { WebDatabaseService } = await import('@/services/database/webDatabaseService');
|
||||
db = await WebDatabaseService.open('test.db');
|
||||
});
|
||||
|
||||
it('execute() maps changes/lastInsertRowid to DatabaseExecResult', async () => {
|
||||
const result: DatabaseExecResult = await db.execute('INSERT INTO items (value) VALUES (?)', [
|
||||
'hello',
|
||||
]);
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
expect(result.lastInsertId).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('select() returns row objects from prepare().all()', async () => {
|
||||
await db.execute('INSERT INTO items (value) VALUES (?)', ['alpha']);
|
||||
await db.execute('INSERT INTO items (value) VALUES (?)', ['beta']);
|
||||
|
||||
const rows = await db.select<{ id: number; value: string }>('SELECT * FROM items');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]!.id).toBe(1);
|
||||
expect(rows[0]!.value).toBe('alpha');
|
||||
});
|
||||
|
||||
it('select() returns empty array when no rows', async () => {
|
||||
const rows = await db.select('SELECT * FROM empty_table');
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('batch() wraps in BEGIN/COMMIT transaction', async () => {
|
||||
await db.batch(['CREATE TABLE t (id INTEGER)', 'INSERT INTO t VALUES (1)']);
|
||||
const mod = await import('@tursodatabase/database-wasm');
|
||||
const mockDb = (mod as unknown as { __mockDb: { exec: ReturnType<typeof vi.fn> } }).__mockDb;
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('BEGIN');
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('CREATE TABLE t (id INTEGER)');
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('INSERT INTO t VALUES (1)');
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('COMMIT');
|
||||
});
|
||||
|
||||
it('batch() rolls back on error', async () => {
|
||||
const mod = await import('@tursodatabase/database-wasm');
|
||||
const mockDb = (mod as unknown as { __mockDb: { exec: ReturnType<typeof vi.fn> } }).__mockDb;
|
||||
let callCount = 0;
|
||||
mockDb.exec.mockImplementation((_sql: string) => {
|
||||
callCount++;
|
||||
// Fail on the second exec (first real statement after BEGIN)
|
||||
if (callCount === 2) throw new Error('SQL error');
|
||||
});
|
||||
|
||||
await expect(db.batch(['BAD SQL', 'GOOD SQL'])).rejects.toThrow('SQL error');
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('ROLLBACK');
|
||||
});
|
||||
|
||||
it('close() delegates to underlying db.close()', async () => {
|
||||
await db.close();
|
||||
const mod = await import('@tursodatabase/database-wasm');
|
||||
const mockDb = (mod as unknown as { __mockDb: { close: ReturnType<typeof vi.fn> } }).__mockDb;
|
||||
expect(mockDb.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: DatabaseExecResult shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DatabaseExecResult type contract', () => {
|
||||
it('has rowsAffected and lastInsertId properties', () => {
|
||||
const result: DatabaseExecResult = {
|
||||
rowsAffected: 5,
|
||||
lastInsertId: 42,
|
||||
};
|
||||
expect(result.rowsAffected).toBe(5);
|
||||
expect(result.lastInsertId).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Unified API consistency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('API consistency between Native and Web implementations', () => {
|
||||
it('both implementations satisfy the DatabaseService interface', async () => {
|
||||
const { NativeDatabaseService } = await import('@/services/database/nativeDatabaseService');
|
||||
const nativeDb: DatabaseService = await NativeDatabaseService.open('sqlite:test.db');
|
||||
expect(nativeDb.execute).toBeDefined();
|
||||
expect(nativeDb.select).toBeDefined();
|
||||
expect(nativeDb.batch).toBeDefined();
|
||||
expect(nativeDb.close).toBeDefined();
|
||||
|
||||
const { WebDatabaseService } = await import('@/services/database/webDatabaseService');
|
||||
const webDb: DatabaseService = await WebDatabaseService.open('test.db');
|
||||
expect(webDb.execute).toBeDefined();
|
||||
expect(webDb.select).toBeDefined();
|
||||
expect(webDb.batch).toBeDefined();
|
||||
expect(webDb.close).toBeDefined();
|
||||
});
|
||||
|
||||
it('both return same shape from execute()', async () => {
|
||||
const { NativeDatabaseService } = await import('@/services/database/nativeDatabaseService');
|
||||
const nativeDb = await NativeDatabaseService.open('sqlite:test.db');
|
||||
const nativeResult = await nativeDb.execute('INSERT INTO t (value) VALUES (?)', ['x']);
|
||||
|
||||
const { WebDatabaseService } = await import('@/services/database/webDatabaseService');
|
||||
const webDb = await WebDatabaseService.open('test.db');
|
||||
const webResult = await webDb.execute('INSERT INTO t (value) VALUES (?)', ['x']);
|
||||
|
||||
expect(Object.keys(nativeResult).sort()).toEqual(['lastInsertId', 'rowsAffected']);
|
||||
expect(Object.keys(webResult).sort()).toEqual(['lastInsertId', 'rowsAffected']);
|
||||
expect(typeof nativeResult.rowsAffected).toBe('number');
|
||||
expect(typeof webResult.rowsAffected).toBe('number');
|
||||
expect(typeof nativeResult.lastInsertId).toBe('number');
|
||||
expect(typeof webResult.lastInsertId).toBe('number');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { it, expect } from 'vitest';
|
||||
import { DatabaseService } from '@/types/database';
|
||||
|
||||
/**
|
||||
* Shared base database operation tests exercised against any DatabaseService.
|
||||
* Covers CRUD, batch, data types, error handling, and result contract.
|
||||
*
|
||||
* Call inside a describe() block:
|
||||
* describe('base ops', () => { baseTests(() => db); });
|
||||
*/
|
||||
export function baseTests(getDb: () => DatabaseService) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema & basic operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('creates a table and inserts a row', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
const result = await db.execute('INSERT INTO items (name) VALUES (?)', ['apple']);
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
expect(result.lastInsertId).toBe(1);
|
||||
});
|
||||
|
||||
it('inserts multiple rows with auto-incrementing ids', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
const r1 = await db.execute('INSERT INTO items (name) VALUES (?)', ['a']);
|
||||
const r2 = await db.execute('INSERT INTO items (name) VALUES (?)', ['b']);
|
||||
const r3 = await db.execute('INSERT INTO items (name) VALUES (?)', ['c']);
|
||||
expect(r1.lastInsertId).toBe(1);
|
||||
expect(r2.lastInsertId).toBe(2);
|
||||
expect(r3.lastInsertId).toBe(3);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SELECT queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('select returns typed rows', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, qty INTEGER)');
|
||||
await db.execute('INSERT INTO items (name, qty) VALUES (?, ?)', ['apple', 10]);
|
||||
await db.execute('INSERT INTO items (name, qty) VALUES (?, ?)', ['banana', 20]);
|
||||
|
||||
const rows = await db.select<{ id: number; name: string; qty: number }>(
|
||||
'SELECT * FROM items ORDER BY id',
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]!.name).toBe('apple');
|
||||
expect(rows[0]!.qty).toBe(10);
|
||||
expect(rows[1]!.name).toBe('banana');
|
||||
expect(rows[1]!.qty).toBe(20);
|
||||
});
|
||||
|
||||
it('select with WHERE and params', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['apple']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['banana']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['cherry']);
|
||||
|
||||
const rows = await db.select<{ id: number; name: string }>(
|
||||
'SELECT * FROM items WHERE name = ?',
|
||||
['banana'],
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.name).toBe('banana');
|
||||
});
|
||||
|
||||
it('select returns empty array for no matching rows', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
const rows = await db.select('SELECT * FROM items');
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UPDATE & DELETE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('UPDATE returns rowsAffected', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['old']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['old']);
|
||||
|
||||
const result = await db.execute('UPDATE items SET name = ? WHERE name = ?', ['new', 'old']);
|
||||
expect(result.rowsAffected).toBe(2);
|
||||
});
|
||||
|
||||
it('DELETE returns rowsAffected', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['a']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['b']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['c']);
|
||||
|
||||
const result = await db.execute('DELETE FROM items WHERE name IN (?, ?)', ['a', 'c']);
|
||||
expect(result.rowsAffected).toBe(2);
|
||||
|
||||
const remaining = await db.select<{ name: string }>('SELECT name FROM items');
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0]!.name).toBe('b');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('batch executes multiple statements atomically', async () => {
|
||||
const db = getDb();
|
||||
await db.batch([
|
||||
'CREATE TABLE t1 (id INTEGER PRIMARY KEY, val TEXT)',
|
||||
"INSERT INTO t1 (val) VALUES ('one')",
|
||||
"INSERT INTO t1 (val) VALUES ('two')",
|
||||
"INSERT INTO t1 (val) VALUES ('three')",
|
||||
]);
|
||||
|
||||
const rows = await db.select<{ val: string }>('SELECT val FROM t1 ORDER BY id');
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.map((r) => r.val)).toEqual(['one', 'two', 'three']);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('handles NULL values correctly', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)');
|
||||
await db.execute('INSERT INTO t (val) VALUES (?)', [null]);
|
||||
|
||||
const rows = await db.select<{ id: number; val: string | null }>('SELECT * FROM t');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.val).toBeNull();
|
||||
});
|
||||
|
||||
it('handles integer and real types', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE nums (i INTEGER, r REAL)');
|
||||
await db.execute('INSERT INTO nums (i, r) VALUES (?, ?)', [42, 3.14]);
|
||||
|
||||
const rows = await db.select<{ i: number; r: number }>('SELECT * FROM nums');
|
||||
expect(rows[0]!.i).toBe(42);
|
||||
expect(rows[0]!.r).toBeCloseTo(3.14);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('throws on invalid SQL', async () => {
|
||||
const db = getDb();
|
||||
await expect(db.execute('INVALID SQL STATEMENT')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws on constraint violation', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT UNIQUE)');
|
||||
await db.execute('INSERT INTO t (val) VALUES (?)', ['unique_val']);
|
||||
await expect(db.execute('INSERT INTO t (val) VALUES (?)', ['unique_val'])).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('execute result always has rowsAffected and lastInsertId as numbers', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY)');
|
||||
const result = await db.execute('INSERT INTO t DEFAULT VALUES');
|
||||
expect(typeof result.rowsAffected).toBe('number');
|
||||
expect(typeof result.lastInsertId).toBe('number');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { it, expect } from 'vitest';
|
||||
import { DatabaseService } from '@/types/database';
|
||||
|
||||
/**
|
||||
* Shared full-text search test cases for Turso's native FTS (Tantivy-based).
|
||||
* Call this inside a describe() block after setting up a DatabaseService instance
|
||||
* opened with { experimental: ['index_method'] }.
|
||||
*
|
||||
* API reference (turso v0.5):
|
||||
* - CREATE INDEX ... USING fts (columns) [WITH (tokenizer=..., weights=...)]
|
||||
* - fts_match(col1, col2, ..., query) — boolean filter
|
||||
* - fts_score(col1, col2, ..., query) — BM25 relevance score
|
||||
* - fts_highlight(col, open, close, query) — wrap matched terms
|
||||
* - OPTIMIZE INDEX idx_name
|
||||
*
|
||||
* Reference: https://turso.tech/blog/beyond-fts5
|
||||
*/
|
||||
export function ftsTests(getDb: () => DatabaseService) {
|
||||
let ftsProbed = false;
|
||||
let ftsSupported = false;
|
||||
|
||||
/**
|
||||
* Wrapper around it() that probes FTS support on first run (lazily,
|
||||
* after beforeEach has created the db) and skips when unavailable.
|
||||
*/
|
||||
function ftsIt(name: string, fn: () => Promise<void>) {
|
||||
it(name, async ({ skip }) => {
|
||||
if (!ftsProbed) {
|
||||
ftsProbed = true;
|
||||
const db = getDb();
|
||||
try {
|
||||
await db.execute('CREATE TABLE _fts_probe (id INTEGER PRIMARY KEY, t TEXT)');
|
||||
await db.execute('CREATE INDEX _fts_probe_idx ON _fts_probe USING fts (t)');
|
||||
await db.execute('DROP INDEX _fts_probe_idx');
|
||||
await db.execute('DROP TABLE _fts_probe');
|
||||
ftsSupported = true;
|
||||
} catch {
|
||||
try {
|
||||
await db.execute('DROP TABLE IF EXISTS _fts_probe');
|
||||
} catch {
|
||||
/* ignore cleanup errors */
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!ftsSupported) {
|
||||
skip();
|
||||
return;
|
||||
}
|
||||
await fn();
|
||||
});
|
||||
}
|
||||
|
||||
async function seedArticles(db: DatabaseService) {
|
||||
await db.execute('CREATE TABLE articles (id INTEGER PRIMARY KEY, title TEXT, body TEXT)');
|
||||
await db.execute('INSERT INTO articles (title, body) VALUES (?, ?)', [
|
||||
'Introduction to SQLite',
|
||||
'SQLite is a lightweight relational database engine widely used in embedded systems.',
|
||||
]);
|
||||
await db.execute('INSERT INTO articles (title, body) VALUES (?, ?)', [
|
||||
'Understanding WAL Mode',
|
||||
'Write-Ahead Logging improves concurrency in SQLite database operations.',
|
||||
]);
|
||||
await db.execute('INSERT INTO articles (title, body) VALUES (?, ?)', [
|
||||
'Full-Text Search Basics',
|
||||
'Full-text search allows efficient querying of large text datasets.',
|
||||
]);
|
||||
await db.execute('INSERT INTO articles (title, body) VALUES (?, ?)', [
|
||||
'Rust and WebAssembly',
|
||||
'Rust compiles to WebAssembly for high-performance browser applications.',
|
||||
]);
|
||||
await db.execute('CREATE INDEX idx_articles_fts ON articles USING fts (title, body)');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FTS index creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ftsIt('creates an FTS index on a table', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE docs (id INTEGER PRIMARY KEY, content TEXT)');
|
||||
await db.execute('CREATE INDEX idx_docs_fts ON docs USING fts (content)');
|
||||
await db.execute('INSERT INTO docs (content) VALUES (?)', ['hello world']);
|
||||
const rows = await db.select<{ id: number; content: string }>(
|
||||
"SELECT * FROM docs WHERE fts_match(content, 'hello')",
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.content).toBe('hello world');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fts_match() — full-text filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ftsIt('fts_match() returns relevant rows', async () => {
|
||||
const db = getDb();
|
||||
await seedArticles(db);
|
||||
|
||||
const rows = await db.select<{ id: number; title: string }>(
|
||||
"SELECT id, title FROM articles WHERE fts_match(title, body, 'SQLite')",
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
const titles = rows.map((r) => r.title);
|
||||
expect(titles).toContain('Introduction to SQLite');
|
||||
});
|
||||
|
||||
ftsIt('fts_match() returns empty result for non-matching query', async () => {
|
||||
const db = getDb();
|
||||
await seedArticles(db);
|
||||
|
||||
const rows = await db.select<{ id: number }>(
|
||||
"SELECT id FROM articles WHERE fts_match(title, body, 'blockchain')",
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
ftsIt('fts_match() works with parameterized queries', async () => {
|
||||
const db = getDb();
|
||||
await seedArticles(db);
|
||||
|
||||
const rows = await db.select<{ id: number; title: string }>(
|
||||
'SELECT id, title FROM articles WHERE fts_match(title, body, ?)',
|
||||
['WebAssembly'],
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.title).toBe('Rust and WebAssembly');
|
||||
});
|
||||
|
||||
ftsIt('fts_match() on single indexed column', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE tags (id INTEGER PRIMARY KEY, label TEXT)');
|
||||
await db.execute('INSERT INTO tags (label) VALUES (?)', ['typescript']);
|
||||
await db.execute('INSERT INTO tags (label) VALUES (?)', ['javascript']);
|
||||
await db.execute('INSERT INTO tags (label) VALUES (?)', ['python']);
|
||||
await db.execute('CREATE INDEX idx_tags_fts ON tags USING fts (label)');
|
||||
|
||||
const rows = await db.select<{ id: number; label: string }>(
|
||||
"SELECT id, label FROM tags WHERE fts_match(label, 'typescript')",
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.label).toBe('typescript');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fts_score() — BM25 ranking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ftsIt('fts_score() returns relevance scores ordered by rank', async () => {
|
||||
const db = getDb();
|
||||
await seedArticles(db);
|
||||
|
||||
const rows = await db.select<{ score: number; title: string }>(
|
||||
"SELECT fts_score(title, body, 'database') AS score, title FROM articles WHERE fts_match(title, body, 'database') ORDER BY score DESC",
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
for (const row of rows) {
|
||||
expect(row.score).toBeGreaterThan(0);
|
||||
}
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
expect(rows[i - 1]!.score).toBeGreaterThanOrEqual(rows[i]!.score);
|
||||
}
|
||||
});
|
||||
|
||||
ftsIt('fts_score() with multi-term query', async () => {
|
||||
const db = getDb();
|
||||
await seedArticles(db);
|
||||
|
||||
const rows = await db.select<{ score: number; title: string }>(
|
||||
"SELECT fts_score(title, body, 'SQLite database') AS score, title FROM articles WHERE fts_match(title, body, 'SQLite database') ORDER BY score DESC",
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
for (const row of rows) {
|
||||
expect(row.score).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fts_highlight() — result highlighting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ftsIt('fts_highlight() wraps matched terms with markers', async () => {
|
||||
const db = getDb();
|
||||
await seedArticles(db);
|
||||
|
||||
const rows = await db.select<{ highlighted: string }>(
|
||||
"SELECT fts_highlight(title, '<b>', '</b>', 'SQLite') AS highlighted FROM articles WHERE fts_match(title, body, 'SQLite') LIMIT 1",
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.highlighted).toContain('<b>');
|
||||
expect(rows[0]!.highlighted).toContain('</b>');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column weights
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ftsIt('FTS index with column weights affects ranking', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT, body TEXT)');
|
||||
// "database" appears only in title of post 1, only in body of post 2
|
||||
await db.execute('INSERT INTO posts (title, body) VALUES (?, ?)', [
|
||||
'Database Internals',
|
||||
'This book covers storage engines and distributed systems.',
|
||||
]);
|
||||
await db.execute('INSERT INTO posts (title, body) VALUES (?, ?)', [
|
||||
'Systems Programming',
|
||||
'Learn about database drivers, networking, and concurrency.',
|
||||
]);
|
||||
await db.execute(
|
||||
"CREATE INDEX idx_posts_fts ON posts USING fts (title, body) WITH (weights = 'title=5.0,body=1.0')",
|
||||
);
|
||||
|
||||
const rows = await db.select<{ score: number; title: string }>(
|
||||
"SELECT fts_score(title, body, 'database') AS score, title FROM posts WHERE fts_match(title, body, 'database') ORDER BY score DESC",
|
||||
);
|
||||
expect(rows.length).toBe(2);
|
||||
// Post with "database" in the heavily-weighted title should rank higher
|
||||
expect(rows[0]!.title).toBe('Database Internals');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tokenizers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ftsIt('ngram tokenizer enables substring matching', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO products (name) VALUES (?)', ['JavaScript']);
|
||||
await db.execute('INSERT INTO products (name) VALUES (?)', ['TypeScript']);
|
||||
await db.execute('INSERT INTO products (name) VALUES (?)', ['Python']);
|
||||
await db.execute(
|
||||
"CREATE INDEX idx_products_fts ON products USING fts (name) WITH (tokenizer = 'ngram')",
|
||||
);
|
||||
|
||||
const rows = await db.select<{ name: string }>(
|
||||
"SELECT name FROM products WHERE fts_match(name, 'Script') ORDER BY name",
|
||||
);
|
||||
expect(rows.length).toBe(2);
|
||||
const names = rows.map((r) => r.name);
|
||||
expect(names).toContain('JavaScript');
|
||||
expect(names).toContain('TypeScript');
|
||||
});
|
||||
|
||||
ftsIt('raw tokenizer performs exact matching', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE tags (id INTEGER PRIMARY KEY, tag TEXT)');
|
||||
await db.execute('INSERT INTO tags (tag) VALUES (?)', ['v1.0.0']);
|
||||
await db.execute('INSERT INTO tags (tag) VALUES (?)', ['v1.0.1']);
|
||||
await db.execute('INSERT INTO tags (tag) VALUES (?)', ['v2.0.0']);
|
||||
await db.execute("CREATE INDEX idx_tags_raw ON tags USING fts (tag) WITH (tokenizer = 'raw')");
|
||||
|
||||
const rows = await db.select<{ tag: string }>(
|
||||
"SELECT tag FROM tags WHERE fts_match(tag, 'v1.0.0')",
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.tag).toBe('v1.0.0');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FTS with data mutations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ftsIt('FTS index reflects newly inserted rows', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE notes (id INTEGER PRIMARY KEY, text TEXT)');
|
||||
await db.execute('CREATE INDEX idx_notes_fts ON notes USING fts (text)');
|
||||
|
||||
await db.execute('INSERT INTO notes (text) VALUES (?)', ['first note about testing']);
|
||||
|
||||
let rows = await db.select<{ id: number }>(
|
||||
"SELECT id FROM notes WHERE fts_match(text, 'testing')",
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
await db.execute('INSERT INTO notes (text) VALUES (?)', [
|
||||
'second note about testing strategies',
|
||||
]);
|
||||
|
||||
rows = await db.select<{ id: number }>("SELECT id FROM notes WHERE fts_match(text, 'testing')");
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
ftsIt('FTS index reflects deleted rows', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE notes (id INTEGER PRIMARY KEY, text TEXT)');
|
||||
await db.execute('CREATE INDEX idx_notes_fts ON notes USING fts (text)');
|
||||
await db.execute('INSERT INTO notes (text) VALUES (?)', ['important meeting notes']);
|
||||
await db.execute('INSERT INTO notes (text) VALUES (?)', ['grocery list items']);
|
||||
|
||||
await db.execute('DELETE FROM notes WHERE id = 2');
|
||||
|
||||
const rows = await db.select<{ text: string }>(
|
||||
"SELECT text FROM notes WHERE fts_match(text, 'meeting')",
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.text).toContain('meeting');
|
||||
|
||||
const all = await db.select('SELECT * FROM notes');
|
||||
expect(all).toHaveLength(1);
|
||||
});
|
||||
|
||||
ftsIt('FTS index reflects updated rows', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE notes (id INTEGER PRIMARY KEY, text TEXT)');
|
||||
await db.execute('CREATE INDEX idx_notes_fts ON notes USING fts (text)');
|
||||
await db.execute('INSERT INTO notes (text) VALUES (?)', ['old content about cats']);
|
||||
|
||||
await db.execute('UPDATE notes SET text = ? WHERE id = ?', ['new content about dogs', 1]);
|
||||
|
||||
const catRows = await db.select<{ id: number }>(
|
||||
"SELECT id FROM notes WHERE fts_match(text, 'cats')",
|
||||
);
|
||||
expect(catRows).toHaveLength(0);
|
||||
|
||||
const dogRows = await db.select<{ id: number }>(
|
||||
"SELECT id FROM notes WHERE fts_match(text, 'dogs')",
|
||||
);
|
||||
expect(dogRows).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OPTIMIZE INDEX
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ftsIt('OPTIMIZE INDEX runs without error', async () => {
|
||||
const db = getDb();
|
||||
await seedArticles(db);
|
||||
await db.execute('OPTIMIZE INDEX idx_articles_fts');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { it, expect } from 'vitest';
|
||||
import { DatabaseService } from '@/types/database';
|
||||
import { migrate, MigrationEntry } from '@/services/database/migrate';
|
||||
|
||||
/**
|
||||
* Shared migration tests exercised against any real DatabaseService.
|
||||
* Verifies the migration runner works correctly with actual SQLite
|
||||
* across all three turso backends (native, node, wasm).
|
||||
*
|
||||
* Call inside a describe() block:
|
||||
* describe('Migrations', () => { migrationTests(() => db); });
|
||||
*/
|
||||
export function migrationTests(getDb: () => DatabaseService) {
|
||||
const migrationV1: MigrationEntry[] = [
|
||||
{
|
||||
name: '2026030601_create_books',
|
||||
sql: `
|
||||
CREATE TABLE books (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const migrationV2: MigrationEntry[] = [
|
||||
...migrationV1,
|
||||
{
|
||||
name: '2026030602_create_annotations',
|
||||
sql: `
|
||||
CREATE TABLE annotations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
book_id INTEGER NOT NULL REFERENCES books(id),
|
||||
cfi TEXT NOT NULL,
|
||||
text TEXT
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const migrationV3: MigrationEntry[] = [
|
||||
...migrationV2,
|
||||
{
|
||||
name: '2026031501_add_bookmarks',
|
||||
sql: `
|
||||
CREATE TABLE bookmarks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
book_id INTEGER NOT NULL REFERENCES books(id),
|
||||
cfi TEXT NOT NULL,
|
||||
label TEXT
|
||||
);
|
||||
CREATE INDEX idx_bookmarks_book ON bookmarks(book_id);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic migration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('applies migrations and creates tables', async () => {
|
||||
const db = getDb();
|
||||
await migrate(db, migrationV1);
|
||||
|
||||
// Table should exist and be usable
|
||||
const result = await db.execute('INSERT INTO books (title, author) VALUES (?, ?)', [
|
||||
'Test Book',
|
||||
'Author',
|
||||
]);
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
expect(result.lastInsertId).toBe(1);
|
||||
|
||||
const rows = await db.select<{ id: number; title: string; author: string }>(
|
||||
'SELECT * FROM books',
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.title).toBe('Test Book');
|
||||
});
|
||||
|
||||
it('creates the __migrations tracking table', async () => {
|
||||
const db = getDb();
|
||||
await migrate(db, migrationV1);
|
||||
|
||||
const tracked = await db.select<{ id: number; name: string; applied_at: string }>(
|
||||
'SELECT * FROM __migrations ORDER BY id',
|
||||
);
|
||||
expect(tracked).toHaveLength(1);
|
||||
expect(tracked[0]!.name).toBe('2026030601_create_books');
|
||||
expect(tracked[0]!.applied_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sets PRAGMA user_version to migration count', async () => {
|
||||
const db = getDb();
|
||||
await migrate(db, migrationV2);
|
||||
|
||||
const rows = await db.select<{ user_version: number }>('PRAGMA user_version');
|
||||
expect(rows[0]!.user_version).toBe(2);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Idempotency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('is idempotent — running twice has no effect', async () => {
|
||||
const db = getDb();
|
||||
await migrate(db, migrationV2);
|
||||
|
||||
// Insert data
|
||||
await db.execute('INSERT INTO books (title) VALUES (?)', ['Book 1']);
|
||||
|
||||
// Run again — should not duplicate tables or data
|
||||
await migrate(db, migrationV2);
|
||||
|
||||
const books = await db.select<{ title: string }>('SELECT * FROM books');
|
||||
expect(books).toHaveLength(1);
|
||||
|
||||
const tracked = await db.select('SELECT * FROM __migrations');
|
||||
expect(tracked).toHaveLength(2);
|
||||
|
||||
const rows = await db.select<{ user_version: number }>('PRAGMA user_version');
|
||||
expect(rows[0]!.user_version).toBe(2);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Incremental migration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('applies only new migrations incrementally', async () => {
|
||||
const db = getDb();
|
||||
|
||||
// First: apply V1
|
||||
await migrate(db, migrationV1);
|
||||
let version = await db.select<{ user_version: number }>('PRAGMA user_version');
|
||||
expect(version[0]!.user_version).toBe(1);
|
||||
|
||||
// Second: apply V2 (adds annotations)
|
||||
await migrate(db, migrationV2);
|
||||
version = await db.select<{ user_version: number }>('PRAGMA user_version');
|
||||
expect(version[0]!.user_version).toBe(2);
|
||||
|
||||
// Verify annotations table works
|
||||
await db.execute('INSERT INTO books (title) VALUES (?)', ['B1']);
|
||||
await db.execute('INSERT INTO annotations (book_id, cfi, text) VALUES (?, ?, ?)', [
|
||||
1,
|
||||
'/2/4',
|
||||
'A note',
|
||||
]);
|
||||
const annotations = await db.select<{ text: string }>('SELECT text FROM annotations');
|
||||
expect(annotations).toHaveLength(1);
|
||||
expect(annotations[0]!.text).toBe('A note');
|
||||
|
||||
// Third: apply V3 (adds bookmarks + index)
|
||||
await migrate(db, migrationV3);
|
||||
version = await db.select<{ user_version: number }>('PRAGMA user_version');
|
||||
expect(version[0]!.user_version).toBe(3);
|
||||
|
||||
// Verify bookmarks table works
|
||||
await db.execute('INSERT INTO bookmarks (book_id, cfi, label) VALUES (?, ?, ?)', [
|
||||
1,
|
||||
'/2/8',
|
||||
'Chapter 3',
|
||||
]);
|
||||
const bookmarks = await db.select<{ label: string }>('SELECT label FROM bookmarks');
|
||||
expect(bookmarks).toHaveLength(1);
|
||||
expect(bookmarks[0]!.label).toBe('Chapter 3');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-statement migration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('handles migrations with multiple SQL statements', async () => {
|
||||
const db = getDb();
|
||||
await migrate(db, migrationV3);
|
||||
|
||||
// V3 migration creates both a table and an index
|
||||
// Verify the index exists via sqlite_master
|
||||
const indexes = await db.select<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_bookmarks_book'",
|
||||
);
|
||||
expect(indexes).toHaveLength(1);
|
||||
expect(indexes[0]!.name).toBe('idx_bookmarks_book');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty migrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('does nothing for empty migration list', async () => {
|
||||
const db = getDb();
|
||||
await migrate(db, []);
|
||||
|
||||
// No tracking table should be created
|
||||
const tables = await db.select<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = '__migrations'",
|
||||
);
|
||||
expect(tables).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom tracking table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('supports custom tracking table name', async () => {
|
||||
const db = getDb();
|
||||
await migrate(db, migrationV1, { table: '__schema_history' });
|
||||
|
||||
const tracked = await db.select<{ name: string }>('SELECT name FROM __schema_history');
|
||||
expect(tracked).toHaveLength(1);
|
||||
expect(tracked[0]!.name).toBe('2026030601_create_books');
|
||||
|
||||
// Default table should not exist
|
||||
const defaultTable = await db.select(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = '__migrations'",
|
||||
);
|
||||
expect(defaultTable).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fast-path verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('fast-path skips when user_version is current', async () => {
|
||||
const db = getDb();
|
||||
await migrate(db, migrationV2);
|
||||
|
||||
// Manually verify user_version is set
|
||||
const before = await db.select<{ user_version: number }>('PRAGMA user_version');
|
||||
expect(before[0]!.user_version).toBe(2);
|
||||
|
||||
// Running same migrations again should hit the fast-path
|
||||
// and not touch the tracking table
|
||||
await migrate(db, migrationV2);
|
||||
|
||||
const after = await db.select<{ user_version: number }>('PRAGMA user_version');
|
||||
expect(after[0]!.user_version).toBe(2);
|
||||
|
||||
const tracked = await db.select('SELECT * FROM __migrations');
|
||||
expect(tracked).toHaveLength(2);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data survives migration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('existing data survives new migrations', async () => {
|
||||
const db = getDb();
|
||||
|
||||
// Apply V1 and insert data
|
||||
await migrate(db, migrationV1);
|
||||
await db.execute('INSERT INTO books (title, author) VALUES (?, ?)', ['Book A', 'Author A']);
|
||||
await db.execute('INSERT INTO books (title, author) VALUES (?, ?)', ['Book B', 'Author B']);
|
||||
|
||||
// Apply V2 — books data should survive
|
||||
await migrate(db, migrationV2);
|
||||
|
||||
const books = await db.select<{ title: string; author: string }>(
|
||||
'SELECT title, author FROM books ORDER BY id',
|
||||
);
|
||||
expect(books).toHaveLength(2);
|
||||
expect(books[0]!.title).toBe('Book A');
|
||||
expect(books[1]!.title).toBe('Book B');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { it, expect } from 'vitest';
|
||||
import { DatabaseService } from '@/types/database';
|
||||
|
||||
/**
|
||||
* Shared vector search test cases for Turso's built-in vector functions.
|
||||
* Call this inside a describe() block after setting up a DatabaseService instance.
|
||||
*
|
||||
* Vector support is available in both the native (Node.js) and WASM backends.
|
||||
*
|
||||
* API reference:
|
||||
* - vector32 / vector / vector64 / vector8 / vector1bit — constructors
|
||||
* - vector_distance_cos(a, b) — cosine distance
|
||||
* - vector_distance_l2(a, b) — Euclidean distance
|
||||
* - vector_distance_dot(a, b) — negative dot product
|
||||
* - vector_extract(blob) — BLOB → JSON text
|
||||
* - vector_concat(a, b) — merge vectors
|
||||
* - vector_slice(blob, s, e) — extract sub-vector
|
||||
*
|
||||
* Reference: https://docs.turso.tech/sql-reference/functions/vector
|
||||
*/
|
||||
export function vectorTests(getDb: () => DatabaseService) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vector creation & storage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('stores and retrieves vector32 embeddings', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, embedding BLOB)');
|
||||
await db.execute('INSERT INTO items (embedding) VALUES (vector32(?))', [
|
||||
'[1.0, 2.0, 3.0, 4.0]',
|
||||
]);
|
||||
|
||||
const rows = await db.select<{ id: number; json: string }>(
|
||||
'SELECT id, vector_extract(embedding) AS json FROM items',
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
const parsed: number[] = JSON.parse(rows[0]!.json);
|
||||
expect(parsed).toHaveLength(4);
|
||||
expect(parsed[0]).toBeCloseTo(1.0);
|
||||
expect(parsed[3]).toBeCloseTo(4.0);
|
||||
});
|
||||
|
||||
it('vector() is an alias for vector32()', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ eq: number }>(
|
||||
"SELECT vector_extract(vector('[1,2,3]')) = vector_extract(vector32('[1,2,3]')) AS eq",
|
||||
);
|
||||
expect(rows[0]!.eq).toBe(1);
|
||||
});
|
||||
|
||||
it('stores and retrieves vector64 embeddings', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, embedding BLOB)');
|
||||
await db.execute('INSERT INTO items (embedding) VALUES (vector64(?))', ['[1.0, 2.0, 3.0]']);
|
||||
|
||||
const rows = await db.select<{ json: string }>(
|
||||
'SELECT vector_extract(embedding) AS json FROM items',
|
||||
);
|
||||
const parsed: number[] = JSON.parse(rows[0]!.json);
|
||||
expect(parsed).toHaveLength(3);
|
||||
expect(parsed[0]).toBeCloseTo(1.0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Distance functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('vector_distance_cos() returns 0 for identical vectors', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ d: number }>(
|
||||
"SELECT vector_distance_cos(vector32('[1,2,3]'), vector32('[1,2,3]')) AS d",
|
||||
);
|
||||
expect(rows[0]!.d).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it('vector_distance_cos() returns higher distance for dissimilar vectors', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ d_similar: number; d_different: number }>(
|
||||
`SELECT
|
||||
vector_distance_cos(vector32('[1,0,0]'), vector32('[0.9,0.1,0]')) AS d_similar,
|
||||
vector_distance_cos(vector32('[1,0,0]'), vector32('[0,0,1]')) AS d_different`,
|
||||
);
|
||||
expect(rows[0]!.d_similar).toBeLessThan(rows[0]!.d_different);
|
||||
});
|
||||
|
||||
it('vector_distance_l2() returns 0 for identical vectors', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ d: number }>(
|
||||
"SELECT vector_distance_l2(vector32('[3,4]'), vector32('[3,4]')) AS d",
|
||||
);
|
||||
expect(rows[0]!.d).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it('vector_distance_l2() computes correct Euclidean distance', async () => {
|
||||
const db = getDb();
|
||||
// distance between [0,0] and [3,4] should be 5
|
||||
const rows = await db.select<{ d: number }>(
|
||||
"SELECT vector_distance_l2(vector32('[0,0]'), vector32('[3,4]')) AS d",
|
||||
);
|
||||
expect(rows[0]!.d).toBeCloseTo(5.0, 4);
|
||||
});
|
||||
|
||||
it('vector_distance_dot() returns more negative for similar vectors', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ d_similar: number; d_different: number }>(
|
||||
`SELECT
|
||||
vector_distance_dot(vector32('[1,1,1]'), vector32('[1,1,1]')) AS d_similar,
|
||||
vector_distance_dot(vector32('[1,1,1]'), vector32('[-1,-1,-1]')) AS d_different`,
|
||||
);
|
||||
// dot returns negative dot product; more negative = more similar
|
||||
expect(rows[0]!.d_similar).toBeLessThan(rows[0]!.d_different);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Similarity search pattern
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('finds nearest neighbors by cosine distance', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT, embedding BLOB)');
|
||||
await db.execute("INSERT INTO docs (title, embedding) VALUES (?, vector32('[1,0,0,0]'))", [
|
||||
'Doc A',
|
||||
]);
|
||||
await db.execute("INSERT INTO docs (title, embedding) VALUES (?, vector32('[0,1,0,0]'))", [
|
||||
'Doc B',
|
||||
]);
|
||||
await db.execute(
|
||||
"INSERT INTO docs (title, embedding) VALUES (?, vector32('[0.95,0.05,0,0]'))",
|
||||
['Doc C'],
|
||||
);
|
||||
|
||||
// Query vector is close to [1,0,0,0]
|
||||
const rows = await db.select<{ title: string; distance: number }>(
|
||||
`SELECT title,
|
||||
vector_distance_cos(embedding, vector32('[1,0,0,0]')) AS distance
|
||||
FROM docs ORDER BY distance ASC LIMIT 2`,
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
// Doc A (exact match) and Doc C (very close) should be the nearest
|
||||
expect(rows[0]!.title).toBe('Doc A');
|
||||
expect(rows[1]!.title).toBe('Doc C');
|
||||
expect(rows[0]!.distance).toBeCloseTo(0, 4);
|
||||
expect(rows[0]!.distance).toBeLessThan(rows[1]!.distance);
|
||||
});
|
||||
|
||||
it('finds nearest neighbors by L2 distance', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE points (id INTEGER PRIMARY KEY, pos BLOB)');
|
||||
await db.execute("INSERT INTO points (pos) VALUES (vector32('[0,0]'))");
|
||||
await db.execute("INSERT INTO points (pos) VALUES (vector32('[3,4]'))");
|
||||
await db.execute("INSERT INTO points (pos) VALUES (vector32('[1,1]'))");
|
||||
|
||||
const rows = await db.select<{ id: number; dist: number }>(
|
||||
`SELECT id, vector_distance_l2(pos, vector32('[0,0]')) AS dist
|
||||
FROM points ORDER BY dist ASC`,
|
||||
);
|
||||
expect(rows).toHaveLength(3);
|
||||
// Origin first, then [1,1] (dist ~1.41), then [3,4] (dist 5)
|
||||
expect(rows[0]!.dist).toBeCloseTo(0, 4);
|
||||
expect(rows[1]!.dist).toBeCloseTo(Math.sqrt(2), 4);
|
||||
expect(rows[2]!.dist).toBeCloseTo(5, 4);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// vector_extract()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('vector_extract() converts BLOB back to JSON', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ json: string }>(
|
||||
"SELECT vector_extract(vector32('[10.5, 20.5, 30.5]')) AS json",
|
||||
);
|
||||
const parsed: number[] = JSON.parse(rows[0]!.json);
|
||||
expect(parsed).toHaveLength(3);
|
||||
expect(parsed[0]).toBeCloseTo(10.5);
|
||||
expect(parsed[2]).toBeCloseTo(30.5);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// vector_slice()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('vector_slice() extracts a sub-vector', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ json: string }>(
|
||||
"SELECT vector_extract(vector_slice(vector32('[10,20,30,40,50]'), 1, 4)) AS json",
|
||||
);
|
||||
const parsed: number[] = JSON.parse(rows[0]!.json);
|
||||
// zero-based: elements at index 1,2,3
|
||||
expect(parsed).toHaveLength(3);
|
||||
expect(parsed[0]).toBeCloseTo(20);
|
||||
expect(parsed[1]).toBeCloseTo(30);
|
||||
expect(parsed[2]).toBeCloseTo(40);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// vector_concat()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('vector_concat() merges two vectors', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ json: string }>(
|
||||
"SELECT vector_extract(vector_concat(vector32('[1,2]'), vector32('[3,4]'))) AS json",
|
||||
);
|
||||
const parsed: number[] = JSON.parse(rows[0]!.json);
|
||||
expect(parsed).toHaveLength(4);
|
||||
expect(parsed).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.closeTo(1),
|
||||
expect.closeTo(2),
|
||||
expect.closeTo(3),
|
||||
expect.closeTo(4),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mixed precision
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('vector_distance_cos() works with vector64', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ d: number }>(
|
||||
"SELECT vector_distance_cos(vector64('[1,0,0]'), vector64('[1,0,0]')) AS d",
|
||||
);
|
||||
expect(rows[0]!.d).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it('vector_distance_l2() works with vector64', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ d: number }>(
|
||||
"SELECT vector_distance_l2(vector64('[0,0]'), vector64('[3,4]')) AS d",
|
||||
);
|
||||
expect(rows[0]!.d).toBeCloseTo(5.0, 4);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, beforeEach, afterEach } from 'vitest';
|
||||
import { NodeDatabaseService } from '@/services/database/nodeDatabaseService';
|
||||
import { DatabaseService } from '@/types/database';
|
||||
import { baseTests } from './suites/base-tests';
|
||||
import { ftsTests } from './suites/fts-tests';
|
||||
import { vectorTests } from './suites/vector-tests';
|
||||
import { migrationTests } from './suites/migration-tests';
|
||||
|
||||
/**
|
||||
* Integration tests using a real in-memory SQLite database via @tursodatabase/database.
|
||||
* These complement the mock-based tests in mock.test.ts by exercising
|
||||
* actual SQL execution through the DatabaseService interface using the same
|
||||
* turso engine that powers the browser-based @tursodatabase/database-wasm.
|
||||
*/
|
||||
describe('NodeDatabaseService (real in-memory SQLite)', () => {
|
||||
let db: DatabaseService;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await NodeDatabaseService.open(':memory:', { experimental: ['index_method'] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
describe('Base Operations', () => {
|
||||
baseTests(() => db);
|
||||
});
|
||||
|
||||
describe('Full-Text Search', () => {
|
||||
ftsTests(() => db);
|
||||
});
|
||||
|
||||
describe('Vector Search', () => {
|
||||
vectorTests(() => db);
|
||||
});
|
||||
|
||||
describe('Migrations', () => {
|
||||
migrationTests(() => db);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { invoke } from '../tauri/tauri-invoke';
|
||||
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
|
||||
import { baseTests } from './suites/base-tests';
|
||||
import { ftsTests } from './suites/fts-tests';
|
||||
import { vectorTests } from './suites/vector-tests';
|
||||
import { migrationTests } from './suites/migration-tests';
|
||||
|
||||
/**
|
||||
* Thin DatabaseService adapter over raw Tauri IPC invoke() calls.
|
||||
* Enables reuse of the shared test suites.
|
||||
*/
|
||||
class TauriDatabaseAdapter implements DatabaseService {
|
||||
constructor(private dbPath: string) {}
|
||||
|
||||
async execute(sql: string, params: unknown[] = []): Promise<DatabaseExecResult> {
|
||||
const result = (await invoke('plugin:turso|execute', {
|
||||
db: this.dbPath,
|
||||
query: sql,
|
||||
values: params,
|
||||
})) as { rowsAffected: number; lastInsertId: number };
|
||||
return {
|
||||
rowsAffected: result.rowsAffected,
|
||||
lastInsertId: result.lastInsertId,
|
||||
};
|
||||
}
|
||||
|
||||
async select<T extends DatabaseRow = DatabaseRow>(
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<T[]> {
|
||||
return (await invoke('plugin:turso|select', {
|
||||
db: this.dbPath,
|
||||
query: sql,
|
||||
values: params,
|
||||
})) as T[];
|
||||
}
|
||||
|
||||
async batch(statements: string[]): Promise<void> {
|
||||
await invoke('plugin:turso|batch', {
|
||||
db: this.dbPath,
|
||||
queries: statements,
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await invoke('plugin:turso|close', { db: this.dbPath });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Integration tests for the turso-backed tauri-plugin-turso running inside
|
||||
* the Tauri WebView. Calls plugin IPC commands via __TAURI_INTERNALS__.invoke().
|
||||
*
|
||||
* The database is opened with experimental index_method enabled so that
|
||||
* FTS (Tantivy-based) CREATE INDEX ... USING fts works.
|
||||
*/
|
||||
describe('turso plugin (native Tauri)', () => {
|
||||
const DB_PATH = 'sqlite::memory:';
|
||||
let dbPath: string;
|
||||
let db: DatabaseService;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = (await invoke('plugin:turso|load', {
|
||||
options: { path: DB_PATH, experimental: ['index_method'] },
|
||||
})) as string;
|
||||
db = new TauriDatabaseAdapter(dbPath);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('loads an in-memory database and returns a path', () => {
|
||||
expect(typeof dbPath).toBe('string');
|
||||
expect(dbPath.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('get_config returns encryption status', async () => {
|
||||
const config = (await invoke('plugin:turso|get_config')) as { encrypted: boolean };
|
||||
expect(typeof config.encrypted).toBe('boolean');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared test suites
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Base Operations', () => {
|
||||
baseTests(() => db);
|
||||
});
|
||||
|
||||
describe('Full-Text Search', () => {
|
||||
ftsTests(() => db);
|
||||
});
|
||||
|
||||
describe('Vector Search', () => {
|
||||
vectorTests(() => db);
|
||||
});
|
||||
|
||||
describe('Migrations', () => {
|
||||
migrationTests(() => db);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, beforeEach, afterEach } from 'vitest';
|
||||
import { WebDatabaseService } from '@/services/database/webDatabaseService';
|
||||
import { DatabaseService } from '@/types/database';
|
||||
import { baseTests } from './suites/base-tests';
|
||||
import { ftsTests } from './suites/fts-tests';
|
||||
import { vectorTests } from './suites/vector-tests';
|
||||
import { migrationTests } from './suites/migration-tests';
|
||||
|
||||
/**
|
||||
* Browser-based integration tests for WebDatabaseService using @tursodatabase/database-wasm.
|
||||
* These run in real headless Chromium via @vitest/browser + Playwright, providing
|
||||
* Web Workers, SharedArrayBuffer, and OPFS support required by the WASM module.
|
||||
*/
|
||||
describe('WebDatabaseService (browser WASM, in-memory SQLite)', () => {
|
||||
let db: DatabaseService;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await WebDatabaseService.open(':memory:', { experimental: ['index_method'] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
describe('Base Operations', () => {
|
||||
baseTests(() => db);
|
||||
});
|
||||
|
||||
describe('Full-Text Search', () => {
|
||||
ftsTests(() => db);
|
||||
});
|
||||
|
||||
describe('Vector Search', () => {
|
||||
vectorTests(() => db);
|
||||
});
|
||||
|
||||
describe('Migrations', () => {
|
||||
migrationTests(() => db);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { DocumentLoader } from '@/libs/document';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
import type { FoliateView } from '@/types/view';
|
||||
import { wrappedFoliateView } from '@/types/view';
|
||||
|
||||
// Register a stub paginator custom element so View.open() doesn't fail in jsdom
|
||||
if (!customElements.get('foliate-paginator')) {
|
||||
customElements.define(
|
||||
'foliate-paginator',
|
||||
class extends HTMLElement {
|
||||
override setAttribute() {}
|
||||
override addEventListener() {}
|
||||
open() {}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Mock the paginator module import so View doesn't try to load the real one
|
||||
vi.mock('foliate-js/paginator.js', () => ({}));
|
||||
|
||||
let book: BookDoc;
|
||||
let view: FoliateView;
|
||||
let totalSections: number;
|
||||
|
||||
const loadEPUB = async () => {
|
||||
const epubPath = resolve(__dirname, '../fixtures/data/sample-alice.epub');
|
||||
const buffer = readFileSync(epubPath);
|
||||
const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' });
|
||||
const loader = new DocumentLoader(file);
|
||||
const { book } = await loader.open();
|
||||
return book;
|
||||
};
|
||||
|
||||
describe('getCFIProgress with real EPUB', () => {
|
||||
beforeAll(async () => {
|
||||
book = await loadEPUB();
|
||||
totalSections = book.sections!.length;
|
||||
|
||||
// Import View and create the element, wrapping it like the app does
|
||||
await import('foliate-js/view.js');
|
||||
const rawView = document.createElement('foliate-view') as FoliateView;
|
||||
view = wrappedFoliateView(rawView);
|
||||
await view.open(book);
|
||||
}, 30000);
|
||||
|
||||
it('should load the EPUB with sections', () => {
|
||||
expect(book.sections!.length).toBeGreaterThan(0);
|
||||
expect(view.book).toBe(book);
|
||||
});
|
||||
|
||||
it('should return a valid progress object with all fields', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const firstTextNode = walker.nextNode();
|
||||
expect(firstTextNode).not.toBeNull();
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStart(firstTextNode!, 0);
|
||||
range.setEnd(firstTextNode!, 0);
|
||||
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// fraction is the overall book progress
|
||||
expect(result!.fraction).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.fraction).toBeLessThanOrEqual(1);
|
||||
// section info
|
||||
expect(result!.section.current).toBe(sectionIndex);
|
||||
expect(result!.section.total).toBe(totalSections);
|
||||
// location info
|
||||
expect(result!.location.current).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.location.next).toBeGreaterThanOrEqual(result!.location.current);
|
||||
expect(result!.location.total).toBeGreaterThan(0);
|
||||
// time info
|
||||
expect(result!.time.section).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.time.total).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return the smallest fraction for a CFI at the start of a section', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
|
||||
expect(sectionIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const firstTextNode = walker.nextNode();
|
||||
expect(firstTextNode).not.toBeNull();
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStart(firstTextNode!, 0);
|
||||
range.setEnd(firstTextNode!, 0);
|
||||
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.section.current).toBe(sectionIndex);
|
||||
// At the start of a section, fraction should equal the section's start fraction
|
||||
// (i.e. only prior sections' sizes contribute)
|
||||
expect(result!.fraction).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return a larger fraction for a CFI at the end of a section', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.createDocument);
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
|
||||
// Get start CFI
|
||||
const walkerStart = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const firstTextNode = walkerStart.nextNode()!;
|
||||
const startRange = doc.createRange();
|
||||
startRange.setStart(firstTextNode, 0);
|
||||
startRange.setEnd(firstTextNode, 0);
|
||||
const startCfi = view.getCFI(sectionIndex, startRange);
|
||||
const startResult = await view.getCFIProgress(startCfi);
|
||||
|
||||
// Get end CFI
|
||||
const walkerEnd = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
let lastTextNode: Node | null = null;
|
||||
for (let node = walkerEnd.nextNode(); node; node = walkerEnd.nextNode()) {
|
||||
lastTextNode = node;
|
||||
}
|
||||
expect(lastTextNode).not.toBeNull();
|
||||
const endRange = doc.createRange();
|
||||
endRange.setStart(lastTextNode!, lastTextNode!.textContent!.length);
|
||||
endRange.setEnd(lastTextNode!, lastTextNode!.textContent!.length);
|
||||
const endCfi = view.getCFI(sectionIndex, endRange);
|
||||
const endResult = await view.getCFIProgress(endCfi);
|
||||
|
||||
expect(startResult).not.toBeNull();
|
||||
expect(endResult).not.toBeNull();
|
||||
expect(endResult!.section.current).toBe(sectionIndex);
|
||||
expect(endResult!.fraction).toBeGreaterThan(startResult!.fraction);
|
||||
});
|
||||
|
||||
it('should return a fraction between start and end for a midpoint CFI', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 1000);
|
||||
expect(sectionIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const textNodes: Node[] = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
expect(textNodes.length).toBeGreaterThan(0);
|
||||
|
||||
const midNodeIndex = Math.floor(textNodes.length / 2);
|
||||
const midNode = textNodes[midNodeIndex]!;
|
||||
const midOffset = Math.floor((midNode.textContent?.length ?? 0) / 2);
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStart(midNode, midOffset);
|
||||
range.setEnd(midNode, midOffset);
|
||||
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.section.current).toBe(sectionIndex);
|
||||
expect(result!.fraction).toBeGreaterThan(0);
|
||||
expect(result!.fraction).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('should produce consistent results for the same CFI', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 500);
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const node = walker.nextNode();
|
||||
expect(node).not.toBeNull();
|
||||
|
||||
const range = doc.createRange();
|
||||
const offset = Math.min(3, node!.textContent!.length);
|
||||
range.setStart(node!, offset);
|
||||
range.setEnd(node!, offset);
|
||||
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
|
||||
const result1 = await view.getCFIProgress(cfi);
|
||||
const result2 = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result1).not.toBeNull();
|
||||
expect(result2).not.toBeNull();
|
||||
expect(result1!.fraction).toBe(result2!.fraction);
|
||||
expect(result1!.section.current).toBe(result2!.section.current);
|
||||
expect(result1!.location.current).toBe(result2!.location.current);
|
||||
});
|
||||
|
||||
it('should return increasing fractions for successive CFIs in the same section', async () => {
|
||||
const sectionIndex = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 2000);
|
||||
expect(sectionIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const doc = await book.sections![sectionIndex]!.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const textNodes: Node[] = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
|
||||
const positions = [0, Math.floor(textNodes.length / 4), Math.floor((textNodes.length * 3) / 4)];
|
||||
const fractions: number[] = [];
|
||||
|
||||
for (const pos of positions) {
|
||||
const node = textNodes[pos]!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(node, 0);
|
||||
range.setEnd(node, 0);
|
||||
const cfi = view.getCFI(sectionIndex, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.section.current).toBe(sectionIndex);
|
||||
fractions.push(result!.fraction);
|
||||
}
|
||||
|
||||
for (let i = 1; i < fractions.length; i++) {
|
||||
expect(fractions[i]).toBeGreaterThan(fractions[i - 1]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return increasing fractions across different sections', async () => {
|
||||
const linearSections = book
|
||||
.sections!.map((s, i) => ({ s, i }))
|
||||
.filter(({ s }) => s.linear !== 'no' && s.size > 0);
|
||||
|
||||
expect(linearSections.length).toBeGreaterThan(1);
|
||||
|
||||
const fractions: number[] = [];
|
||||
|
||||
for (const { s, i } of linearSections.slice(0, 3)) {
|
||||
const doc = await s.createDocument();
|
||||
const body = doc.body ?? doc.documentElement;
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
const firstTextNode = walker.nextNode();
|
||||
if (!firstTextNode) continue;
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStart(firstTextNode, 0);
|
||||
range.setEnd(firstTextNode, 0);
|
||||
|
||||
const cfi = view.getCFI(i, range);
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.section.current).toBe(i);
|
||||
expect(result!.fraction).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.fraction).toBeLessThanOrEqual(1);
|
||||
fractions.push(result!.fraction);
|
||||
}
|
||||
|
||||
// Fractions across sections should be increasing
|
||||
for (let i = 1; i < fractions.length; i++) {
|
||||
expect(fractions[i]).toBeGreaterThan(fractions[i - 1]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return null for an invalid CFI', async () => {
|
||||
const result = await view.getCFIProgress('epubcfi(/99/999!/0)');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return progress for real CFIs', async () => {
|
||||
const expectedData = [
|
||||
{ cfi: 'epubcfi(/99/999!/0)', fraction: null },
|
||||
{ cfi: 'epubcfi(/6/4!/4/2/6,/1:0,/1:13)', fraction: 0.009, page: 1 },
|
||||
{ cfi: 'epubcfi(/6/8!/4/2/2[chapter_458]/4/18,/1:0,/1:681)', fraction: 0.045, page: 5 },
|
||||
{ cfi: 'epubcfi(/6/10!/4/2/2[chapter_460]/2,/3:1,/3:18)', fraction: 0.098, page: 11 },
|
||||
{ cfi: 'epubcfi(/6/10!/4/2/2[chapter_460]/4/70,/1:0,/1:261)', fraction: 0.161, page: 19 },
|
||||
{ cfi: 'epubcfi(/6/12!/4/2/2[chapter_462]/4/22,/1:0,/1:156)', fraction: 0.179, page: 21 },
|
||||
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/6,/1:0,/1:65)', fraction: 0.411, page: 46 },
|
||||
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/94,/1:1,/1:30)', fraction: 0.446, page: 51 },
|
||||
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/134,/1:0,/1:58)', fraction: 0.473, page: 54 },
|
||||
{ cfi: 'epubcfi(/6/18!/4/2/2[chapter_468]/4/170,/1:0,/1:124)', fraction: 0.491, page: 55 },
|
||||
{ cfi: 'epubcfi(/6/26!/4/2/2[chapter_476]/4/120,/1:0,/1:117)', fraction: 0.804, page: 91 },
|
||||
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4,/82/1:0,/86/1:26)', fraction: 0.955, page: 107 },
|
||||
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4/134,/1:1,/1:118)', fraction: 0.955, page: 108 },
|
||||
{ cfi: 'epubcfi(/6/30!/4/2/2[chapter_480]/4/134,/1:119,/1:358)', fraction: 0.955, page: 108 },
|
||||
];
|
||||
|
||||
for (const { cfi, fraction, page } of expectedData) {
|
||||
const result = await view.getCFIProgress(cfi);
|
||||
if (fraction === null) {
|
||||
expect(result).toBeNull();
|
||||
} else {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBeCloseTo(fraction, 1);
|
||||
expect(result!.location.current).toBe(page! - 1);
|
||||
expect(Math.abs(result!.fraction - fraction)).toBeLessThan(0.006);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { PageProgress } from 'foliate-js/progress.js';
|
||||
|
||||
const createHTML = (html: string): Document => {
|
||||
const parser = new DOMParser();
|
||||
return parser.parseFromString(`<!DOCTYPE html><html><body>${html}</body></html>`, 'text/html');
|
||||
};
|
||||
|
||||
const makeBook = (sections: { html: string }[]) => ({
|
||||
sections: sections.map(({ html }) => ({
|
||||
createDocument: () => Promise.resolve(createHTML(html)),
|
||||
})),
|
||||
});
|
||||
|
||||
describe('PageProgress', () => {
|
||||
describe('getProgress', () => {
|
||||
it('should return fraction 0 when anchor targets the start of a section', async () => {
|
||||
const book = makeBook([{ html: '<p>Hello world</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
const text = doc.body.querySelector('p')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 0);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-start');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(0);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should return fraction 1 when anchor targets the end of a section', async () => {
|
||||
const book = makeBook([{ html: '<p>Hello world</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
const text = doc.body.querySelector('p')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, text.textContent!.length);
|
||||
range.setEnd(text, text.textContent!.length);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-end');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(1);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct fraction for a midpoint in a section', async () => {
|
||||
const book = makeBook([{ html: '<p>abcdefghij</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
const text = doc.body.querySelector('p')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 5);
|
||||
range.setEnd(text, 5);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-mid');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(0.5);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle multiple text nodes across elements', async () => {
|
||||
const book = makeBook([{ html: '<p>aaaa</p><p>bbbb</p><p>cccc</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
// target start of second <p>, which is at offset 4 of 12 total chars
|
||||
const text = doc.body.querySelectorAll('p')[1]!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 0);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-multi');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBeCloseTo(4 / 12);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle anchor returning an Element instead of a Range', async () => {
|
||||
const book = makeBook([{ html: '<p>first</p><p id="target">second</p><p>third</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => doc.getElementById('target')!,
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-element');
|
||||
expect(result).not.toBeNull();
|
||||
// "first" is 5 chars, "second" starts at offset 5 of 16 total
|
||||
expect(result!.fraction).toBeCloseTo(5 / 16);
|
||||
expect(result!.index).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct index for non-zero section', async () => {
|
||||
const book = makeBook([
|
||||
{ html: '<p>chapter one</p>' },
|
||||
{ html: '<p>chapter two content</p>' },
|
||||
]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 1,
|
||||
anchor: (doc: Document) => {
|
||||
const text = doc.body.querySelector('p')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 0);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-section1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(0);
|
||||
expect(result!.index).toBe(1);
|
||||
});
|
||||
|
||||
it('should return null when resolveNavigation returns no index', async () => {
|
||||
const book = makeBook([{ html: '<p>text</p>' }]);
|
||||
const resolveNavigation = (): { index: undefined; anchor: undefined } => ({
|
||||
index: undefined,
|
||||
anchor: undefined,
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('bad-cfi');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when resolveNavigation returns no anchor', async () => {
|
||||
const book = makeBook([{ html: '<p>text</p>' }]);
|
||||
const resolveNavigation = (): { index: number; anchor: undefined } => ({
|
||||
index: 0,
|
||||
anchor: undefined,
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('no-anchor');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when section has no createDocument', async () => {
|
||||
const book = { sections: [{}] };
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: () => null,
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('no-doc');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return fraction 0 for an empty document', async () => {
|
||||
const book = makeBook([{ html: '' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
const range = doc.createRange();
|
||||
range.selectNodeContents(doc.body);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-empty');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBe(0);
|
||||
});
|
||||
|
||||
it('should return null and log error when resolveNavigation throws', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const book = makeBook([{ html: '<p>text</p>' }]);
|
||||
const resolveNavigation = () => {
|
||||
throw new Error('bad cfi');
|
||||
};
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('invalid');
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle nested elements correctly', async () => {
|
||||
const book = makeBook([{ html: '<div><span>ab</span><em>cd</em></div><p>ef</p>' }]);
|
||||
const resolveNavigation = () => ({
|
||||
index: 0,
|
||||
anchor: (doc: Document) => {
|
||||
// target the <em> text node "cd" at offset 0, which is at char 2 of 6
|
||||
const text = doc.body.querySelector('em')!.firstChild!;
|
||||
const range = doc.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 2);
|
||||
return range;
|
||||
},
|
||||
});
|
||||
|
||||
const pp = new PageProgress(book, resolveNavigation);
|
||||
const result = await pp.getProgress('cfi-nested');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fraction).toBeCloseTo(2 / 6);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,501 @@
|
||||
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
|
||||
import { DocumentLoader } from '@/libs/document';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
import type { FoliateView } from '@/types/view';
|
||||
|
||||
// Vite serves fixture files; fetch the EPUB at runtime in the browser.
|
||||
const EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href;
|
||||
|
||||
interface PaginatorElement extends HTMLElement {
|
||||
open: (book: BookDoc) => void;
|
||||
goTo: (target: {
|
||||
index: number;
|
||||
anchor?: number | (() => number);
|
||||
select?: boolean;
|
||||
}) => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
next: () => Promise<void>;
|
||||
destroy: () => void;
|
||||
getContents: () => Array<{ index: number; doc: Document; overlayer: unknown }>;
|
||||
setStyles: (styles: string | [string, string]) => void;
|
||||
render: () => void;
|
||||
primaryIndex: number;
|
||||
pages: number;
|
||||
page: number;
|
||||
size: number;
|
||||
viewSize: number;
|
||||
scrolled: boolean;
|
||||
columnCount: number;
|
||||
sections: Array<{ linear?: string; load: () => Promise<string> }>;
|
||||
}
|
||||
|
||||
let book: BookDoc;
|
||||
|
||||
const loadEPUB = async () => {
|
||||
const resp = await fetch(EPUB_URL);
|
||||
const buffer = await resp.arrayBuffer();
|
||||
const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' });
|
||||
const loader = new DocumentLoader(file);
|
||||
const { book } = await loader.open();
|
||||
return book;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for the paginator to emit 'stabilized'.
|
||||
* MUST be called BEFORE the action that triggers stabilization (e.g. goTo),
|
||||
* because #display dispatches 'stabilized' synchronously before returning.
|
||||
*/
|
||||
const waitForStabilized = (el: HTMLElement, timeout = 10000) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout);
|
||||
el.addEventListener(
|
||||
'stabilized',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
/** Wait until `getContents().length >= n` or timeout. */
|
||||
const waitForViews = async (el: PaginatorElement, n: number, timeout = 10000) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
if (el.getContents().length >= n) return;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
};
|
||||
|
||||
/** Wait for fill to complete by polling until getContents count stabilizes. */
|
||||
const waitForFillComplete = async (el: PaginatorElement, timeout = 10000) => {
|
||||
const start = Date.now();
|
||||
let lastCount = -1;
|
||||
let stableFor = 0;
|
||||
while (Date.now() - start < timeout) {
|
||||
const count = el.getContents().length;
|
||||
if (count === lastCount) {
|
||||
stableFor += 100;
|
||||
if (stableFor >= 500) return;
|
||||
} else {
|
||||
stableFor = 0;
|
||||
lastCount = count;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
};
|
||||
|
||||
describe('Paginator multi-view architecture (browser)', () => {
|
||||
let paginator: PaginatorElement;
|
||||
|
||||
beforeAll(async () => {
|
||||
book = await loadEPUB();
|
||||
await import('foliate-js/paginator.js');
|
||||
}, 30000);
|
||||
|
||||
const createPaginator = () => {
|
||||
const el = document.createElement('foliate-paginator') as PaginatorElement;
|
||||
// The paginator needs non-zero dimensions for layout calculations
|
||||
Object.assign(el.style, {
|
||||
width: '800px',
|
||||
height: '600px',
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
});
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
};
|
||||
|
||||
/** Create paginator, open book, navigate to section, wait for stabilized. */
|
||||
const setupAt = async (index: number) => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index });
|
||||
await stabilized;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (paginator) {
|
||||
try {
|
||||
paginator.destroy();
|
||||
} catch {
|
||||
/* iframe body may already be torn down */
|
||||
}
|
||||
paginator.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe('Initial state', () => {
|
||||
it('should expose primaryIndex as -1 before any section is loaded', () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
expect(paginator.primaryIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it('should have no pages before loading a section', () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
expect(paginator.pages).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty contents before loading', () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
expect(paginator.getContents()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Single section display', () => {
|
||||
it('should set primaryIndex after goTo', async () => {
|
||||
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
await setupAt(firstLinear);
|
||||
expect(paginator.primaryIndex).toBe(firstLinear);
|
||||
});
|
||||
|
||||
it('should have positive page count after loading', async () => {
|
||||
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
await setupAt(firstLinear);
|
||||
expect(paginator.pages).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should emit stabilized event after display', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
let stabilizedFired = false;
|
||||
paginator.addEventListener('stabilized', () => {
|
||||
stabilizedFired = true;
|
||||
});
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: firstLinear });
|
||||
await stabilized;
|
||||
expect(stabilizedFired).toBe(true);
|
||||
});
|
||||
|
||||
it('should have at least one entry in getContents after loading', async () => {
|
||||
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
await setupAt(firstLinear);
|
||||
const contents = paginator.getContents();
|
||||
expect(contents.length).toBeGreaterThanOrEqual(1);
|
||||
expect(contents.find((c) => c.index === firstLinear)).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have a valid document in getContents entries', async () => {
|
||||
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
await setupAt(firstLinear);
|
||||
const contents = paginator.getContents();
|
||||
const primary = contents.find((c) => c.index === firstLinear);
|
||||
expect(primary).toBeDefined();
|
||||
// Iframe documents have a different Document constructor than the
|
||||
// test's global scope, so use nodeType check instead of instanceof.
|
||||
expect(primary!.doc.nodeType).toBe(Node.DOCUMENT_NODE);
|
||||
expect(primary!.doc.body).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-view filling', () => {
|
||||
it('should load adjacent sections after display', async () => {
|
||||
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
await setupAt(firstLinear);
|
||||
await waitForViews(paginator, 2);
|
||||
const contents = paginator.getContents();
|
||||
expect(contents.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('should return contents sorted by section index', async () => {
|
||||
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
await setupAt(firstLinear);
|
||||
await waitForViews(paginator, 2);
|
||||
const contents = paginator.getContents();
|
||||
const indices = contents.map((c) => c.index);
|
||||
expect(indices).toEqual([...indices].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
it('should include the primary section in getContents', async () => {
|
||||
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
await setupAt(firstLinear);
|
||||
await waitForViews(paginator, 2);
|
||||
const contents = paginator.getContents();
|
||||
const primary = contents.find((c) => c.index === firstLinear);
|
||||
expect(primary).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation between sections', () => {
|
||||
it('should update primaryIndex when navigating to a different section', async () => {
|
||||
const linearSections = book
|
||||
.sections!.map((s, i) => ({ s, i }))
|
||||
.filter(({ s }) => s.linear !== 'no');
|
||||
expect(linearSections.length).toBeGreaterThan(1);
|
||||
|
||||
const first = linearSections[0]!.i;
|
||||
await setupAt(first);
|
||||
expect(paginator.primaryIndex).toBe(first);
|
||||
|
||||
// Second goTo may reuse an already-loaded view (no 'stabilized' event).
|
||||
// Just await the goTo promise directly.
|
||||
const second = linearSections[1]!.i;
|
||||
await paginator.goTo({ index: second });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(paginator.primaryIndex).toBe(second);
|
||||
});
|
||||
|
||||
it('should navigate to a section by fraction anchor', async () => {
|
||||
const linearSections = book
|
||||
.sections!.map((s, i) => ({ s, i }))
|
||||
.filter(({ s }) => s.linear !== 'no' && s.size > 2000);
|
||||
|
||||
const idx = linearSections[0]!.i;
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: idx, anchor: 0.5 });
|
||||
await stabilized;
|
||||
expect(paginator.primaryIndex).toBe(idx);
|
||||
// When anchored at 0.5 in a multi-page section, page > 0
|
||||
expect(paginator.page).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should navigate to a later section and back', async () => {
|
||||
const linearSections = book
|
||||
.sections!.map((s, i) => ({ s, i }))
|
||||
.filter(({ s }) => s.linear !== 'no');
|
||||
expect(linearSections.length).toBeGreaterThan(2);
|
||||
|
||||
const later = linearSections[Math.min(5, linearSections.length - 1)]!.i;
|
||||
await setupAt(later);
|
||||
expect(paginator.primaryIndex).toBe(later);
|
||||
|
||||
// Navigate back to first — far enough that views won't be reused
|
||||
const first = linearSections[0]!.i;
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: first });
|
||||
await stabilized;
|
||||
expect(paginator.primaryIndex).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Relocate events', () => {
|
||||
it('should emit relocate with fraction data after display', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
const relocates: Array<{ index: number; fraction: number; reason: string }> = [];
|
||||
paginator.addEventListener('relocate', ((e: CustomEvent) => {
|
||||
relocates.push(e.detail);
|
||||
}) as EventListener);
|
||||
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: firstLinear });
|
||||
await stabilized;
|
||||
// Give time for afterScroll to fire
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
expect(relocates.length).toBeGreaterThan(0);
|
||||
const last = relocates[relocates.length - 1]!;
|
||||
expect(last.index).toBe(firstLinear);
|
||||
expect(typeof last.fraction).toBe('number');
|
||||
expect(last.fraction).toBeGreaterThanOrEqual(0);
|
||||
expect(last.fraction).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should report fraction=0 when anchored at start of section', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
const relocates: Array<{ index: number; fraction: number }> = [];
|
||||
paginator.addEventListener('relocate', ((e: CustomEvent) => {
|
||||
relocates.push(e.detail);
|
||||
}) as EventListener);
|
||||
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: firstLinear, anchor: 0 });
|
||||
await stabilized;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const last = relocates[relocates.length - 1]!;
|
||||
expect(last.fraction).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Two-column layout at 800×600', () => {
|
||||
// At 800px width, Math.ceil(800/720) = 2 and max-column-count defaults
|
||||
// to 2, so the paginator uses a two-column (spread) layout.
|
||||
|
||||
it('should use columnCount=2 at 800px width', async () => {
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
await setupAt(idx);
|
||||
expect(paginator.columnCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should have viewSize >= size for a section with content', async () => {
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 2000);
|
||||
await setupAt(idx);
|
||||
// viewSize includes padding + content columns
|
||||
expect(paginator.viewSize).toBeGreaterThanOrEqual(paginator.size);
|
||||
});
|
||||
|
||||
it('should compute pages = ceil(viewSize / size)', async () => {
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 2000);
|
||||
await setupAt(idx);
|
||||
const expectedPages = Math.ceil(paginator.viewSize / paginator.size);
|
||||
expect(paginator.pages).toBe(expectedPages);
|
||||
});
|
||||
|
||||
it('should advance fraction by 2 columns per page turn', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
// Pick a section large enough to span multiple spreads
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 5000);
|
||||
const relocates: Array<{ fraction: number; size: number }> = [];
|
||||
paginator.addEventListener('relocate', ((e: CustomEvent) => {
|
||||
relocates.push({ fraction: e.detail.fraction, size: e.detail.size });
|
||||
}) as EventListener);
|
||||
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: idx, anchor: 0 });
|
||||
await stabilized;
|
||||
await waitForFillComplete(paginator);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
const fractionAtStart = relocates[relocates.length - 1]?.fraction ?? 0;
|
||||
expect(fractionAtStart).toBe(0);
|
||||
|
||||
// Page forward once — should advance by 2 columns worth of content
|
||||
await paginator.next();
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
const fractionAfterNext = relocates[relocates.length - 1]!.fraction;
|
||||
expect(fractionAfterNext).toBeGreaterThan(fractionAtStart);
|
||||
|
||||
// The fraction step per page should equal columnCount / textPages.
|
||||
// With 2 columns, each page turn advances by exactly `size` (detail.size).
|
||||
const lastSize = relocates[relocates.length - 1]!.size;
|
||||
expect(lastSize).toBeGreaterThan(0);
|
||||
expect(lastSize).toBeLessThanOrEqual(1);
|
||||
// fraction advanced by approximately `size` (2/textPages)
|
||||
const step = fractionAfterNext - fractionAtStart;
|
||||
expect(step).toBeCloseTo(lastSize, 1);
|
||||
});
|
||||
|
||||
it('should report two header/footer cells for two-column layout', async () => {
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
await setupAt(idx);
|
||||
// In 2-column layout, the paginator creates 2 header and 2 footer cells
|
||||
const header = paginator.shadowRoot?.getElementById('header');
|
||||
const footer = paginator.shadowRoot?.getElementById('footer');
|
||||
expect(header?.children.length).toBe(2);
|
||||
expect(footer?.children.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should use columnCount=1 in scrolled mode regardless of width', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
paginator.setAttribute('flow', 'scrolled');
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: idx });
|
||||
await stabilized;
|
||||
// Scrolled mode always uses 1 column
|
||||
expect(paginator.columnCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Adjacent index skipping non-linear sections', () => {
|
||||
it('should skip non-linear sections during navigation', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
const sections = book.sections!;
|
||||
const nonLinearIdx = sections.findIndex((s) => s.linear === 'no');
|
||||
// If there are non-linear sections, navigating past them should work
|
||||
if (nonLinearIdx >= 0) {
|
||||
const prevLinear = sections.slice(0, nonLinearIdx).findLastIndex((s) => s.linear !== 'no');
|
||||
if (prevLinear >= 0) {
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: prevLinear });
|
||||
await stabilized;
|
||||
await waitForFillComplete(paginator);
|
||||
// Next should skip non-linear and go to next linear
|
||||
await paginator.next();
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
expect(paginator.primaryIndex).not.toBe(nonLinearIdx);
|
||||
expect(sections[paginator.primaryIndex]!.linear).not.toBe('no');
|
||||
}
|
||||
}
|
||||
// If no non-linear sections, the test passes trivially
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scrolled mode', () => {
|
||||
it('should support flow=scrolled attribute', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
paginator.setAttribute('flow', 'scrolled');
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: idx });
|
||||
await stabilized;
|
||||
expect(paginator.scrolled).toBe(true);
|
||||
expect(paginator.primaryIndex).toBe(idx);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FoliateView CFI navigation (browser)', () => {
|
||||
let view: FoliateView;
|
||||
|
||||
beforeAll(async () => {
|
||||
book = await loadEPUB();
|
||||
// Import both view.js (registers foliate-view) and paginator.js
|
||||
await import('foliate-js/view.js');
|
||||
await import('foliate-js/paginator.js');
|
||||
}, 30000);
|
||||
|
||||
const createView = () => {
|
||||
const el = document.createElement('foliate-view') as FoliateView;
|
||||
Object.assign(el.style, {
|
||||
width: '800px',
|
||||
height: '600px',
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
});
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
if (view) {
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
try {
|
||||
view.close();
|
||||
} catch {
|
||||
/* iframe body may already be torn down */
|
||||
}
|
||||
view.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('should navigate to CFI and render Chapter 5', async () => {
|
||||
view = createView();
|
||||
await view.open(book);
|
||||
|
||||
const cfi = 'epubcfi(/6/16!/4,,/2/2[chapter_466]/4/18/1:70)';
|
||||
const { index } = view.resolveCFI(cfi);
|
||||
|
||||
// Wait for the renderer (paginator) to stabilize after goTo
|
||||
const stabilized = new Promise<void>((resolve) => {
|
||||
view.renderer.addEventListener('stabilized', () => resolve(), { once: true });
|
||||
});
|
||||
await view.goTo(cfi);
|
||||
await stabilized;
|
||||
|
||||
// The primary section should contain "Chapter 5"
|
||||
const contents = view.renderer.getContents();
|
||||
const primary = contents.find((c) => c.index === index);
|
||||
expect(primary).toBeDefined();
|
||||
const headings = primary!.doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
const chapterHeading = Array.from(headings).find((h) => h.textContent?.includes('Chapter 5'));
|
||||
expect(chapterHeading).toBeDefined();
|
||||
expect(chapterHeading!.textContent).toContain('Chapter 5');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock the paginator module to avoid custom element registration conflicts
|
||||
vi.mock('foliate-js/paginator.js', () => ({}));
|
||||
|
||||
describe('Paginator multi-view architecture', () => {
|
||||
describe('View padding configuration', () => {
|
||||
it('should have default padding of {before:1, after:1}', () => {
|
||||
// The View class has #padding defaulting to {before:1, after:1}
|
||||
// This is verified by checking that paginator.pages includes 2 padding pages
|
||||
// when there's a single view (pages = contentPages + 2)
|
||||
expect(true).toBe(true); // placeholder - tested via integration
|
||||
});
|
||||
});
|
||||
|
||||
describe('Paginator primaryIndex getter', () => {
|
||||
it('should expose primaryIndex on the paginator element', async () => {
|
||||
// Register a stub custom element if not already registered
|
||||
if (!customElements.get('foliate-paginator')) {
|
||||
// Use a simplified stub that mirrors the real Paginator's public interface
|
||||
customElements.define(
|
||||
'foliate-paginator',
|
||||
class extends HTMLElement {
|
||||
#primaryIndex = -1;
|
||||
get primaryIndex() {
|
||||
return this.#primaryIndex;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
const el = document.createElement('foliate-paginator') as HTMLElement & {
|
||||
primaryIndex: number;
|
||||
};
|
||||
expect(el.primaryIndex).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContents returns all loaded views', () => {
|
||||
it('should return contents sorted by section index', () => {
|
||||
// With multi-view, getContents() returns entries from all loaded views
|
||||
// sorted by section index. Each entry has { doc, index, overlayer }.
|
||||
// This is verified by the fact that consumers can .find() by index.
|
||||
const contents = [
|
||||
{ doc: {} as Document, index: 3, overlayer: null },
|
||||
{ doc: {} as Document, index: 4, overlayer: null },
|
||||
{ doc: {} as Document, index: 5, overlayer: null },
|
||||
];
|
||||
const primaryIndex = 4;
|
||||
const primary = contents.find((x) => x.index === primaryIndex) ?? contents[0];
|
||||
expect(primary?.index).toBe(4);
|
||||
});
|
||||
|
||||
it('should fall back to first entry when primaryIndex not found', () => {
|
||||
const contents = [{ doc: {} as Document, index: 3, overlayer: null }];
|
||||
const primaryIndex = 99; // not in contents
|
||||
const primary = contents.find((x) => x.index === primaryIndex) ?? contents[0];
|
||||
expect(primary?.index).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle empty contents gracefully', () => {
|
||||
const contents: { doc: Document; index: number; overlayer: unknown }[] = [];
|
||||
const primaryIndex = 0;
|
||||
const primary = contents.find((x) => x.index === primaryIndex) ?? contents[0];
|
||||
expect(primary).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('View padding assignment logic', () => {
|
||||
it('should assign {before:1, after:1} for single view', () => {
|
||||
const views = [{ index: 5, padding: { before: 0, after: 0 } }];
|
||||
// Simulate #updateViewPadding
|
||||
if (views.length === 1) {
|
||||
views[0]!.padding = { before: 1, after: 1 };
|
||||
}
|
||||
expect(views[0]!.padding).toEqual({ before: 1, after: 1 });
|
||||
});
|
||||
|
||||
it('should assign correct padding for multiple views', () => {
|
||||
const views = [
|
||||
{ index: 3, padding: { before: 0, after: 0 } },
|
||||
{ index: 4, padding: { before: 0, after: 0 } },
|
||||
{ index: 5, padding: { before: 0, after: 0 } },
|
||||
];
|
||||
// Simulate #updateViewPadding
|
||||
for (let i = 0; i < views.length; i++) {
|
||||
const before = i === 0 ? 1 : 0;
|
||||
const after = i === views.length - 1 ? 1 : 0;
|
||||
views[i]!.padding = { before, after };
|
||||
}
|
||||
expect(views[0]!.padding).toEqual({ before: 1, after: 0 });
|
||||
expect(views[1]!.padding).toEqual({ before: 0, after: 0 });
|
||||
expect(views[2]!.padding).toEqual({ before: 0, after: 1 });
|
||||
});
|
||||
|
||||
it('should assign correct padding for two views', () => {
|
||||
const views = [
|
||||
{ index: 3, padding: { before: 0, after: 0 } },
|
||||
{ index: 4, padding: { before: 0, after: 0 } },
|
||||
];
|
||||
for (let i = 0; i < views.length; i++) {
|
||||
const before = i === 0 ? 1 : 0;
|
||||
const after = i === views.length - 1 ? 1 : 0;
|
||||
views[i]!.padding = { before, after };
|
||||
}
|
||||
expect(views[0]!.padding).toEqual({ before: 1, after: 0 });
|
||||
expect(views[1]!.padding).toEqual({ before: 0, after: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Page counting across views (column-level sizing)', () => {
|
||||
// contentPages is now in column units. View element sizes are:
|
||||
// contentColumns * columnSize + (padding.before + padding.after) * spreadSize
|
||||
// where columnSize = spreadSize / columnCount
|
||||
const spreadSize = 800;
|
||||
const columnCount = 2;
|
||||
const columnSize = spreadSize / columnCount; // 400
|
||||
|
||||
type ViewInfo = {
|
||||
index: number;
|
||||
contentPages: number;
|
||||
padding: { before: number; after: number };
|
||||
};
|
||||
|
||||
const getViewElementSize = (view: ViewInfo) => {
|
||||
return (
|
||||
view.contentPages * columnSize + (view.padding.before + view.padding.after) * spreadSize
|
||||
);
|
||||
};
|
||||
|
||||
const getViewOffset = (views: ViewInfo[], targetIndex: number) => {
|
||||
let offset = 0;
|
||||
for (const view of views) {
|
||||
if (view.index === targetIndex) return offset;
|
||||
offset += getViewElementSize(view);
|
||||
}
|
||||
return offset;
|
||||
};
|
||||
|
||||
// #getPagesBeforeView uses pixel offsets divided by spread size (Math.floor)
|
||||
const getPagesBeforeView = (views: ViewInfo[], targetIndex: number) => {
|
||||
return Math.floor(getViewOffset(views, targetIndex) / spreadSize);
|
||||
};
|
||||
|
||||
it('should count spread pages before a given view using pixel offsets', () => {
|
||||
const views: ViewInfo[] = [
|
||||
{ index: 3, contentPages: 5, padding: { before: 1, after: 0 } },
|
||||
{ index: 4, contentPages: 2, padding: { before: 0, after: 0 } },
|
||||
{ index: 5, contentPages: 8, padding: { before: 0, after: 1 } },
|
||||
];
|
||||
expect(getPagesBeforeView(views, 3)).toBe(0);
|
||||
// View 3: 5*400 + 800 = 2800px → floor(2800/800) = 3
|
||||
expect(getPagesBeforeView(views, 4)).toBe(3);
|
||||
// View 3+4: 2800 + 2*400 = 3600 → floor(3600/800) = 4
|
||||
expect(getPagesBeforeView(views, 5)).toBe(4);
|
||||
});
|
||||
|
||||
it('should compute total pages using Math.ceil of viewSize / spreadSize', () => {
|
||||
const views: ViewInfo[] = [
|
||||
{ index: 0, contentPages: 5, padding: { before: 1, after: 0 } },
|
||||
{ index: 1, contentPages: 2, padding: { before: 0, after: 0 } },
|
||||
{ index: 2, contentPages: 8, padding: { before: 0, after: 1 } },
|
||||
];
|
||||
const totalViewSize = views.reduce((sum, v) => sum + getViewElementSize(v), 0);
|
||||
// 5*400+800 + 2*400 + 8*400+800 = 2800 + 800 + 4000 = 7600
|
||||
const totalPages = Math.ceil(totalViewSize / spreadSize);
|
||||
expect(totalPages).toBe(Math.ceil(7600 / 800)); // 10
|
||||
});
|
||||
|
||||
it('should place 1-column section at first column with next section after', () => {
|
||||
// Image page (1 col) + normal section (10 cols)
|
||||
const views: ViewInfo[] = [
|
||||
{ index: 0, contentPages: 1, padding: { before: 1, after: 0 } },
|
||||
{ index: 1, contentPages: 10, padding: { before: 0, after: 1 } },
|
||||
];
|
||||
// View 0: 1*400 + 800 = 1200. View 1: 10*400 + 800 = 4800
|
||||
const totalViewSize = views.reduce((sum, v) => sum + getViewElementSize(v), 0);
|
||||
expect(totalViewSize).toBe(6000);
|
||||
expect(Math.ceil(totalViewSize / spreadSize)).toBe(8);
|
||||
// Pages before view 1: floor(1200/800) = 1
|
||||
expect(getPagesBeforeView(views, 1)).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle single-column layout (columnCount=1) same as before', () => {
|
||||
const singleColSpread = 800;
|
||||
const singleColSize = singleColSpread; // columnCount=1
|
||||
// contentPages=3 means 3 columns, but with columnCount=1, each column IS a spread
|
||||
const viewSize = 3 * singleColSize + 2 * singleColSpread;
|
||||
expect(viewSize).toBe(4000);
|
||||
expect(Math.ceil(viewSize / singleColSpread)).toBe(5); // 3 content + 2 padding
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fraction and anchor calculation with column-level sizing', () => {
|
||||
const columnCount = 2;
|
||||
|
||||
it('should compute fraction as localColumn / textPages', () => {
|
||||
// Simulate #afterScroll fraction calculation
|
||||
const textPages = 6; // 6 content columns
|
||||
// At spread 0 (first content spread)
|
||||
const localPage0 = 0;
|
||||
const localColumn0 = localPage0 * columnCount; // 0
|
||||
const fraction0 = Math.max(0, Math.min(1, localColumn0 / textPages));
|
||||
expect(fraction0).toBe(0);
|
||||
|
||||
// At spread 1 (second content spread)
|
||||
const localPage1 = 1;
|
||||
const localColumn1 = localPage1 * columnCount; // 2
|
||||
const fraction1 = Math.max(0, Math.min(1, localColumn1 / textPages));
|
||||
expect(fraction1).toBeCloseTo(1 / 3);
|
||||
|
||||
// At spread 2 (third content spread, last)
|
||||
const localPage2 = 2;
|
||||
const localColumn2 = localPage2 * columnCount; // 4
|
||||
const fraction2 = Math.max(0, Math.min(1, localColumn2 / textPages));
|
||||
expect(fraction2).toBeCloseTo(2 / 3);
|
||||
});
|
||||
|
||||
it('should convert anchor fraction to spread page for scrolling', () => {
|
||||
const textPages = 5; // 5 content columns
|
||||
// anchor=0 → column 0 → spread 0
|
||||
expect(Math.floor(Math.round(0 * (textPages - 1)) / columnCount)).toBe(0);
|
||||
// anchor=0.5 → column 2 → spread 1
|
||||
expect(Math.floor(Math.round(0.5 * (textPages - 1)) / columnCount)).toBe(1);
|
||||
// anchor=1.0 → column 4 → spread 2
|
||||
expect(Math.floor(Math.round(1.0 * (textPages - 1)) / columnCount)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Primary section detection', () => {
|
||||
it('should detect primary view as first visible view', () => {
|
||||
// Simulate #detectPrimaryView — uses visibleStart, not midpoint
|
||||
const views = [
|
||||
{ index: 3, offset: 0, size: 100 },
|
||||
{ index: 4, offset: 100, size: 50 },
|
||||
{ index: 5, offset: 150, size: 200 },
|
||||
];
|
||||
const detectPrimary = (visibleStart: number) => {
|
||||
for (const view of views) {
|
||||
if (visibleStart < view.offset + view.size) {
|
||||
return view.index;
|
||||
}
|
||||
}
|
||||
return views[views.length - 1]?.index;
|
||||
};
|
||||
expect(detectPrimary(0)).toBe(3); // start in first view
|
||||
expect(detectPrimary(50)).toBe(3); // still in first view
|
||||
expect(detectPrimary(100)).toBe(4); // at boundary → second view
|
||||
expect(detectPrimary(125)).toBe(4); // in second view
|
||||
expect(detectPrimary(150)).toBe(5); // at boundary → third view
|
||||
expect(detectPrimary(300)).toBe(5); // in third view
|
||||
});
|
||||
});
|
||||
|
||||
describe('Adjacent index with fromIndex parameter', () => {
|
||||
it('should find next linear section from a given index', () => {
|
||||
const sections = [
|
||||
{ linear: 'yes' }, // 0
|
||||
{ linear: 'no' }, // 1 (non-linear)
|
||||
{ linear: 'yes' }, // 2
|
||||
{ linear: 'yes' }, // 3
|
||||
{ linear: 'no' }, // 4 (non-linear)
|
||||
{ linear: 'yes' }, // 5
|
||||
];
|
||||
const adjacentIndex = (dir: number, fromIndex: number) => {
|
||||
for (let index = fromIndex + dir; index >= 0 && index < sections.length; index += dir) {
|
||||
if (sections[index]?.linear !== 'no') return index;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
expect(adjacentIndex(1, 0)).toBe(2); // skip non-linear 1
|
||||
expect(adjacentIndex(1, 2)).toBe(3);
|
||||
expect(adjacentIndex(1, 3)).toBe(5); // skip non-linear 4
|
||||
expect(adjacentIndex(-1, 5)).toBe(3); // skip non-linear 4
|
||||
expect(adjacentIndex(-1, 2)).toBe(0); // skip non-linear 1
|
||||
expect(adjacentIndex(1, 5)).toBeUndefined(); // end of book
|
||||
expect(adjacentIndex(-1, 0)).toBeUndefined(); // start of book
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trim distant views', () => {
|
||||
it('should keep max 4 views (1 before + primary + 2 after)', () => {
|
||||
// Simulate #trimDistantViews
|
||||
const viewIndices = [1, 3, 5, 7, 9, 11];
|
||||
const primaryIndex = 5;
|
||||
const sorted = [...viewIndices].sort((a, b) => a - b);
|
||||
const primaryPos = sorted.indexOf(primaryIndex);
|
||||
const keep = new Set([primaryIndex]);
|
||||
if (primaryPos > 0) keep.add(sorted[primaryPos - 1]!);
|
||||
for (let d = 1; d <= 2; d++) {
|
||||
if (primaryPos + d < sorted.length) keep.add(sorted[primaryPos + d]!);
|
||||
}
|
||||
const remaining = viewIndices.filter((i) => keep.has(i));
|
||||
expect(remaining).toEqual([3, 5, 7, 9]);
|
||||
expect(remaining.length).toBeLessThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,450 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import { DocumentLoader } from '@/libs/document';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
|
||||
// Vite serves fixture files; fetch the EPUB at runtime in the browser.
|
||||
const EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href;
|
||||
|
||||
interface PaginatorElement extends HTMLElement {
|
||||
open: (book: BookDoc) => void;
|
||||
goTo: (target: {
|
||||
index: number;
|
||||
anchor?: number | (() => number);
|
||||
select?: boolean;
|
||||
}) => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
next: () => Promise<void>;
|
||||
destroy: () => void;
|
||||
getContents: () => Array<{ index: number; doc: Document; overlayer: unknown }>;
|
||||
setStyles: (styles: string | [string, string]) => void;
|
||||
render: () => void;
|
||||
scrollToAnchor: (anchor: number | Range, select?: boolean, smooth?: boolean) => Promise<void>;
|
||||
primaryIndex: number;
|
||||
pages: number;
|
||||
page: number;
|
||||
size: number;
|
||||
viewSize: number;
|
||||
scrolled: boolean;
|
||||
columnCount: number;
|
||||
sections: Array<{ linear?: string; load: () => Promise<string> }>;
|
||||
}
|
||||
|
||||
let book: BookDoc;
|
||||
|
||||
const loadEPUB = async () => {
|
||||
const resp = await fetch(EPUB_URL);
|
||||
const buffer = await resp.arrayBuffer();
|
||||
const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' });
|
||||
const loader = new DocumentLoader(file);
|
||||
const { book } = await loader.open();
|
||||
return book;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for the paginator to emit 'stabilized'.
|
||||
* MUST be called BEFORE the action that triggers stabilization (e.g. goTo),
|
||||
* because #display dispatches 'stabilized' synchronously before returning.
|
||||
*/
|
||||
const waitForStabilized = (el: HTMLElement, timeout = 10000) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout);
|
||||
el.addEventListener(
|
||||
'stabilized',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
/** Wait until `getContents().length >= n` or timeout. */
|
||||
const waitForViews = async (el: PaginatorElement, n: number, timeout = 10000) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
if (el.getContents().length >= n) return;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
};
|
||||
|
||||
/** Wait for fill to complete by polling until getContents count stabilizes. */
|
||||
const waitForFillComplete = async (el: PaginatorElement, timeout = 10000) => {
|
||||
const start = Date.now();
|
||||
let lastCount = -1;
|
||||
let stableFor = 0;
|
||||
while (Date.now() - start < timeout) {
|
||||
const count = el.getContents().length;
|
||||
if (count === lastCount) {
|
||||
stableFor += 100;
|
||||
if (stableFor >= 500) return;
|
||||
} else {
|
||||
stableFor = 0;
|
||||
lastCount = count;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
};
|
||||
|
||||
describe('Paginator stabilization (browser)', () => {
|
||||
let paginator: PaginatorElement;
|
||||
|
||||
// Suppress unhandled errors from paginator's #replaceBackground firing
|
||||
// after views are destroyed (queued iframe loads from rapid navigation).
|
||||
// This is a known paginator cleanup race, not a test failure.
|
||||
const suppressHandler = (e: ErrorEvent) => {
|
||||
if (e.message?.includes('getComputedStyle')) e.preventDefault();
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
window.addEventListener('error', suppressHandler);
|
||||
book = await loadEPUB();
|
||||
await import('foliate-js/paginator.js');
|
||||
}, 30000);
|
||||
|
||||
afterAll(() => {
|
||||
window.removeEventListener('error', suppressHandler);
|
||||
});
|
||||
|
||||
const createPaginator = () => {
|
||||
const el = document.createElement('foliate-paginator') as PaginatorElement;
|
||||
Object.assign(el.style, {
|
||||
width: '800px',
|
||||
height: '600px',
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
});
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
};
|
||||
|
||||
/** Create paginator, open book, navigate to section, wait for stabilized. */
|
||||
const setupAt = async (index: number) => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index });
|
||||
await stabilized;
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
if (paginator) {
|
||||
// Flush pending RAFs and iframe load callbacks before destroying.
|
||||
// Rapid navigation tests can leave queued callbacks that reference
|
||||
// views — destroying immediately would cause unhandled errors in
|
||||
// paginator.js (#replaceBackground accessing destroyed documents).
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
try {
|
||||
paginator.destroy();
|
||||
} catch {
|
||||
/* iframe body may already be torn down */
|
||||
}
|
||||
paginator.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe('Stabilized event lifecycle', () => {
|
||||
it('should emit stabilized event after goTo completes', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
let stabilizedCount = 0;
|
||||
paginator.addEventListener('stabilized', () => {
|
||||
stabilizedCount++;
|
||||
});
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: idx });
|
||||
await stabilized;
|
||||
expect(stabilizedCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should emit stabilized for each navigation to a new section', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
const linearSections = book
|
||||
.sections!.map((s, i) => ({ s, i }))
|
||||
.filter(({ s }) => s.linear !== 'no');
|
||||
expect(linearSections.length).toBeGreaterThan(1);
|
||||
|
||||
let stabilizedCount = 0;
|
||||
paginator.addEventListener('stabilized', () => {
|
||||
stabilizedCount++;
|
||||
});
|
||||
|
||||
const s1 = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: linearSections[0]!.i });
|
||||
await s1;
|
||||
const countAfterFirst = stabilizedCount;
|
||||
expect(countAfterFirst).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Navigate to a far section so view is not reused
|
||||
const farIdx = linearSections[Math.min(5, linearSections.length - 1)]!.i;
|
||||
const s2 = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: farIdx });
|
||||
await s2;
|
||||
expect(stabilizedCount).toBeGreaterThan(countAfterFirst);
|
||||
});
|
||||
|
||||
it('should have content visible (opacity=1) after stabilized', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
|
||||
let containerOpacity = '';
|
||||
paginator.addEventListener('stabilized', () => {
|
||||
// Access shadow DOM container to check opacity
|
||||
const container = paginator.shadowRoot?.getElementById('container');
|
||||
containerOpacity = container?.style.opacity ?? '';
|
||||
});
|
||||
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: idx });
|
||||
await stabilized;
|
||||
// After stabilized, opacity should be '1' (visible)
|
||||
expect(containerOpacity).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container opacity during stabilization', () => {
|
||||
it('should set opacity to 0 at start and 1 at end of display', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
|
||||
const opacities: string[] = [];
|
||||
|
||||
// Observe the shadow DOM container for style changes
|
||||
const container = paginator.shadowRoot?.getElementById('container');
|
||||
if (container) {
|
||||
const observer = new MutationObserver(() => {
|
||||
opacities.push(container.style.opacity);
|
||||
});
|
||||
observer.observe(container, { attributes: true, attributeFilter: ['style'] });
|
||||
}
|
||||
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: idx });
|
||||
await stabilized;
|
||||
|
||||
// Should have seen opacity transitions: 0 → 1
|
||||
expect(opacities).toContain('0');
|
||||
expect(opacities).toContain('1');
|
||||
// The last opacity should be '1' (content visible)
|
||||
const lastOpacity = opacities[opacities.length - 1];
|
||||
expect(lastOpacity).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Font loading integration', () => {
|
||||
it('should load view even when document has no custom fonts', async () => {
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
await setupAt(idx);
|
||||
// View should be loaded with a valid document
|
||||
const contents = paginator.getContents();
|
||||
const primary = contents.find((c) => c.index === idx);
|
||||
expect(primary).toBeDefined();
|
||||
expect(primary!.doc.fonts).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Render micro-stabilization', () => {
|
||||
it('should emit stabilized when render is called on a loaded paginator', async () => {
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
await setupAt(idx);
|
||||
// Wait for fill to fully complete so #stabilizing is false
|
||||
await waitForFillComplete(paginator);
|
||||
|
||||
const stabilizedFromRender = waitForStabilized(paginator);
|
||||
paginator.render();
|
||||
// render() dispatches stabilized in a RAF
|
||||
await stabilizedFromRender;
|
||||
// If we get here, stabilized was emitted
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve primaryIndex across re-renders', async () => {
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 1000);
|
||||
await setupAt(idx);
|
||||
await waitForFillComplete(paginator);
|
||||
|
||||
const indexBefore = paginator.primaryIndex;
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
paginator.render();
|
||||
await stabilized;
|
||||
expect(paginator.primaryIndex).toBe(indexBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stabilization suppresses scroll-to-anchor', () => {
|
||||
it('should not emit extra relocate events during initial fill', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
const relocates: Array<{ index: number; fraction: number; reason: string }> = [];
|
||||
paginator.addEventListener('relocate', ((e: CustomEvent) => {
|
||||
relocates.push(e.detail);
|
||||
}) as EventListener);
|
||||
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: idx });
|
||||
await stabilized;
|
||||
// Wait for fill to complete
|
||||
await waitForFillComplete(paginator);
|
||||
|
||||
// All relocate events should reference valid section indices
|
||||
for (const r of relocates) {
|
||||
expect(r.index).toBeGreaterThanOrEqual(0);
|
||||
expect(r.index).toBeLessThan(book.sections!.length);
|
||||
}
|
||||
// Primary index should still be the one we navigated to
|
||||
expect(paginator.primaryIndex).toBe(idx);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fill visible area completes after stabilized', () => {
|
||||
it('should have more views after fill completes than at stabilized time', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
|
||||
let viewsAtStabilized = 0;
|
||||
paginator.addEventListener(
|
||||
'stabilized',
|
||||
() => {
|
||||
viewsAtStabilized = paginator.getContents().length;
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: idx });
|
||||
await stabilized;
|
||||
// Wait for fill to complete
|
||||
await waitForFillComplete(paginator);
|
||||
|
||||
const viewsAfterFill = paginator.getContents().length;
|
||||
// Fill should have loaded additional sections beyond what was
|
||||
// available at stabilized time (or at least the same count if
|
||||
// the book has very few sections)
|
||||
expect(viewsAfterFill).toBeGreaterThanOrEqual(viewsAtStabilized);
|
||||
});
|
||||
|
||||
it('should block backward loading while stabilizing', async () => {
|
||||
const linearSections = book
|
||||
.sections!.map((s, i) => ({ s, i }))
|
||||
.filter(({ s }) => s.linear !== 'no');
|
||||
|
||||
// Navigate to a middle section so there are sections before
|
||||
const midIdx = linearSections[Math.min(3, linearSections.length - 1)]!.i;
|
||||
await setupAt(midIdx);
|
||||
// After stabilized, primary should still be the target
|
||||
expect(paginator.primaryIndex).toBe(midIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scrolled mode stabilization', () => {
|
||||
it('should stabilize correctly in scrolled mode', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
paginator.setAttribute('flow', 'scrolled');
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: idx });
|
||||
await stabilized;
|
||||
|
||||
expect(paginator.scrolled).toBe(true);
|
||||
expect(paginator.primaryIndex).toBe(idx);
|
||||
expect(paginator.getContents().length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should pre-load previous section in scrolled mode', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
paginator.setAttribute('flow', 'scrolled');
|
||||
const linearSections = book
|
||||
.sections!.map((s, i) => ({ s, i }))
|
||||
.filter(({ s }) => s.linear !== 'no');
|
||||
|
||||
// Navigate to second linear section with anchor=0 (top)
|
||||
if (linearSections.length > 1) {
|
||||
const secondIdx = linearSections[1]!.i;
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: secondIdx, anchor: 0 });
|
||||
await stabilized;
|
||||
// In scrolled mode, previous section is pre-loaded in #display
|
||||
await waitForViews(paginator, 2);
|
||||
const contents = paginator.getContents();
|
||||
const indices = contents.map((c) => c.index);
|
||||
// Should have loaded the previous section
|
||||
const firstIdx = linearSections[0]!.i;
|
||||
expect(indices).toContain(firstIdx);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Attribute-triggered re-render', () => {
|
||||
it('should re-stabilize when flow attribute changes', async () => {
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
|
||||
await setupAt(idx);
|
||||
await waitForFillComplete(paginator);
|
||||
|
||||
// Switch to scrolled mode — should trigger render() and stabilization
|
||||
const stabilizedFromSwitch = waitForStabilized(paginator);
|
||||
paginator.setAttribute('flow', 'scrolled');
|
||||
await stabilizedFromSwitch;
|
||||
expect(paginator.scrolled).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve primaryIndex when switching flow modes', async () => {
|
||||
const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 1000);
|
||||
await setupAt(idx);
|
||||
await waitForFillComplete(paginator);
|
||||
|
||||
const indexBefore = paginator.primaryIndex;
|
||||
|
||||
// Switch to scrolled
|
||||
const s1 = waitForStabilized(paginator);
|
||||
paginator.setAttribute('flow', 'scrolled');
|
||||
await s1;
|
||||
expect(paginator.primaryIndex).toBe(indexBefore);
|
||||
|
||||
// Wait for fill to complete before switching back
|
||||
await waitForFillComplete(paginator);
|
||||
|
||||
// Switch back to paginated
|
||||
const s2 = waitForStabilized(paginator);
|
||||
paginator.removeAttribute('flow');
|
||||
await s2;
|
||||
expect(paginator.primaryIndex).toBe(indexBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple rapid navigations', () => {
|
||||
it('should settle on the last navigation target after rapid goTo calls', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
const linearSections = book
|
||||
.sections!.map((s, i) => ({ s, i }))
|
||||
.filter(({ s }) => s.linear !== 'no');
|
||||
expect(linearSections.length).toBeGreaterThan(2);
|
||||
|
||||
// Fire multiple navigations rapidly
|
||||
const first = linearSections[0]!.i;
|
||||
const second = linearSections[1]!.i;
|
||||
const third = linearSections[Math.min(2, linearSections.length - 1)]!.i;
|
||||
|
||||
// Don't await intermediate — fire them all
|
||||
paginator.goTo({ index: first });
|
||||
paginator.goTo({ index: second });
|
||||
await paginator.goTo({ index: third });
|
||||
// Wait long enough for all background RAF callbacks to flush
|
||||
// (setStyles triggers RAF → #replaceBackground which reads computed styles)
|
||||
await waitForFillComplete(paginator);
|
||||
|
||||
// The paginator should have settled on a valid section
|
||||
expect(paginator.primaryIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(paginator.getContents().length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock the paginator module to avoid custom element registration conflicts
|
||||
vi.mock('foliate-js/paginator.js', () => ({}));
|
||||
|
||||
describe('Paginator stabilization', () => {
|
||||
describe('View.fontReady property', () => {
|
||||
it('should default to a resolved promise', async () => {
|
||||
// View.fontReady should initialize to Promise.resolve()
|
||||
// so awaiting it never blocks when no doc is loaded
|
||||
const fontReady = Promise.resolve();
|
||||
await expect(fontReady).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should be set from doc.fonts.ready in load()', () => {
|
||||
// After View.load(), fontReady should be assigned from doc.fonts.ready.then(...)
|
||||
// Simulating the pattern: this.fontReady = doc.fonts.ready.then(() => this.expand())
|
||||
const expandFn = vi.fn();
|
||||
const fontsReady = Promise.resolve().then(() => expandFn());
|
||||
const view = { fontReady: fontsReady };
|
||||
expect(view.fontReady).toBeInstanceOf(Promise);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stabilization flag suppresses scroll-to-anchor', () => {
|
||||
it('should not call scrollToAnchor during stabilization on expand', () => {
|
||||
// In #createView, the onExpand callback should check both #filling and #stabilizing
|
||||
// Simulate: if (!this.#filling && !this.#stabilizing) this.#scrollToAnchor(...)
|
||||
const scrollToAnchor = vi.fn();
|
||||
const filling = false;
|
||||
const stabilizing = true;
|
||||
|
||||
// onExpand callback
|
||||
if (!filling && !stabilizing) scrollToAnchor();
|
||||
expect(scrollToAnchor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call scrollToAnchor when not filling and not stabilizing', () => {
|
||||
const scrollToAnchor = vi.fn();
|
||||
const filling = false;
|
||||
const stabilizing = false;
|
||||
|
||||
if (!filling && !stabilizing) scrollToAnchor();
|
||||
expect(scrollToAnchor).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should not call scrollToAnchor when filling', () => {
|
||||
const scrollToAnchor = vi.fn();
|
||||
const filling = true;
|
||||
const stabilizing = false;
|
||||
|
||||
if (!filling && !stabilizing) scrollToAnchor();
|
||||
expect(scrollToAnchor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Font timeout mechanism', () => {
|
||||
it('should resolve within timeout even if fonts never load', async () => {
|
||||
const neverResolves = new Promise<void>(() => {
|
||||
/* intentionally never resolves */
|
||||
});
|
||||
const timeout = 50; // shorter for test
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const start = Date.now();
|
||||
await Promise.race([Promise.all([neverResolves]), wait(timeout)]);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(elapsed).toBeLessThan(timeout + 50);
|
||||
});
|
||||
|
||||
it('should resolve immediately if fonts are already loaded', async () => {
|
||||
const alreadyLoaded = Promise.resolve();
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const start = Date.now();
|
||||
await Promise.race([Promise.all([alreadyLoaded]), wait(3000)]);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(elapsed).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stabilization lifecycle in #display()', () => {
|
||||
it('should set opacity to 0 at start and 1 at end', () => {
|
||||
const container = { style: { opacity: '' } };
|
||||
// Start of display
|
||||
container.style.opacity = '0';
|
||||
expect(container.style.opacity).toBe('0');
|
||||
|
||||
// End of display (after fillVisibleArea)
|
||||
container.style.opacity = '1';
|
||||
expect(container.style.opacity).toBe('1');
|
||||
});
|
||||
|
||||
it('should dispatch stabilized event after completing', () => {
|
||||
const events: string[] = [];
|
||||
const dispatchEvent = (event: { type: string }) => {
|
||||
events.push(event.type);
|
||||
};
|
||||
|
||||
// Simulate end of #display()
|
||||
dispatchEvent({ type: 'stabilized' });
|
||||
expect(events).toContain('stabilized');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stabilizing stays true until fill completes', () => {
|
||||
it('should keep stabilizing true after display until fillPromise resolves', async () => {
|
||||
let stabilizing = true;
|
||||
let fillResolve: () => void;
|
||||
const fillPromise = new Promise<void>((resolve) => {
|
||||
fillResolve = resolve;
|
||||
});
|
||||
|
||||
// Simulate #display end: dispatch stabilized but don't clear #stabilizing
|
||||
// Instead, defer clearing to fillPromise completion
|
||||
fillPromise.then(() => {
|
||||
stabilizing = false;
|
||||
});
|
||||
|
||||
// stabilizing should still be true before fill completes
|
||||
expect(stabilizing).toBe(true);
|
||||
|
||||
// Resolve fill
|
||||
fillResolve!();
|
||||
await fillPromise;
|
||||
|
||||
expect(stabilizing).toBe(false);
|
||||
});
|
||||
|
||||
it('should block backward loading while stabilizing', () => {
|
||||
const loadPrevSection = vi.fn();
|
||||
const filling = false;
|
||||
const stabilizing = true;
|
||||
const scrollStart = 50; // near top
|
||||
const viewportSize = 800;
|
||||
|
||||
// Debounced scroll handler check
|
||||
if (!filling && !stabilizing && scrollStart < viewportSize) {
|
||||
loadPrevSection();
|
||||
}
|
||||
expect(loadPrevSection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow backward loading after stabilizing clears', () => {
|
||||
const loadPrevSection = vi.fn();
|
||||
const filling = false;
|
||||
const stabilizing = false;
|
||||
const scrollStart = 50;
|
||||
const viewportSize = 800;
|
||||
|
||||
if (!filling && !stabilizing && scrollStart < viewportSize) {
|
||||
loadPrevSection();
|
||||
}
|
||||
expect(loadPrevSection).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Debounced scroll handler skips during stabilization', () => {
|
||||
function simulateDebouncedHandler(state: {
|
||||
stabilizing: boolean;
|
||||
justAnchored: boolean;
|
||||
filling: boolean;
|
||||
start: number;
|
||||
size: number;
|
||||
}) {
|
||||
const afterScroll = vi.fn();
|
||||
const loadPrevSection = vi.fn();
|
||||
|
||||
if (state.stabilizing) return { afterScroll, loadPrevSection };
|
||||
if (state.justAnchored) state.justAnchored = false;
|
||||
else afterScroll();
|
||||
if (!state.filling && state.start < state.size) {
|
||||
loadPrevSection();
|
||||
}
|
||||
return { afterScroll, loadPrevSection };
|
||||
}
|
||||
|
||||
it('should skip entirely while stabilizing', () => {
|
||||
const state = { stabilizing: true, justAnchored: true, filling: false, start: 0, size: 800 };
|
||||
const { afterScroll, loadPrevSection } = simulateDebouncedHandler(state);
|
||||
expect(afterScroll).not.toHaveBeenCalled();
|
||||
expect(loadPrevSection).not.toHaveBeenCalled();
|
||||
expect(state.justAnchored).toBe(true); // preserved
|
||||
});
|
||||
|
||||
it('should run normally after stabilizing clears', () => {
|
||||
const state = {
|
||||
stabilizing: false,
|
||||
justAnchored: false,
|
||||
filling: false,
|
||||
start: 50,
|
||||
size: 800,
|
||||
};
|
||||
const { afterScroll, loadPrevSection } = simulateDebouncedHandler(state);
|
||||
expect(afterScroll).toHaveBeenCalledOnce();
|
||||
expect(loadPrevSection).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should not load backward when prev section already loaded (views.has check)', () => {
|
||||
// When prev section is pre-loaded in #display, views.has(prevIdx) is true
|
||||
// so the debounced handler's backward loading is a no-op.
|
||||
// This test verifies the pre-load prevents cascade.
|
||||
const prevAlreadyLoaded = true;
|
||||
const loadPrevSection = vi.fn();
|
||||
if (!prevAlreadyLoaded) loadPrevSection();
|
||||
expect(loadPrevSection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pre-load previous section in #display for scrolled mode', () => {
|
||||
it('should pre-load prev section when anchor <= 0.5 in scrolled mode', () => {
|
||||
const scrolled = true;
|
||||
const anchor = 0; // beginning of section
|
||||
const contentPages = 10;
|
||||
const columnCount = 2;
|
||||
|
||||
const needsPrev =
|
||||
(contentPages > 0 && contentPages < columnCount) ||
|
||||
(scrolled && typeof anchor === 'number' && anchor <= 0.5);
|
||||
|
||||
expect(needsPrev).toBe(true);
|
||||
});
|
||||
|
||||
it('should pre-load prev section when anchor = 0.5 in scrolled mode', () => {
|
||||
const scrolled = true;
|
||||
const anchor = 0.5;
|
||||
const contentPages = 10;
|
||||
const columnCount = 2;
|
||||
|
||||
const needsPrev =
|
||||
(contentPages > 0 && contentPages < columnCount) ||
|
||||
(scrolled && typeof anchor === 'number' && anchor <= 0.5);
|
||||
|
||||
expect(needsPrev).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT pre-load prev section when anchor > 0.5 in scrolled mode', () => {
|
||||
const scrolled = true;
|
||||
const anchor = 0.8;
|
||||
const contentPages = 10;
|
||||
const columnCount = 2;
|
||||
|
||||
const needsPrev =
|
||||
(contentPages > 0 && contentPages < columnCount) ||
|
||||
(scrolled && typeof anchor === 'number' && anchor <= 0.5);
|
||||
|
||||
expect(needsPrev).toBe(false);
|
||||
});
|
||||
|
||||
it('should still pre-load for short primary alignment regardless of mode', () => {
|
||||
const scrolled = false;
|
||||
const anchor = 0.8;
|
||||
const contentPages = 1; // shorter than one spread
|
||||
const columnCount = 2;
|
||||
|
||||
const needsPrev =
|
||||
(contentPages > 0 && contentPages < columnCount) ||
|
||||
(scrolled && typeof anchor === 'number' && anchor <= 0.5);
|
||||
|
||||
expect(needsPrev).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT pre-load in paginated mode with anchor=0 and normal section', () => {
|
||||
const scrolled = false;
|
||||
const anchor = 0;
|
||||
const contentPages = 10;
|
||||
const columnCount = 2;
|
||||
|
||||
const needsPrev =
|
||||
(contentPages > 0 && contentPages < columnCount) ||
|
||||
(scrolled && typeof anchor === 'number' && anchor <= 0.5);
|
||||
|
||||
expect(needsPrev).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Render micro-stabilization', () => {
|
||||
it('should only micro-stabilize when not already in stabilization', () => {
|
||||
let stabilizing = false;
|
||||
const container = { style: { opacity: '' } };
|
||||
|
||||
// When not already stabilizing
|
||||
const needsStabilize = !stabilizing;
|
||||
if (needsStabilize) {
|
||||
stabilizing = true;
|
||||
container.style.opacity = '0';
|
||||
}
|
||||
expect(needsStabilize).toBe(true);
|
||||
expect(stabilizing).toBe(true);
|
||||
expect(container.style.opacity).toBe('0');
|
||||
});
|
||||
|
||||
it('should not micro-stabilize when already stabilizing', () => {
|
||||
let stabilizing = true;
|
||||
const container = { style: { opacity: '0' } };
|
||||
|
||||
const needsStabilize = !stabilizing;
|
||||
if (needsStabilize) {
|
||||
stabilizing = true;
|
||||
container.style.opacity = '0';
|
||||
}
|
||||
expect(needsStabilize).toBe(false);
|
||||
// stabilizing remains true, container opacity unchanged (already 0)
|
||||
expect(stabilizing).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve, join } from 'path';
|
||||
import { parse, toRange, fromRange } from 'foliate-js/epubcfi.js';
|
||||
import { DocumentLoader } from '@/libs/document';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
|
||||
const vendorDir = join(process.cwd(), 'public/vendor');
|
||||
|
||||
/**
|
||||
* Tests EPUB CFI resolution with a real PDF loaded via DocumentLoader.
|
||||
*
|
||||
* Reference CFIs captured from sample-alice.pdf in a full browser environment:
|
||||
* - index 3: epubcfi(/6/8!/4/4,/94/1:0,/118/1:10)
|
||||
* - index 4: epubcfi(/6/10!/4/4/42,/1:0,/1:46)
|
||||
*
|
||||
* In jsdom the canvas 2d context is unavailable, so createDocument() uses a
|
||||
* fallback that produces one <span> per getTextContent() item. The element
|
||||
* count differs from the full TextLayer render, so these tests generate CFIs
|
||||
* from the fallback DOM and verify round-trip resolution.
|
||||
*/
|
||||
describe('PDF CFI resolution with real document', () => {
|
||||
let book: BookDoc;
|
||||
let doc3: Document;
|
||||
let doc4: Document;
|
||||
|
||||
/** Shift the spine-level part from a parsed CFI (as view.js resolveCFI does). */
|
||||
const shiftSpine = (parts: ReturnType<typeof parse>) => {
|
||||
(parts.parent ?? parts).shift();
|
||||
return parts;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await import('foliate-js/pdf.js');
|
||||
const pdfjsLib = (globalThis as Record<string, unknown>)['pdfjsLib'] as {
|
||||
GlobalWorkerOptions: { workerSrc: string };
|
||||
};
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
`file://${join(vendorDir, 'pdfjs/pdf.worker.min.mjs')}`,
|
||||
).href;
|
||||
|
||||
const pdfPath = resolve(__dirname, '../fixtures/data/sample-alice.pdf');
|
||||
const buffer = readFileSync(pdfPath);
|
||||
const file = new File([buffer], 'sample-alice.pdf', { type: 'application/pdf' });
|
||||
const loader = new DocumentLoader(file);
|
||||
const result = await loader.open();
|
||||
book = result.book;
|
||||
|
||||
doc3 = await book.sections[3]!.createDocument();
|
||||
doc4 = await book.sections[4]!.createDocument();
|
||||
}, 30_000);
|
||||
|
||||
// ---------- DOM structure -------------------------------------------------
|
||||
|
||||
it('should have textLayer, canvas, and annotationLayer wrappers', () => {
|
||||
expect(doc3.querySelector('#canvas')).toBeTruthy();
|
||||
expect(doc3.querySelector('.textLayer')).toBeTruthy();
|
||||
expect(doc3.querySelector('.annotationLayer')).toBeTruthy();
|
||||
|
||||
const textLayer = doc3.querySelector('.textLayer')!;
|
||||
expect(textLayer.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ---------- Round-trip: fromRange → parse → toRange -----------------------
|
||||
|
||||
it('should round-trip a range CFI on page 3', () => {
|
||||
const textLayer = doc3.querySelector('.textLayer')!;
|
||||
const spans = textLayer.querySelectorAll('span');
|
||||
// Find a span with enough text content
|
||||
let targetSpan: Element | null = null;
|
||||
for (const span of spans) {
|
||||
if (span.firstChild && span.firstChild.textContent!.length >= 10) {
|
||||
targetSpan = span;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(targetSpan).toBeTruthy();
|
||||
|
||||
const srcRange = doc3.createRange();
|
||||
srcRange.setStart(targetSpan!.firstChild!, 0);
|
||||
srcRange.setEnd(targetSpan!.firstChild!, 10);
|
||||
const expectedText = srcRange.toString();
|
||||
|
||||
const cfi = fromRange(srcRange);
|
||||
const parts = parse(cfi);
|
||||
const resolved = toRange(doc3, parts);
|
||||
|
||||
expect(resolved).toBeInstanceOf(Range);
|
||||
expect(resolved!.toString()).toBe(expectedText);
|
||||
});
|
||||
|
||||
it('should round-trip a range CFI on page 4', () => {
|
||||
const textLayer = doc4.querySelector('.textLayer')!;
|
||||
const spans = textLayer.querySelectorAll('span');
|
||||
let targetSpan: Element | null = null;
|
||||
for (const span of spans) {
|
||||
if (span.firstChild && span.firstChild.textContent!.length >= 10) {
|
||||
targetSpan = span;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(targetSpan).toBeTruthy();
|
||||
|
||||
const srcRange = doc4.createRange();
|
||||
srcRange.setStart(targetSpan!.firstChild!, 0);
|
||||
srcRange.setEnd(targetSpan!.firstChild!, 10);
|
||||
const expectedText = srcRange.toString();
|
||||
|
||||
const cfi = fromRange(srcRange);
|
||||
const parts = parse(cfi);
|
||||
const resolved = toRange(doc4, parts);
|
||||
|
||||
expect(resolved).toBeInstanceOf(Range);
|
||||
expect(resolved!.toString()).toBe(expectedText);
|
||||
});
|
||||
|
||||
it('should round-trip a multi-span range CFI', () => {
|
||||
const textLayer = doc3.querySelector('.textLayer')!;
|
||||
const spans = textLayer.querySelectorAll('span');
|
||||
// Select a range spanning two different spans
|
||||
const span1 = spans[0]!;
|
||||
const span2 = spans[2]!;
|
||||
expect(span1.firstChild).toBeTruthy();
|
||||
expect(span2.firstChild).toBeTruthy();
|
||||
|
||||
const srcRange = doc3.createRange();
|
||||
srcRange.setStart(span1.firstChild!, 0);
|
||||
const endOffset = Math.min(5, span2.firstChild!.textContent!.length);
|
||||
srcRange.setEnd(span2.firstChild!, endOffset);
|
||||
const expectedText = srcRange.toString();
|
||||
|
||||
const cfi = fromRange(srcRange);
|
||||
const parts = parse(cfi);
|
||||
const resolved = toRange(doc3, parts);
|
||||
|
||||
expect(resolved).toBeInstanceOf(Range);
|
||||
expect(resolved!.toString()).toBe(expectedText);
|
||||
});
|
||||
|
||||
it('should round-trip a collapsed (point) CFI', () => {
|
||||
const textLayer = doc3.querySelector('.textLayer')!;
|
||||
const span = textLayer.querySelector('span')!;
|
||||
expect(span.firstChild).toBeTruthy();
|
||||
|
||||
const srcRange = doc3.createRange();
|
||||
srcRange.setStart(span.firstChild!, 3);
|
||||
srcRange.collapse(true);
|
||||
|
||||
const cfi = fromRange(srcRange);
|
||||
const parts = parse(cfi);
|
||||
const resolved = toRange(doc3, parts);
|
||||
|
||||
expect(resolved).toBeInstanceOf(Range);
|
||||
expect(resolved!.collapsed).toBe(true);
|
||||
});
|
||||
|
||||
// ---------- Cross-page CFI mismatch ---------------------------------------
|
||||
|
||||
it('should fail to resolve a page 3 CFI on page 4 document', () => {
|
||||
const textLayer = doc3.querySelector('.textLayer')!;
|
||||
const spans = textLayer.querySelectorAll('span');
|
||||
// Pick the last span so its index likely exceeds page 4's element count
|
||||
const lastSpan = spans[spans.length - 1]!;
|
||||
expect(lastSpan.firstChild).toBeTruthy();
|
||||
|
||||
const srcRange = doc3.createRange();
|
||||
srcRange.setStart(lastSpan.firstChild!, 0);
|
||||
const endOffset = Math.min(5, lastSpan.firstChild!.textContent!.length);
|
||||
srcRange.setEnd(lastSpan.firstChild!, endOffset);
|
||||
|
||||
const cfi = fromRange(srcRange);
|
||||
const parts = parse(cfi);
|
||||
const range = toRange(doc4, parts);
|
||||
// May resolve to a different node or return null depending on DOM sizes;
|
||||
// either way it should NOT match the original text
|
||||
if (range) {
|
||||
expect(range.toString()).not.toBe(srcRange.toString());
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Reference CFI format verification -----------------------------
|
||||
|
||||
it('should parse real browser-captured CFIs correctly', () => {
|
||||
// These CFIs were captured from sample-alice.pdf in a full browser with
|
||||
// TextLayer. They verify that the CFI format is structurally valid.
|
||||
const cfi3 = 'epubcfi(/6/8!/4/4,/94/1:0,/118/1:10)';
|
||||
const cfi4 = 'epubcfi(/6/10!/4/4/42,/1:0,/1:46)';
|
||||
|
||||
const parts3 = parse(cfi3);
|
||||
expect(parts3.parent).toBeTruthy();
|
||||
expect(parts3.start).toBeTruthy();
|
||||
expect(parts3.end).toBeTruthy();
|
||||
|
||||
const parts4 = parse(cfi4);
|
||||
expect(parts4.parent).toBeTruthy();
|
||||
expect(parts4.start).toBeTruthy();
|
||||
expect(parts4.end).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should encode the correct section index in CFI spine step', () => {
|
||||
// Section index 3 → spine step /6/8, section index 4 → spine step /6/10
|
||||
const cfi3 = 'epubcfi(/6/8!/4/4,/94/1:0,/118/1:10)';
|
||||
const cfi4 = 'epubcfi(/6/10!/4/4/42,/1:0,/1:46)';
|
||||
|
||||
expect(cfi3).toContain('/6/8!');
|
||||
expect(cfi4).toContain('/6/10!');
|
||||
|
||||
// Spine step /6/N: N = (index + 1) * 2
|
||||
// index 3 → (3+1)*2 = 8, index 4 → (4+1)*2 = 10
|
||||
const parts3 = parse(cfi3);
|
||||
expect(parts3.parent[0][0].index).toBe(6);
|
||||
expect(parts3.parent[0][1].index).toBe(8);
|
||||
|
||||
const parts4 = parse(cfi4);
|
||||
expect(parts4.parent[0][0].index).toBe(6);
|
||||
expect(parts4.parent[0][1].index).toBe(10);
|
||||
});
|
||||
|
||||
// ---------- Out-of-bounds CFI indices -------------------------------------
|
||||
|
||||
it('should return null when CFI child indices exceed the DOM', () => {
|
||||
const cfi = 'epubcfi(/6/8!/4/4,/9000/1:0,/9002/1:5)';
|
||||
const parts = shiftSpine(parse(cfi));
|
||||
const range = toRange(doc3, parts);
|
||||
expect(range).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for a simple CFI with an unreachable node', () => {
|
||||
const cfi = 'epubcfi(/6/8!/4/9000/1:0)';
|
||||
const parts = shiftSpine(parse(cfi));
|
||||
const range = toRange(doc3, parts);
|
||||
expect(range).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when start resolves but end does not', () => {
|
||||
const cfi = 'epubcfi(/6/8!/4/4,/2/1:0,/9999/1:5)';
|
||||
const parts = shiftSpine(parse(cfi));
|
||||
const range = toRange(doc3, parts);
|
||||
expect(range).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve, join } from 'path';
|
||||
import { textWalker } from 'foliate-js/text-walker.js';
|
||||
import { TTS } from 'foliate-js/tts.js';
|
||||
import { createRejectFilter } from '@/utils/node';
|
||||
import { DocumentLoader } from '@/libs/document';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
|
||||
// The @pdfjs alias in vitest.config.mts resolves to public/vendor/pdfjs,
|
||||
// mirroring how foliate-js/pdf.js does `import '@pdfjs/pdf.min.mjs'`.
|
||||
const vendorDir = join(process.cwd(), 'public/vendor');
|
||||
|
||||
/** Strip all XML/SSML tags to get plain text content */
|
||||
const stripTags = (ssml: string): string => ssml.replace(/<[^>]+\/?>/g, '').trim();
|
||||
|
||||
const highlight = vi.fn();
|
||||
|
||||
/**
|
||||
* Build a document that mimics a rendered PDF page with text layer,
|
||||
* matching the structure that pdf.js produces in the iframe.
|
||||
*/
|
||||
const createPDFTextLayerDoc = (textSpans: string[], annotationText?: string): Document => {
|
||||
const parser = new DOMParser();
|
||||
const spans = textSpans.map((t) => `<span>${t}</span>`).join('');
|
||||
const annotation = annotationText
|
||||
? `<div class="annotationLayer"><a href="#">${annotationText}</a></div>`
|
||||
: '<div class="annotationLayer"></div>';
|
||||
const html =
|
||||
`<!DOCTYPE html><html lang="en">` +
|
||||
`<body>` +
|
||||
`<div id="canvas"><canvas></canvas></div>` +
|
||||
`<div class="textLayer">${spans}</div>` +
|
||||
`${annotation}` +
|
||||
`</body></html>`;
|
||||
return parser.parseFromString(html, 'text/html');
|
||||
};
|
||||
|
||||
/** Node filter matching what TTSController uses for PDFs */
|
||||
const pdfNodeFilter = createRejectFilter({
|
||||
tags: ['rt', 'canvas'],
|
||||
classes: ['annotationLayer'],
|
||||
contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/ }],
|
||||
});
|
||||
|
||||
describe('PDF TTS', () => {
|
||||
describe('TTS with PDF text layer document', () => {
|
||||
it('should generate SSML from text layer spans', () => {
|
||||
const doc = createPDFTextLayerDoc(['Alice was beginning to get very ', 'tired of sitting']);
|
||||
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
const text = stripTags(ssml!);
|
||||
expect(text).toContain('Alice');
|
||||
expect(text).toContain('tired');
|
||||
});
|
||||
|
||||
it('should filter out canvas content', () => {
|
||||
const doc = createPDFTextLayerDoc(['Hello world']);
|
||||
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(stripTags(ssml!)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should filter out annotation layer text', () => {
|
||||
const doc = createPDFTextLayerDoc(['Main text content'], 'Link annotation text');
|
||||
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
|
||||
|
||||
const allText: string[] = [];
|
||||
let ssml = tts.start();
|
||||
while (ssml) {
|
||||
allText.push(stripTags(ssml));
|
||||
ssml = tts.next();
|
||||
}
|
||||
const combined = allText.join(' ');
|
||||
|
||||
expect(combined).toContain('Main text content');
|
||||
expect(combined).not.toContain('Link annotation text');
|
||||
});
|
||||
|
||||
it('should produce valid SSML with speak root element', () => {
|
||||
const doc = createPDFTextLayerDoc(['Test content']);
|
||||
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(ssml).toContain('<speak');
|
||||
expect(ssml).toContain('</speak>');
|
||||
});
|
||||
|
||||
it('should include mark elements in SSML output', () => {
|
||||
const doc = createPDFTextLayerDoc(['Some text with multiple words']);
|
||||
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(ssml).toContain('<mark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PDF node filter', () => {
|
||||
it('should reject canvas elements', () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
expect(pdfNodeFilter(canvas)).toBe(NodeFilter.FILTER_REJECT);
|
||||
});
|
||||
|
||||
it('should reject annotationLayer elements', () => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'annotationLayer';
|
||||
expect(pdfNodeFilter(div)).toBe(NodeFilter.FILTER_REJECT);
|
||||
});
|
||||
|
||||
it('should reject rt elements', () => {
|
||||
const rt = document.createElement('rt');
|
||||
expect(pdfNodeFilter(rt)).toBe(NodeFilter.FILTER_REJECT);
|
||||
});
|
||||
|
||||
it('should skip regular div elements', () => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'textLayer';
|
||||
expect(pdfNodeFilter(div)).toBe(NodeFilter.FILTER_SKIP);
|
||||
});
|
||||
|
||||
it('should accept text nodes', () => {
|
||||
const text = document.createTextNode('hello');
|
||||
expect(pdfNodeFilter(text)).toBe(NodeFilter.FILTER_ACCEPT);
|
||||
});
|
||||
|
||||
it('should reject footnote-like anchor content', () => {
|
||||
const a = document.createElement('a');
|
||||
a.textContent = '[1]';
|
||||
expect(pdfNodeFilter(a)).toBe(NodeFilter.FILTER_REJECT);
|
||||
});
|
||||
|
||||
it('should not reject normal anchor content', () => {
|
||||
const a = document.createElement('a');
|
||||
a.textContent = 'Click here for more';
|
||||
expect(pdfNodeFilter(a)).toBe(NodeFilter.FILTER_SKIP);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DocumentLoader with sample-alice.pdf', () => {
|
||||
let book: BookDoc;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Override workerSrc to an absolute file path so the pdfjs fake-worker
|
||||
// can import it inside jsdom (the module-level code in pdf.js sets it
|
||||
// to a URL path that only works in a real browser).
|
||||
// Import pdf.js first to trigger the @pdfjs side-effect that sets globalThis.pdfjsLib.
|
||||
await import('foliate-js/pdf.js');
|
||||
const pdfjsLib = (globalThis as Record<string, unknown>)['pdfjsLib'] as {
|
||||
GlobalWorkerOptions: { workerSrc: string };
|
||||
};
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
`file://${join(vendorDir, 'pdfjs/pdf.worker.min.mjs')}`,
|
||||
).href;
|
||||
|
||||
const pdfPath = resolve(__dirname, '../fixtures/data/sample-alice.pdf');
|
||||
const buffer = readFileSync(pdfPath);
|
||||
const file = new File([buffer], 'sample-alice.pdf', { type: 'application/pdf' });
|
||||
const loader = new DocumentLoader(file);
|
||||
const result = await loader.open();
|
||||
book = result.book;
|
||||
expect(result.format).toBe('PDF');
|
||||
}, 30_000);
|
||||
|
||||
it('should load the sample PDF and return a book object', () => {
|
||||
expect(book).toBeTruthy();
|
||||
expect(book.rendition.layout).toBe('pre-paginated');
|
||||
});
|
||||
|
||||
it('should have sections matching the number of pages', () => {
|
||||
expect(book.sections).toBeTruthy();
|
||||
expect(book.sections.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should extract metadata', () => {
|
||||
expect(book.metadata).toBeTruthy();
|
||||
// sample-alice.pdf should have a title
|
||||
expect(book.metadata.title).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should provide createDocument on every section', () => {
|
||||
for (const section of book.sections) {
|
||||
expect(typeof section.createDocument).toBe('function');
|
||||
}
|
||||
});
|
||||
|
||||
it('should generate TTS SSML from createDocument output', async () => {
|
||||
const doc = await book.sections[0]!.createDocument();
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(ssml).toContain('<speak');
|
||||
expect(ssml).toContain('<mark');
|
||||
|
||||
const text = stripTags(ssml!);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should navigate through all TTS blocks of a page', async () => {
|
||||
const doc = await book.sections[0]!.createDocument();
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
|
||||
const blocks: string[] = [];
|
||||
let ssml = tts.start();
|
||||
while (ssml) {
|
||||
blocks.push(stripTags(ssml));
|
||||
ssml = tts.next();
|
||||
}
|
||||
|
||||
expect(blocks.length).toBeGreaterThan(0);
|
||||
for (const block of blocks) {
|
||||
expect(block.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should produce createDocument output for multiple pages', async () => {
|
||||
const pagesToTest = Math.min(book.sections.length, 3);
|
||||
|
||||
for (let i = 0; i < pagesToTest; i++) {
|
||||
const doc = await book.sections[i]!.createDocument();
|
||||
expect(doc).toBeTruthy();
|
||||
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(stripTags(ssml!).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return consistent text across repeated createDocument calls', async () => {
|
||||
const doc1 = await book.sections[0]!.createDocument();
|
||||
const doc2 = await book.sections[0]!.createDocument();
|
||||
|
||||
const tts1 = new TTS(doc1, textWalker, undefined, highlight, 'word');
|
||||
const tts2 = new TTS(doc2, textWalker, undefined, highlight, 'word');
|
||||
|
||||
expect(stripTags(tts1.start()!)).toBe(stripTags(tts2.start()!));
|
||||
});
|
||||
|
||||
it('should work with sentence granularity on real PDF content', async () => {
|
||||
const doc = await book.sections[0]!.createDocument();
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'sentence');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(stripTags(ssml!).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should call highlight callback when marking words from PDF', async () => {
|
||||
highlight.mockClear();
|
||||
const doc = await book.sections[0]!.createDocument();
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
tts.start();
|
||||
|
||||
const range = tts.setMark('0');
|
||||
expect(range).toBeTruthy();
|
||||
expect(highlight).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { textWalker } from 'foliate-js/text-walker.js';
|
||||
import { TTS } from 'foliate-js/tts.js';
|
||||
|
||||
const createHTMLDoc = (bodyHTML: string, attrs: Record<string, string> = {}): Document => {
|
||||
const parser = new DOMParser();
|
||||
const attrStr = Object.entries(attrs)
|
||||
.map(([k, v]) => ` ${k}="${v}"`)
|
||||
.join('');
|
||||
const html = `<!DOCTYPE html><html${attrStr}><body>${bodyHTML}</body></html>`;
|
||||
return parser.parseFromString(html, 'text/html');
|
||||
};
|
||||
|
||||
const createXHTMLDoc = (bodyHTML: string, attrs: Record<string, string> = {}): Document => {
|
||||
const parser = new DOMParser();
|
||||
const attrStr = Object.entries(attrs)
|
||||
.map(([k, v]) => ` ${k}="${v}"`)
|
||||
.join('');
|
||||
const xml =
|
||||
`<?xml version="1.0" encoding="UTF-8"?>` +
|
||||
`<html${attrStr}><head><title></title></head><body>${bodyHTML}</body></html>`;
|
||||
return parser.parseFromString(xml, 'application/xhtml+xml');
|
||||
};
|
||||
|
||||
const createPlainHTMLDoc = (html: string): Document => {
|
||||
const parser = new DOMParser();
|
||||
return parser.parseFromString(html, 'text/html');
|
||||
};
|
||||
|
||||
/** Strip all XML tags to get plain text content from SSML */
|
||||
const stripTags = (ssml: string): string => ssml.replace(/<[^>]+\/?>/g, '').trim();
|
||||
|
||||
const highlight = vi.fn();
|
||||
|
||||
describe('TTS', () => {
|
||||
describe('plain HTML document', () => {
|
||||
it('should init and generate SSML for a plain HTML doc without doctype or lang', () => {
|
||||
const doc = createPlainHTMLDoc(`<html><head></head><body><p>Hello world</p></body></html>`);
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(stripTags(ssml!)).toBe('Hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('document with lang attribute', () => {
|
||||
it('should init and generate SSML with lang on html element', () => {
|
||||
const doc = createHTMLDoc('<p>Hello world</p>', { lang: 'en' });
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(stripTags(ssml!)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should init and generate SSML with xml:lang on XHTML element', () => {
|
||||
const doc = createXHTMLDoc('<p>Hello world</p>', {
|
||||
xmlns: 'http://www.w3.org/1999/xhtml',
|
||||
'xml:lang': 'en',
|
||||
});
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(stripTags(ssml!)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should propagate lang into SSML speak element', () => {
|
||||
const doc = createHTMLDoc('<p>Hello world</p>', { lang: 'fr' });
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(ssml).toContain('xml:lang="fr"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('document without lang or xml:lang', () => {
|
||||
it('should init and generate SSML for HTML doc without any lang', () => {
|
||||
const doc = createHTMLDoc('<p>Hello world</p>');
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(stripTags(ssml!)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should init and generate SSML for XHTML doc with xmlns but no lang', () => {
|
||||
const doc = createXHTMLDoc('<p>Hello world</p>', {
|
||||
xmlns: 'http://www.w3.org/1999/xhtml',
|
||||
});
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(stripTags(ssml!)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should navigate with next() on doc without lang', () => {
|
||||
const doc = createHTMLDoc('<p>First paragraph</p><p>Second paragraph</p>');
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml1 = tts.start();
|
||||
const ssml2 = tts.next();
|
||||
|
||||
expect(ssml1).toBeTruthy();
|
||||
expect(stripTags(ssml1!)).toContain('First paragraph');
|
||||
if (ssml2) {
|
||||
expect(stripTags(ssml2)).toContain('Second paragraph');
|
||||
}
|
||||
});
|
||||
|
||||
it('should generate SSML with sentence granularity on doc without lang', () => {
|
||||
const doc = createHTMLDoc('<p>First sentence. Second sentence.</p>');
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'sentence');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(stripTags(ssml!)).toContain('First sentence');
|
||||
});
|
||||
|
||||
it('should not include xml:lang on speak element when doc has no lang', () => {
|
||||
const doc = createHTMLDoc('<p>No lang content</p>');
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
const speakMatch = ssml!.match(/<speak[^>]*>/);
|
||||
expect(speakMatch).toBeTruthy();
|
||||
expect(speakMatch![0]).not.toContain('xml:lang');
|
||||
});
|
||||
});
|
||||
|
||||
describe('document without xmlns namespace declarations', () => {
|
||||
it('should init and generate SSML for XHTML doc without xmlns', () => {
|
||||
// XHTML without xmlns="http://www.w3.org/1999/xhtml" causes doc.body to be null
|
||||
const doc = createXHTMLDoc('<p>Hello world</p>');
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(stripTags(ssml!)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should init and generate SSML for XHTML doc without xmlns but with xml:lang', () => {
|
||||
const doc = createXHTMLDoc('<p>Hello world</p>', {
|
||||
'xml:lang': 'en',
|
||||
});
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(stripTags(ssml!)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should init and generate SSML for XHTML doc without epub namespace', () => {
|
||||
// Missing xmlns:epub="http://www.idpf.org/2007/ops" should not prevent TTS
|
||||
const doc = createXHTMLDoc('<p>Hello world</p>', {
|
||||
xmlns: 'http://www.w3.org/1999/xhtml',
|
||||
});
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(stripTags(ssml!)).toBe('Hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('document without both lang and xmlns', () => {
|
||||
it('should init and generate SSML for bare XHTML doc', () => {
|
||||
// No xmlns and no lang: both issues combined
|
||||
const doc = createXHTMLDoc('<p>Bare document content</p>');
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(stripTags(ssml!)).toBe('Bare document content');
|
||||
});
|
||||
|
||||
it('should handle multiple blocks in bare XHTML doc', () => {
|
||||
const doc = createXHTMLDoc('<p>First block</p><div>Second block</div><p>Third block</p>');
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml1 = tts.start();
|
||||
expect(ssml1).toBeTruthy();
|
||||
|
||||
const ssml2 = tts.next();
|
||||
if (ssml2) {
|
||||
expect(ssml2).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSML output correctness', () => {
|
||||
it('should produce valid SSML with speak root element', () => {
|
||||
const doc = createHTMLDoc('<p>Test content</p>', { lang: 'en' });
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(ssml).toContain('<speak');
|
||||
expect(ssml).toContain('</speak>');
|
||||
});
|
||||
|
||||
it('should include mark elements in SSML output', () => {
|
||||
const doc = createHTMLDoc('<p>Some text with words</p>', { lang: 'en' });
|
||||
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(ssml).toContain('<mark');
|
||||
});
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user