forked from akai/readest
Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ef126f820 | |||
| 1788b86ced | |||
| 3111e93054 | |||
| 288abc678c | |||
| 7e559c5b90 | |||
| eaaeb844f3 | |||
| 097fde203d | |||
| efd85e67d9 | |||
| bd4bf459e3 | |||
| 8fb1745dbb | |||
| 47c6d6d058 | |||
| 564693d456 | |||
| aa3cd2a0c5 | |||
| c581f8ab47 | |||
| 63ed763499 | |||
| 213fc0d21e | |||
| 9bc1494582 | |||
| 33b2ba1609 | |||
| b9add62ba7 | |||
| 23fa2dc8b2 | |||
| 67ac09a290 | |||
| f10c14ae96 | |||
| 438d3ae0c2 | |||
| 842062fe44 | |||
| d01897d62a | |||
| 6eeb8e7580 | |||
| 9cebadba57 | |||
| e5ec9abca3 | |||
| 5e7f09d5c8 | |||
| ffa193d116 | |||
| 686f8988bc | |||
| 8d5c98004b | |||
| 214411919f | |||
| 42c2825e90 | |||
| 8974a87168 | |||
| f3e9983742 | |||
| beaf034035 | |||
| a5f18ab024 | |||
| d1f5934947 | |||
| f03d6f6861 | |||
| 20523b7eaf | |||
| f31c9b2a98 | |||
| 93071e727f | |||
| 2d12210606 | |||
| 88ccc528d4 | |||
| 9d3fc07865 | |||
| 6d465a40fb | |||
| 01c7e4c2e2 | |||
| 4005858242 | |||
| c24f951375 | |||
| dbe1efd1e9 | |||
| 0299cce343 | |||
| ca9f504329 | |||
| df3527ceca | |||
| 73d0838291 | |||
| 8e3574509e | |||
| 9bdf375304 | |||
| effa922597 | |||
| fe484257a0 | |||
| cc0cd9a114 | |||
| 916d63806e | |||
| 59d0118067 | |||
| 957c6b9f32 | |||
| 9c5a1147fa | |||
| 60ad6706ff | |||
| d9199d1182 | |||
| 219edb9faf | |||
| 30a0227ef3 | |||
| 29b5a7cb88 | |||
| abb727870e | |||
| af84a8b110 | |||
| 0dea5d3094 | |||
| 527b95521e | |||
| 396b23ddb4 | |||
| ee0d1549bd | |||
| 6da6b85530 | |||
| cde055dcb7 | |||
| 08957ce3d7 | |||
| 33515f1f09 | |||
| e3c411f043 | |||
| eda71725da | |||
| f19f45c00e | |||
| 6fb7897092 | |||
| 2de941d7fb | |||
| 3e4b7ebb13 | |||
| 2b52f82b57 | |||
| 9fb7e80757 | |||
| 38869ce438 | |||
| 1dbf93c1cf | |||
| aafcab8232 | |||
| bcfe1b8830 | |||
| 5db1847f7e | |||
| 165ca4a6e9 | |||
| 9556e1305f |
@@ -32,6 +32,11 @@ jobs:
|
||||
|
||||
build_web_app:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
config:
|
||||
- platform: 'web'
|
||||
- platform: 'tauri'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -53,7 +58,19 @@ jobs:
|
||||
run: |
|
||||
pnpm install && pnpm setup-pdfjs
|
||||
|
||||
- name: build the web App
|
||||
- name: run tests
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm build-web
|
||||
pnpm test -- --watchAll=false
|
||||
|
||||
- name: build the web App
|
||||
if: matrix.config.platform == 'web'
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm build-web && pnpm check:all
|
||||
|
||||
- name: build the Tauri App
|
||||
if: matrix.config.platform == 'tauri'
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm build && pnpm check:all
|
||||
|
||||
@@ -38,3 +38,5 @@ next-env.d.ts
|
||||
|
||||
# Rust build
|
||||
target
|
||||
|
||||
fastlane/report.xml
|
||||
|
||||
@@ -4,3 +4,6 @@
|
||||
[submodule "packages/tauri"]
|
||||
path = packages/tauri
|
||||
url = https://github.com/readest/tauri.git
|
||||
[submodule "packages/tauri-plugins"]
|
||||
path = packages/tauri-plugins
|
||||
url = https://github.com/readest/tauri-plugins-workspace.git
|
||||
|
||||
+2
-2
@@ -3,14 +3,14 @@
|
||||
When contributing to `Readest`, whether on GitHub or in other community spaces:
|
||||
|
||||
- Be respectful, civil, and open-minded.
|
||||
- Before opening a new pull request, try searching through the [issue tracker](https://github.com/chrox/readest/issues) for known issues or fixes.
|
||||
- Before opening a new pull request, try searching through the [issue tracker](https://github.com/readest/readest/issues) for known issues or fixes.
|
||||
- If you want to make code changes based on your personal opinion(s), make sure you open an issue first describing the changes you want to make, and open a pull request only when your suggestions get approved by maintainers.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
### Prerequisites
|
||||
|
||||
In order to not waste your time implementing a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/chrox/readest/issues/new/choose) describing the problem you would like to solve.
|
||||
In order to not waste your time implementing a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/readest/readest/issues/new/choose) describing the problem you would like to solve.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Generated
+466
-272
File diff suppressed because it is too large
Load Diff
+26
-1
@@ -2,9 +2,29 @@
|
||||
members = [
|
||||
"apps/readest-app/src-tauri",
|
||||
"packages/tauri/crates/tauri",
|
||||
"packages/tauri/crates/tauri-utils",
|
||||
"packages/tauri/crates/tauri-build",
|
||||
"packages/tauri-plugins/plugins/fs",
|
||||
"packages/tauri-plugins/plugins/dialog",
|
||||
"packages/tauri-plugins/plugins/deep-link",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tracing = "0.1"
|
||||
log = "0.4"
|
||||
tauri = { version = "2", default-features = false }
|
||||
tauri-build = "2"
|
||||
tauri-plugin = "2"
|
||||
tauri-utils = "2"
|
||||
schemars = "0.8"
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
glob = "0.3"
|
||||
dunce = "1"
|
||||
url = "2"
|
||||
|
||||
[workspace.package]
|
||||
authors = ["Bilingify LLC"]
|
||||
homepage = "https://readest.com"
|
||||
@@ -15,4 +35,9 @@ edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
[patch.crates-io]
|
||||
tauri = { path = "packages/tauri/crates/tauri" }
|
||||
tauri = { path = "packages/tauri/crates/tauri" }
|
||||
tauri-utils = { path = "packages/tauri/crates/tauri-utils" }
|
||||
tauri-build = { path = "packages/tauri/crates/tauri-build" }
|
||||
tauri-plugin-fs = { path = "packages/tauri-plugins/plugins/fs" }
|
||||
tauri-plugin-dialog = { path = "packages/tauri-plugins/plugins/dialog" }
|
||||
tauri-plugin-deep-link = { path = "packages/tauri-plugins/plugins/deep-link" }
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<br>
|
||||
[![Last commit][badge-last-commit]][link-gh-commits]
|
||||
[![Commits][badge-commit-activity]][link-gh-pulse]
|
||||
[![Ask DeepWiki][badge-deepwiki]][link-deepwiki]
|
||||
|
||||
</div>
|
||||
|
||||
@@ -56,6 +57,7 @@
|
||||
| **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. | ✅ |
|
||||
| **Translate with DeepL** | From a single sentence to the entire book—translate instantly with DeepL. | ✅ |
|
||||
| **Translate with Yandex** | Instantly translate text or books using Yandex Translate. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
|
||||
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
|
||||
@@ -165,6 +167,8 @@ For Windows targets, “Build Tools for Visual Studio 2022” (or a higher editi
|
||||
pnpm tauri dev
|
||||
# or start development for the Web app
|
||||
pnpm dev-web
|
||||
# preview with OpenNext build for the Web app
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
For Android:
|
||||
@@ -304,6 +308,7 @@ The following fonts are utilized in this software, either bundled within the app
|
||||
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
|
||||
[badge-hellogithub]: https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8a5b6ade2aee461a8bd94e59200682a7&claim_uid=eRLUbPOy2qZtDgw&theme=small
|
||||
[badge-donate]: https://donate.readest.com/badge.svg
|
||||
[badge-deepwiki]: https://deepwiki.com/badge.svg
|
||||
[link-donate]: https://donate.readest.com/?tickers=btc%2Ceth%2Csol%2Cusdc
|
||||
[link-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
|
||||
[link-website]: https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme
|
||||
@@ -316,3 +321,4 @@ The following fonts are utilized in this software, either bundled within the app
|
||||
[link-parallel-read]: https://readest.com/#parallel-read
|
||||
[link-koreader]: https://github.com/koreader/koreader
|
||||
[link-hellogithub]: https://hellogithub.com/repository/8a5b6ade2aee461a8bd94e59200682a7
|
||||
[link-deepwiki]: https://deepwiki.com/readest/readest
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import withPWAInit from '@ducanh2912/next-pwa';
|
||||
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
|
||||
import withBundleAnalyzer from '@next/bundle-analyzer';
|
||||
|
||||
const isDev = process.env['NODE_ENV'] === 'development';
|
||||
const appPlatform = process.env['NEXT_PUBLIC_APP_PLATFORM'];
|
||||
|
||||
if (isDev) {
|
||||
const { initOpenNextCloudflareForDev } = await import('@opennextjs/cloudflare');
|
||||
initOpenNextCloudflareForDev();
|
||||
}
|
||||
|
||||
@@ -25,6 +26,19 @@ const nextConfig = {
|
||||
// Configure assetPrefix or else the server won't properly resolve your assets.
|
||||
assetPrefix: '',
|
||||
reactStrictMode: true,
|
||||
serverExternalPackages: ['isows'],
|
||||
transpilePackages: !isDev
|
||||
? [
|
||||
'i18next-browser-languagedetector',
|
||||
'react-i18next',
|
||||
'i18next',
|
||||
'@ducanh2912/next-pwa',
|
||||
'@tauri-apps',
|
||||
'highlight.js',
|
||||
'foliate-js',
|
||||
'marked',
|
||||
]
|
||||
: [],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
@@ -55,4 +69,8 @@ const withPWA = withPWAInit({
|
||||
},
|
||||
});
|
||||
|
||||
export default withPWA(nextConfig);
|
||||
const withAnalyzer = withBundleAnalyzer({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
});
|
||||
|
||||
export default withPWA(withAnalyzer(nextConfig));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.63",
|
||||
"version": "0.9.69",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -11,6 +11,7 @@
|
||||
"start-web": "dotenv -e .env.web -- next start",
|
||||
"i18n:extract": "i18next-scanner",
|
||||
"lint": "next lint",
|
||||
"test": "dotenv -e .env -e .env.test.local vitest",
|
||||
"tauri": "tauri",
|
||||
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs",
|
||||
"copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
|
||||
@@ -32,46 +33,53 @@
|
||||
"release-ios-appstore": "dotenv -e .env.ios-appstore.local -- bash scripts/release-ios-appstore.sh",
|
||||
"release-google-play": "dotenv -e .env.google-play.local -- bash scripts/release-google-play.sh",
|
||||
"config-wrangler": "sed -i \"s/\\${TRANSLATIONS_KV_ID}/$TRANSLATIONS_KV_ID/g\" wrangler.toml",
|
||||
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
||||
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare preview --ip 0.0.0.0",
|
||||
"deploy": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
||||
"upload": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare upload",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
|
||||
"check:optional-chaining": "count=$(grep -rno '\\?\\.[a-zA-Z_$]' .next/static/chunks/* out/_next/static/chunks/* | wc -l); if [ \"$count\" -gt 0 ]; then echo '❌ Optional chaining found in output!'; exit 1; else echo '✅ No optional chaining found.'; fi",
|
||||
"check:all": "pnpm check:optional-chaining",
|
||||
"build-check": "pnpm build && pnpm build-web && pnpm check:all"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.735.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.735.0",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@fabianlars/tauri-plugin-oauth": "2",
|
||||
"@opennextjs/cloudflare": "^1.3.1",
|
||||
"@opennextjs/cloudflare": "^1.6.1",
|
||||
"@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.50.2",
|
||||
"@tauri-apps/api": "2.6.0",
|
||||
"@tauri-apps/plugin-cli": "^2.3.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.3.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.2",
|
||||
"@tauri-apps/plugin-fs": "^2.3.0",
|
||||
"@tauri-apps/plugin-haptics": "^2.2.5",
|
||||
"@tauri-apps/plugin-http": "^2.4.4",
|
||||
"@tauri-apps/plugin-log": "^2.5.1",
|
||||
"@tauri-apps/plugin-opener": "^2.3.1",
|
||||
"@tauri-apps/plugin-os": "^2.2.2",
|
||||
"@tauri-apps/plugin-process": "^2.2.2",
|
||||
"@tauri-apps/plugin-shell": "~2.2.2",
|
||||
"@tauri-apps/plugin-updater": "^2.8.1",
|
||||
"@tauri-apps/plugin-cli": "^2.4.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.3.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
||||
"@tauri-apps/plugin-haptics": "^2.3.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.0",
|
||||
"@tauri-apps/plugin-log": "^2.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2.4.0",
|
||||
"@tauri-apps/plugin-os": "^2.3.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.0",
|
||||
"@tauri-apps/plugin-shell": "~2.3.0",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"@zip.js/zip.js": "^2.7.53",
|
||||
"app-store-server-api": "^0.17.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"cssbeautify": "^0.3.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"foliate-js": "workspace:*",
|
||||
"franc-min": "^6.2.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"iso-639-2": "^3.0.2",
|
||||
"iso-639-3": "^3.0.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"marked": "^15.0.12",
|
||||
@@ -89,20 +97,25 @@
|
||||
"semver": "^7.7.1",
|
||||
"stripe": "^18.2.1",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"zustand": "5.0.1"
|
||||
"zod": "^4.0.8",
|
||||
"zustand": "5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^15.4.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tauri-apps/cli": "2.6.0",
|
||||
"@tauri-apps/cli": "2.7.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/cssbeautify": "^0.3.5",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/node": "^22.15.31",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-color": "^3.0.13",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/tinycolor2": "^1.4.6",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cpx2": "^8.0.0",
|
||||
"daisyui": "^4.12.24",
|
||||
@@ -110,6 +123,7 @@
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-next": "15.0.3",
|
||||
"i18next-scanner": "^4.6.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"mkdirp": "^3.0.1",
|
||||
"node-env-run": "^4.0.2",
|
||||
"postcss": "^8.4.49",
|
||||
@@ -118,6 +132,8 @@
|
||||
"raw-loader": "^4.0.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"wrangler": "^4.21.0"
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.2.4",
|
||||
"wrangler": "^4.26.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "الاتجاه الأفقي",
|
||||
"Hyphenation": "الواصلة",
|
||||
"Import Books": "استيراد الكتب",
|
||||
"Language:": "اللغة:",
|
||||
"Layout": "التخطيط",
|
||||
"Light Mode": "الوضع الفاتح",
|
||||
"Loading...": "جارٍ التحميل...",
|
||||
@@ -67,8 +66,8 @@
|
||||
"Page": "الصفحة",
|
||||
"Paging Animation": "رسوم متحركةعند الانتقال إلى صفحة جديدة",
|
||||
"Paragraph": "فقرة",
|
||||
"Published:": "تاريخ النشر:",
|
||||
"Publisher:": "الناشر:",
|
||||
"Published": "تاريخ النشر",
|
||||
"Publisher": "الناشر",
|
||||
"Reading Progress Synced": "تمت مزامنة تقدم القراءة",
|
||||
"Reload Page": "إعادة تحميل الصفحة",
|
||||
"Reveal in File Explorer": "إظهار في مستكشف الملفات",
|
||||
@@ -93,7 +92,7 @@
|
||||
"Slow": "بطيء",
|
||||
"Solarized": "سولاريزد",
|
||||
"Speak": "تحدث",
|
||||
"Subjects:": "المواضيع:",
|
||||
"Subjects": "المواضيع",
|
||||
"System Fonts": "خطوط النظام",
|
||||
"TTS not supported in this device": "القراءة الصوتية غير مدعومة في هذا الجهاز",
|
||||
"Theme Color": "لون السمة",
|
||||
@@ -102,7 +101,7 @@
|
||||
"Translated Text": "النص المترجم",
|
||||
"Unknown": "غير معروف",
|
||||
"Untitled": "بدون عنوان",
|
||||
"Updated:": "تم التحديث:",
|
||||
"Updated": "تم التحديث",
|
||||
"Version {{version}}": "الإصدار {{version}}",
|
||||
"Vertical Direction": "الاتجاه العمودي",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "مرحبًا بكم في مكتبتكم. يمكنكم استيراد كتبكم هنا وقراءتها في أي وقت.",
|
||||
@@ -243,7 +242,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "جارٍ تنزيل {{downloaded}} من {{contentLength}}",
|
||||
"Download finished": "اكتمل التنزيل",
|
||||
"DOWNLOAD & INSTALL": "تنزيل وتثبيت",
|
||||
"Changelog:": "سجل التغييرات:",
|
||||
"Changelog": "سجل التغييرات",
|
||||
"Software Update": "تحديث البرنامج",
|
||||
"Title": "العنوان",
|
||||
"Date Read": "تاريخ القراءة",
|
||||
@@ -252,10 +251,9 @@
|
||||
"Ascending": "تصاعدي",
|
||||
"Descending": "تنازلي",
|
||||
"Sort by...": "ترتيب حسب...",
|
||||
"Added:": "تاريخ الإضافة:",
|
||||
"Format:": "التنسيق:",
|
||||
"Added": "تاريخ الإضافة",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Description:": "الوصف:",
|
||||
"Description": "الوصف",
|
||||
"No description available": "لا يوجد وصف متاح",
|
||||
"List": "قائمة",
|
||||
"Grid": "شبكة",
|
||||
@@ -272,7 +270,7 @@
|
||||
"Checking for updates...": "جارٍ التحقق من التحديثات...",
|
||||
"Error checking for updates": "خطأ في التحقق من التحديثات",
|
||||
"Details": "التفاصيل",
|
||||
"File Size:": "حجم الملف:",
|
||||
"File Size": "حجم الملف",
|
||||
"Auto Detect": "الكشف التلقائي",
|
||||
"Next Section": "القسم التالي",
|
||||
"Previous Section": "القسم السابق",
|
||||
@@ -374,7 +372,6 @@
|
||||
"Upgrade to Plus or Pro": "الترقية إلى Plus أو Pro",
|
||||
"Current Plan": "الخطة الحالية",
|
||||
"Plan Limits": "حدود الخطة",
|
||||
"Failed to manage subscription. Please try again later.": "فشل في إدارة الاشتراك. يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
"Processing your payment...": "جارٍ معالجة الدفع...",
|
||||
"Please wait while we confirm your subscription.": "يرجى الانتظار بينما نقوم بتأكيد اشتراكك.",
|
||||
"Payment Processing": "معالجة الدفع",
|
||||
@@ -417,7 +414,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "استمع بلا حدود وحوّل النص لصوت بسهولة.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "استمتع بترجمة موسعة وخيارات متقدمة.",
|
||||
"DeepL Pro Access": "وصول DeepL Pro",
|
||||
"2 GB Cloud Sync Storage": "2 جيجابايت تخزين سحابي",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "احصل على دعم سريع ومخصص عند الحاجة.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "تم استهلاك حصة الترجمة اليومية. قم بالترقية للاستمرار.",
|
||||
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "ترجم أي نص فورًا بقوة Google أو Azure أو DeepL—افهم المحتوى بأي لغة.",
|
||||
@@ -428,10 +424,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "استخدم أدوات الذكاء الاصطناعي القوية للقراءة الذكية والترجمة واكتشاف المحتوى.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "ترجم حتى 100 ألف حرف يوميًا بأدق محرك ترجمة متاح.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "ترجم حتى 500 ألف حرف يوميًا بأدق محرك ترجمة متاح.",
|
||||
"10 GB Cloud Sync Storage": "10 جيجابايت تخزين سحابي",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "احفظ واوصل لمجموعة قراءتك كاملة بـ 2 جيجابايت تخزين سحابي آمن.",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "احفظ واوصل لمجموعة قراءتك كاملة بـ 10 جيجابايت تخزين سحابي آمن.",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "احفظ واوصل لمجموعة قراءتك كاملة بـ 5 جيجابايت تخزين سحابي آمن.",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "احفظ واوصل لمجموعة قراءتك كاملة بـ 20 جيجابايت تخزين سحابي آمن.",
|
||||
"Deleted cloud backup of the book: {{title}}": "تم حذف النسخة الاحتياطية السحابية للكتاب: {{title}}",
|
||||
"Failed to delete cloud backup of the book": "فشل في حذف النسخة الاحتياطية السحابية للكتاب",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "هل أنت متأكد من حذف النسخة الاحتياطية السحابية للكتاب المحدد؟"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "هل أنت متأكد من حذف النسخة الاحتياطية السحابية للكتاب المحدد؟",
|
||||
"What's New in Readest": "ما الجديد في Readest",
|
||||
"Enter book title": "أدخل عنوان الكتاب",
|
||||
"Subtitle": "العنوان الفرعي",
|
||||
"Enter book subtitle": "أدخل العنوان الفرعي للكتاب",
|
||||
"Enter author name": "أدخل اسم المؤلف",
|
||||
"Series": "السلسلة",
|
||||
"Enter series name": "أدخل اسم السلسلة",
|
||||
"Series Index": "رقم الكتاب في السلسلة",
|
||||
"Enter series index": "أدخل رقم الكتاب في السلسلة",
|
||||
"Total in Series": "العدد الكلي في السلسلة",
|
||||
"Enter total books in series": "أدخل العدد الكلي للكتب في السلسلة",
|
||||
"Enter publisher": "أدخل اسم الناشر",
|
||||
"Publication Date": "تاريخ النشر",
|
||||
"Identifier": "المعرف",
|
||||
"Enter book description": "أدخل وصف الكتاب",
|
||||
"Change cover image": "تغيير صورة الغلاف",
|
||||
"Replace": "استبدال",
|
||||
"Unlock cover": "إلغاء قفل الغلاف",
|
||||
"Lock cover": "قفل الغلاف",
|
||||
"Auto-Retrieve Metadata": "استرجاع البيانات الوصفية تلقائياً",
|
||||
"Auto-Retrieve": "استرجاع تلقائي",
|
||||
"Unlock all fields": "إلغاء قفل جميع الحقول",
|
||||
"Unlock All": "إلغاء قفل الكل",
|
||||
"Lock all fields": "قفل جميع الحقول",
|
||||
"Lock All": "قفل الكل",
|
||||
"Reset": "إعادة تعيين",
|
||||
"Edit Metadata": "تحرير البيانات الوصفية",
|
||||
"Locked": "مقفل",
|
||||
"Select Metadata Source": "اختر مصدر البيانات الوصفية",
|
||||
"Keep manual input": "الاحتفاظ بالإدخال اليدوي",
|
||||
"Google Books": "كتب جوجل",
|
||||
"Open Library": "المكتبة المفتوحة",
|
||||
"Fiction, Science, History": "خيال، علوم، تاريخ",
|
||||
"Select Cover Image": "اختر صورة الغلاف",
|
||||
"Open Book in New Window": "فتح الكتاب في نافذة جديدة",
|
||||
"Voices for {{lang}}": "الأصوات لـ {{lang}}",
|
||||
"Yandex Translate": "ترجمة Yandex",
|
||||
"YYYY or YYYY-MM-DD": "YYYY أو YYYY-MM-DD",
|
||||
"Restore Purchase": "استعادة الشراء",
|
||||
"No purchases found to restore.": "لا توجد مشتريات لاستعادتها.",
|
||||
"Failed to restore purchases.": "فشل في استعادة المشتريات.",
|
||||
"Failed to manage subscription.": "فشل في إدارة الاشتراك.",
|
||||
"Failed to load subscription plans.": "فشل في تحميل خطط الاشتراك.",
|
||||
"year": "سنة",
|
||||
"Failed to create checkout session": "فشل في إنشاء جلسة الدفع",
|
||||
"Storage": "التخزين",
|
||||
"Terms of Service": "شروط الخدمة",
|
||||
"Privacy Policy": "سياسة الخصوصية",
|
||||
"Disable Double Click": "تعطيل النقر المزدوج"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "Horizontale Richtung",
|
||||
"Hyphenation": "Silbentrennung",
|
||||
"Import Books": "Bücher importieren",
|
||||
"Language:": "Sprache:",
|
||||
"Layout": "Layout",
|
||||
"Light Mode": "Hellmodus",
|
||||
"Loading...": "Wird geladen...",
|
||||
@@ -67,8 +66,8 @@
|
||||
"Page": "Seite",
|
||||
"Paging Animation": "Blätter-Animation",
|
||||
"Paragraph": "Absatz",
|
||||
"Published:": "Veröffentlicht:",
|
||||
"Publisher:": "Verlag:",
|
||||
"Published": "Veröffentlicht",
|
||||
"Publisher": "Verlag",
|
||||
"Reading Progress Synced": "Lesevorgang synchronisiert",
|
||||
"Reload Page": "Seite neu laden",
|
||||
"Reveal in File Explorer": "Im Datei-Explorer anzeigen",
|
||||
@@ -93,7 +92,7 @@
|
||||
"Slow": "Langsam",
|
||||
"Solarized": "Solarisiert",
|
||||
"Speak": "Sprechen",
|
||||
"Subjects:": "Themen:",
|
||||
"Subjects": "Themen",
|
||||
"System Fonts": "System-Schriftarten",
|
||||
"TTS not supported in this device": "TTS wird auf diesem Gerät nicht unterstützt",
|
||||
"Theme Color": "Themenfarbe",
|
||||
@@ -102,7 +101,7 @@
|
||||
"Translated Text": "Übersetzter Text",
|
||||
"Unknown": "Unbekannt",
|
||||
"Untitled": "Ohne Titel",
|
||||
"Updated:": "Aktualisiert:",
|
||||
"Updated": "Aktualisiert",
|
||||
"Version {{version}}": "Version {{version}}",
|
||||
"Vertical Direction": "Vertikale Richtung",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Willkommen in Ihrer Bibliothek. Sie können hier Ihre Bücher importieren und jederzeit lesen.",
|
||||
@@ -243,7 +242,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Herunterladen {{downloaded}} von {{contentLength}}",
|
||||
"Download finished": "Download abgeschlossen",
|
||||
"DOWNLOAD & INSTALL": "HERUNTERLADEN & INSTALLIEREN",
|
||||
"Changelog:": "Änderungsprotokoll:",
|
||||
"Changelog": "Änderungsprotokoll",
|
||||
"Software Update": "Software-Update",
|
||||
"Title": "Titel",
|
||||
"Date Read": "Datum gelesen",
|
||||
@@ -252,10 +251,9 @@
|
||||
"Ascending": "Aufsteigend",
|
||||
"Descending": "Absteigend",
|
||||
"Sort by...": "Sortieren nach...",
|
||||
"Added:": "Hinzugefügt:",
|
||||
"Format:": "Format:",
|
||||
"Added": "Hinzugefügt",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Description:": "Beschreibung:",
|
||||
"Description": "Beschreibung",
|
||||
"No description available": "Keine Beschreibung verfügbar",
|
||||
"List": "Liste",
|
||||
"Grid": "Raster",
|
||||
@@ -272,7 +270,7 @@
|
||||
"Checking for updates...": "Auf Updates prüfen...",
|
||||
"Error checking for updates": "Fehler beim Überprüfen auf Updates",
|
||||
"Details": "Details",
|
||||
"File Size:": "Dateigröße:",
|
||||
"File Size": "Dateigröße",
|
||||
"Auto Detect": "Automatisch erkennen",
|
||||
"Next Section": "Nächster Abschnitt",
|
||||
"Previous Section": "Vorheriger Abschnitt",
|
||||
@@ -358,7 +356,6 @@
|
||||
"Upgrade to Plus or Pro": "Upgrade auf Plus oder Pro",
|
||||
"Current Plan": "Aktueller Tarif",
|
||||
"Plan Limits": "Tariflimits",
|
||||
"Failed to manage subscription. Please try again later.": "Verwaltung des Abos fehlgeschlagen. Bitte versuchen Sie es später erneut.",
|
||||
"Processing your payment...": "Zahlung wird verarbeitet...",
|
||||
"Please wait while we confirm your subscription.": "Bitte warten Sie, während wir Ihr Abo bestätigen.",
|
||||
"Payment Processing": "Zahlung wird verarbeitet",
|
||||
@@ -400,7 +397,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "Beliebig viel Text in Audio umwandeln.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Erweitere Übersetzungen und tägliches Kontingent.",
|
||||
"DeepL Pro Access": "DeepL Pro-Zugang",
|
||||
"2 GB Cloud Sync Storage": "2 GB Cloud-Speicher",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "Erhalte schnellen, persönlichen Support.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Tageslimit erreicht. Upgrade nötig, um weiter zu übersetzen.",
|
||||
"Personalize every detail with adjustable fonts, layouts, themes, and advanced display settings for the perfect reading experience.": "Personalisieren Sie jedes Detail mit anpassbaren Schriften, Layouts, Themes und erweiterten Anzeigeeinstellungen für das perfekte Leseerlebnis.",
|
||||
@@ -412,10 +408,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Nutzen Sie leistungsstarke KI-Tools für intelligenteres Lesen, Übersetzen und Content-Discovery.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Übersetzen Sie täglich bis zu 100.000 Zeichen mit der genauesten verfügbaren Übersetzungsmaschine.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Übersetzen Sie täglich bis zu 500.000 Zeichen mit der genauesten verfügbaren Übersetzungsmaschine.",
|
||||
"10 GB Cloud Sync Storage": "10 GB Cloud-Sync-Speicher",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Speichern und greifen Sie sicher auf Ihre gesamte Lesekollektion mit bis zu 2 GB Cloud-Speicher zu.",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Speichern und greifen Sie sicher auf Ihre gesamte Lesekollektion mit bis zu 10 GB Cloud-Speicher zu.",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Speichern und greifen Sie sicher auf Ihre gesamte Lesekollektion mit bis zu 5 GB Cloud-Speicher zu.",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Speichern und greifen Sie sicher auf Ihre gesamte Lesekollektion mit bis zu 20 GB Cloud-Speicher zu.",
|
||||
"Deleted cloud backup of the book: {{title}}": "Gelöschte Cloud-Sicherung des Buches: {{title}}",
|
||||
"Failed to delete cloud backup of the book": "Fehler beim Löschen der Cloud-Sicherung des Buches",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Sind Sie sicher, dass Sie die Cloud-Sicherung des ausgewählten Buches löschen möchten?"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Sind Sie sicher, dass Sie die Cloud-Sicherung des ausgewählten Buches löschen möchten?",
|
||||
"What's New in Readest": "Was gibt es Neues in Readest",
|
||||
"Enter book title": "Buchtitel eingeben",
|
||||
"Subtitle": "Untertitel",
|
||||
"Enter book subtitle": "Buchuntertitel eingeben",
|
||||
"Enter author name": "Autorennamen eingeben",
|
||||
"Series": "Serie",
|
||||
"Enter series name": "Seriennamen eingeben",
|
||||
"Series Index": "Seriennummer",
|
||||
"Enter series index": "Seriennummer eingeben",
|
||||
"Total in Series": "Gesamt in Serie",
|
||||
"Enter total books in series": "Gesamtanzahl der Bücher in Serie eingeben",
|
||||
"Enter publisher": "Verlag eingeben",
|
||||
"Publication Date": "Veröffentlichungsdatum",
|
||||
"Identifier": "Identifikator",
|
||||
"Enter book description": "Buchbeschreibung eingeben",
|
||||
"Change cover image": "Titelbild ändern",
|
||||
"Replace": "Ersetzen",
|
||||
"Unlock cover": "Titelbild entsperren",
|
||||
"Lock cover": "Titelbild sperren",
|
||||
"Auto-Retrieve Metadata": "Metadaten automatisch abrufen",
|
||||
"Auto-Retrieve": "Automatisch abrufen",
|
||||
"Unlock all fields": "Alle Felder entsperren",
|
||||
"Unlock All": "Alle entsperren",
|
||||
"Lock all fields": "Alle Felder sperren",
|
||||
"Lock All": "Alle sperren",
|
||||
"Reset": "Zurücksetzen",
|
||||
"Edit Metadata": "Metadaten bearbeiten",
|
||||
"Locked": "Gesperrt",
|
||||
"Select Metadata Source": "Metadatenquelle auswählen",
|
||||
"Keep manual input": "Manuelle Eingabe beibehalten",
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Belletristik, Wissenschaft, Geschichte",
|
||||
"Select Cover Image": "Coverbild auswählen",
|
||||
"Open Book in New Window": "Buch in neuem Fenster öffnen",
|
||||
"Voices for {{lang}}": "Stimmen für {{lang}}",
|
||||
"Yandex Translate": "Yandex Übersetzer",
|
||||
"YYYY or YYYY-MM-DD": "YYYY oder YYYY-MM-DD",
|
||||
"Restore Purchase": "Kauf wiederherstellen",
|
||||
"No purchases found to restore.": "Keine Käufe zum Wiederherstellen gefunden.",
|
||||
"Failed to restore purchases.": "Wiederherstellung der Käufe fehlgeschlagen.",
|
||||
"Failed to manage subscription.": "Verwaltung des Abonnements fehlgeschlagen.",
|
||||
"Failed to load subscription plans.": "Laden der Abonnementpläne fehlgeschlagen.",
|
||||
"year": "Jahr",
|
||||
"Failed to create checkout session": "Fehler beim Erstellen der Checkout-Sitzung",
|
||||
"Storage": "Speicher",
|
||||
"Terms of Service": "Allgemeine Geschäftsbedingungen",
|
||||
"Privacy Policy": "Datenschutzbestimmungen",
|
||||
"Disable Double Click": "Doppelklick deaktivieren"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "Οριζόντια κατεύθυνση",
|
||||
"Hyphenation": "Συλλαβισμός",
|
||||
"Import Books": "Εισαγωγή βιβλίων",
|
||||
"Language:": "Γλώσσα:",
|
||||
"Layout": "Διάταξη",
|
||||
"Light Mode": "Φωτεινή λειτουργία",
|
||||
"Loading...": "Φόρτωση...",
|
||||
@@ -68,8 +67,8 @@
|
||||
"Paging Animation": "Κινούμενα σχέδια σελιδοποίησης",
|
||||
"Paragraph": "Παράγραφος",
|
||||
"Parallel Read": "Παράλληλη ανάγνωση",
|
||||
"Published:": "Δημοσιεύτηκε:",
|
||||
"Publisher:": "Εκδότης:",
|
||||
"Published": "Δημοσιεύτηκε",
|
||||
"Publisher": "Εκδότης",
|
||||
"Reading Progress Synced": "Ο συγχρονισμός προόδου ανάγνωσης ολοκληρώθηκε",
|
||||
"Reload Page": "Επαναφόρτωση σελίδας",
|
||||
"Reveal in File Explorer": "Εμφάνιση στον εξερευνητή αρχείων",
|
||||
@@ -94,7 +93,7 @@
|
||||
"Slow": "Αργά",
|
||||
"Solarized": "Solarized",
|
||||
"Speak": "Ομιλία",
|
||||
"Subjects:": "Θέματα:",
|
||||
"Subjects": "Θέματα",
|
||||
"System Fonts": "Γραμματοσειρές συστήματος",
|
||||
"TTS not supported in this device": "Η μετατροπή κειμένου σε ομιλία δεν υποστηρίζεται σε αυτή τη συσκευή",
|
||||
"Theme Color": "Χρώμα θέματος",
|
||||
@@ -103,7 +102,7 @@
|
||||
"Translated Text": "Μεταφρασμένο κείμενο",
|
||||
"Unknown": "Άγνωστο",
|
||||
"Untitled": "Χωρίς τίτλο",
|
||||
"Updated:": "Ενημερώθηκε:",
|
||||
"Updated": "Ενημερώθηκε",
|
||||
"Version {{version}}": "Έκδοση {{version}}",
|
||||
"Vertical Direction": "Κάθετη κατεύθυνση",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Καλώς ήρθατε στη βιβλιοθήκη σας. Μπορείτε να εισάγετε τα βιβλία σας εδώ και να τα διαβάσετε οποιαδήποτε στιγμή.",
|
||||
@@ -244,7 +243,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Κατεβάζω {{downloaded}} από {{contentLength}}",
|
||||
"Download finished": "Η λήψη ολοκληρώθηκε",
|
||||
"DOWNLOAD & INSTALL": "ΛΗΨΗ & ΕΓΚΑΤΑΣΤΑΣΗ",
|
||||
"Changelog:": "Αλλαγές:",
|
||||
"Changelog": "Αλλαγές",
|
||||
"Software Update": "Ενημέρωση λογισμικού",
|
||||
"Title": "Τίτλος",
|
||||
"Date Read": "Ημερομηνία ανάγνωσης",
|
||||
@@ -253,10 +252,9 @@
|
||||
"Ascending": "Αύξουσα",
|
||||
"Descending": "Φθίνουσα",
|
||||
"Sort by...": "Ταξινόμηση κατά...",
|
||||
"Added:": "Προστέθηκε:",
|
||||
"Format:": "Μορφή:",
|
||||
"Added": "Προστέθηκε",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Description:": "Περιγραφή:",
|
||||
"Description": "Περιγραφή",
|
||||
"No description available": "Δεν είναι διαθέσιμη καμία περιγραφή",
|
||||
"List": "Λίστα",
|
||||
"Grid": "Πλέγμα",
|
||||
@@ -273,7 +271,7 @@
|
||||
"Checking for updates...": "Έλεγχος για ενημερώσεις...",
|
||||
"Error checking for updates": "Σφάλμα κατά τον έλεγχο για ενημερώσεις",
|
||||
"Details": "Πληρ.",
|
||||
"File Size:": "Μέγεθος αρχείου:",
|
||||
"File Size": "Μέγεθος αρχείου",
|
||||
"Auto Detect": "Αυτόματη ανίχνευση",
|
||||
"Next Section": "Επόμενη ενότητα",
|
||||
"Previous Section": "Προηγούμενη ενότητα",
|
||||
@@ -359,7 +357,6 @@
|
||||
"Upgrade to Plus or Pro": "Αναβάθμιση σε Plus ή Pro",
|
||||
"Current Plan": "Τρέχον πρόγραμμα",
|
||||
"Plan Limits": "Όρια προγράμματος",
|
||||
"Failed to manage subscription. Please try again later.": "Αποτυχία διαχείρισης της συνδρομής. Παρακαλώ δοκιμάστε ξανά αργότερα.",
|
||||
"Processing your payment...": "Επεξεργασία πληρωμής...",
|
||||
"Please wait while we confirm your subscription.": "Παρακαλώ περιμένετε όσο επιβεβαιώνουμε τη συνδρομή σας.",
|
||||
"Payment Processing": "Επεξεργασία πληρωμής",
|
||||
@@ -402,7 +399,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "Απεριόριστη μετατροπή κειμένου σε ήχο.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Περισσότερες μεταφράσεις και δυνατότητες.",
|
||||
"DeepL Pro Access": "Πρόσβαση στο DeepL Pro",
|
||||
"2 GB Cloud Sync Storage": "2 GB αποθήκευση στο cloud",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "Γρήγορη και άμεση υποστήριξη.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Έχετε φτάσει το ημερήσιο όριο. Κάντε αναβάθμιση για περισσότερες μεταφράσεις.",
|
||||
"Includes All Plus Plan Benefits": "Περιλαμβάνει Όλα τα Οφέλη του Plus Πλάνου",
|
||||
@@ -412,10 +408,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Αξιοποιήστε ισχυρά εργαλεία AI για εξυπνότερη ανάγνωση, μετάφραση και ανακάλυψη περιεχομένου.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Μεταφράστε έως 100.000 χαρακτήρες ημερησίως με τη πιο ακριβή μηχανή μετάφρασης.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Μεταφράστε έως 500.000 χαρακτήρες ημερησίως με τη πιο ακριβή μηχανή μετάφρασης.",
|
||||
"10 GB Cloud Sync Storage": "10 GB Αποθήκευση Cloud Sync",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Αποθηκεύστε και αποκτήστε πρόσβαση στη συλλογή ανάγνωσης με έως 2 GB ασφαλή cloud αποθήκευση.",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Αποθηκεύστε και αποκτήστε πρόσβαση στη συλλογή ανάγνωσης με έως 10 GB ασφαλή cloud αποθήκευση.",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Αποθηκεύστε και αποκτήστε πρόσβαση στη συλλογή ανάγνωσης με έως 5 GB ασφαλή cloud αποθήκευση.",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Αποθηκεύστε και αποκτήστε πρόσβαση στη συλλογή ανάγνωσης με έως 20 GB ασφαλή cloud αποθήκευση.",
|
||||
"Deleted cloud backup of the book: {{title}}": "Διαγράφηκε το αντίγραφο ασφαλείας του βιβλίου: {{title}}",
|
||||
"Failed to delete cloud backup of the book": "Αποτυχία διαγραφής του αντιγράφου ασφαλείας του βιβλίου",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Είστε σίγουροι ότι θέλετε να διαγράψετε το αντίγραφο ασφαλείας του επιλεγμένου βιβλίου;"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Είστε σίγουροι ότι θέλετε να διαγράψετε το αντίγραφο ασφαλείας του επιλεγμένου βιβλίου;",
|
||||
"What's New in Readest": "Τι νέο υπάρχει στο Readest",
|
||||
"Enter book title": "Εισάγετε τίτλο βιβλίου",
|
||||
"Subtitle": "Υπότιτλος",
|
||||
"Enter book subtitle": "Εισάγετε υπότιτλο βιβλίου",
|
||||
"Enter author name": "Εισάγετε όνομα συγγραφέα",
|
||||
"Series": "Σειρά",
|
||||
"Enter series name": "Εισάγετε όνομα σειράς",
|
||||
"Series Index": "Αριθμός σειράς",
|
||||
"Enter series index": "Εισάγετε αριθμό σειράς",
|
||||
"Total in Series": "Σύνολο στη σειρά",
|
||||
"Enter total books in series": "Εισάγετε συνολικό αριθμό βιβλίων στη σειρά",
|
||||
"Enter publisher": "Εισάγετε εκδότη",
|
||||
"Publication Date": "Ημερομηνία έκδοσης",
|
||||
"Identifier": "Αναγνωριστικό",
|
||||
"Enter book description": "Εισάγετε περιγραφή βιβλίου",
|
||||
"Change cover image": "Αλλαγή εικόνας εξωφύλλου",
|
||||
"Replace": "Αντικατάσταση",
|
||||
"Unlock cover": "Ξεκλείδωμα εξωφύλλου",
|
||||
"Lock cover": "Κλείδωμα εξωφύλλου",
|
||||
"Auto-Retrieve Metadata": "Αυτόματη ανάκτηση μεταδεδομένων",
|
||||
"Auto-Retrieve": "Αυτόματη ανάκτηση",
|
||||
"Unlock all fields": "Ξεκλείδωμα όλων των πεδίων",
|
||||
"Unlock All": "Ξεκλείδωμα όλων",
|
||||
"Lock all fields": "Κλείδωμα όλων των πεδίων",
|
||||
"Lock All": "Κλείδωμα όλων",
|
||||
"Reset": "Επαναφορά",
|
||||
"Edit Metadata": "Επεξεργασία μεταδεδομένων",
|
||||
"Locked": "Κλειδωμένο",
|
||||
"Select Metadata Source": "Επιλέξτε πηγή μεταδεδομένων",
|
||||
"Keep manual input": "Διατήρηση χειροκίνητης εισαγωγής",
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Μυθοπλασία, Επιστήμη, Ιστορία",
|
||||
"Select Cover Image": "Επιλέξτε εικόνα εξωφύλλου",
|
||||
"Open Book in New Window": "Άνοιγμα βιβλίου σε νέο παράθυρο",
|
||||
"Voices for {{lang}}": "Φωνές για {{lang}}",
|
||||
"Yandex Translate": "Μετάφραση Yandex",
|
||||
"YYYY or YYYY-MM-DD": "YYYY ή YYYY-MM-DD",
|
||||
"Restore Purchase": "Επαναφορά αγοράς",
|
||||
"No purchases found to restore.": "Δεν βρέθηκαν αγορές για επαναφορά.",
|
||||
"Failed to restore purchases.": "Αποτυχία επαναφοράς αγορών.",
|
||||
"Failed to manage subscription.": "Αποτυχία διαχείρισης συνδρομής.",
|
||||
"Failed to load subscription plans.": "Αποτυχία φόρτωσης σχεδίων συνδρομής.",
|
||||
"year": "έτος",
|
||||
"Failed to create checkout session": "Αποτυχία δημιουργίας συνεδρίας πληρωμής",
|
||||
"Storage": "Αποθήκευση",
|
||||
"Terms of Service": "Όροι χρήσης",
|
||||
"Privacy Policy": "Πολιτική απορρήτου",
|
||||
"Disable Double Click": "Απενεργοποίηση διπλού κλικ"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "Dirección horizontal",
|
||||
"Hyphenation": "Guionización",
|
||||
"Import Books": "Importar libros",
|
||||
"Language:": "Idioma:",
|
||||
"Layout": "Diseño",
|
||||
"Light Mode": "Modo claro",
|
||||
"Loading...": "Cargando...",
|
||||
@@ -68,8 +67,8 @@
|
||||
"Paging Animation": "Animación de paginación",
|
||||
"Paragraph": "Párrafo",
|
||||
"Parallel Read": "Lectura paralela",
|
||||
"Published:": "Publicado:",
|
||||
"Publisher:": "Editorial:",
|
||||
"Published": "Publicado",
|
||||
"Publisher": "Editorial",
|
||||
"Reading Progress Synced": "Progreso de lectura sincronizado",
|
||||
"Reload Page": "Recargar página",
|
||||
"Reveal in File Explorer": "Mostrar en el Explorador de archivos",
|
||||
@@ -94,7 +93,7 @@
|
||||
"Slow": "Lento",
|
||||
"Solarized": "Solarizado",
|
||||
"Speak": "Leer",
|
||||
"Subjects:": "Temas:",
|
||||
"Subjects": "Temas",
|
||||
"System Fonts": "Fuentes del sistema",
|
||||
"TTS not supported in this device": "TTS no es compatible con este dispositivo",
|
||||
"Theme Color": "Color del tema",
|
||||
@@ -103,7 +102,7 @@
|
||||
"Translated Text": "Texto traducido",
|
||||
"Unknown": "Desconocido",
|
||||
"Untitled": "Sin título",
|
||||
"Updated:": "Actualizado:",
|
||||
"Updated": "Actualizado",
|
||||
"Version {{version}}": "Versión {{version}}",
|
||||
"Vertical Direction": "Dirección vertical",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenido a tu biblioteca. Puedes importar tus libros aquí y leerlos en cualquier momento.",
|
||||
@@ -244,7 +243,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Descargando {{downloaded}} de {{contentLength}}",
|
||||
"Download finished": "Descarga finalizada",
|
||||
"DOWNLOAD & INSTALL": "DESCARGAR E INSTALAR",
|
||||
"Changelog:": "Notas de la versión:",
|
||||
"Changelog": "Notas de la versión",
|
||||
"Software Update": "Actualización de software",
|
||||
"Title": "Título",
|
||||
"Date Read": "Fecha de lectura",
|
||||
@@ -253,10 +252,9 @@
|
||||
"Ascending": "Ascendente",
|
||||
"Descending": "Descendente",
|
||||
"Sort by...": "Ordenar por...",
|
||||
"Added:": "Agregado:",
|
||||
"Format:": "Formato:",
|
||||
"Added": "Agregado",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Description:": "Descripción:",
|
||||
"Description": "Descripción",
|
||||
"No description available": "No hay descripción disponible",
|
||||
"List": "Lista",
|
||||
"Grid": "Cuadrícula",
|
||||
@@ -273,7 +271,7 @@
|
||||
"Checking for updates...": "Comprobando actualizaciones...",
|
||||
"Error checking for updates": "Error al comprobar actualizaciones",
|
||||
"Details": "Detalles",
|
||||
"File Size:": "Peso del archivo:",
|
||||
"File Size": "Peso del archivo",
|
||||
"Auto Detect": "Detección automática",
|
||||
"Next Section": "Siguiente sección",
|
||||
"Previous Section": "Sección anterior",
|
||||
@@ -363,7 +361,6 @@
|
||||
"Upgrade to Plus or Pro": "Actualizar a Plus o Pro",
|
||||
"Current Plan": "Plan actual",
|
||||
"Plan Limits": "Límites del plan",
|
||||
"Failed to manage subscription. Please try again later.": "No se pudo gestionar la suscripción. Por favor, inténtalo de nuevo más tarde.",
|
||||
"Processing your payment...": "Procesando tu pago...",
|
||||
"Please wait while we confirm your subscription.": "Espera mientras confirmamos tu suscripción.",
|
||||
"Payment Processing": "Procesando pago",
|
||||
@@ -406,7 +403,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "Convierte texto en audio sin límites.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Más traducciones y funciones avanzadas.",
|
||||
"DeepL Pro Access": "Acceso a DeepL Pro",
|
||||
"2 GB Cloud Sync Storage": "2 GB de almacenamiento en la nube",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "Soporte rápido y prioritario.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Has alcanzado tu límite diario. Mejora tu plan para más traducciones.",
|
||||
"Includes All Plus Plan Benefits": "Incluye Todos los Beneficios del Plan Plus",
|
||||
@@ -416,10 +412,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Aprovecha herramientas de IA potentes para lectura inteligente, traducción y descubrimiento de contenido.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Traduce hasta 100,000 caracteres diarios con el motor de traducción más preciso disponible.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Traduce hasta 500,000 caracteres diarios con el motor de traducción más preciso disponible.",
|
||||
"10 GB Cloud Sync Storage": "10 GB de Almacenamiento en la Nube",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Almacena y accede de forma segura a toda tu colección de lectura con hasta 2 GB de almacenamiento en la nube.",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Almacena y accede de forma segura a toda tu colección de lectura con hasta 10 GB de almacenamiento en la nube.",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Almacena y accede de forma segura a toda tu colección de lectura con hasta 5 GB de almacenamiento en la nube.",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Almacena y accede de forma segura a toda tu colección de lectura con hasta 20 GB de almacenamiento en la nube.",
|
||||
"Deleted cloud backup of the book: {{title}}": "Respaldo en la nube del libro eliminado: {{title}}",
|
||||
"Failed to delete cloud backup of the book": "Fallo al eliminar el respaldo en la nube del libro",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "¿Estás seguro de que deseas eliminar el respaldo en la nube del libro seleccionado?"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "¿Estás seguro de que deseas eliminar el respaldo en la nube del libro seleccionado?",
|
||||
"What's New in Readest": "¿Qué hay de nuevo en Readest",
|
||||
"Enter book title": "Ingresa el título del libro",
|
||||
"Subtitle": "Subtítulo",
|
||||
"Enter book subtitle": "Ingresa el subtítulo del libro",
|
||||
"Enter author name": "Ingresa el nombre del autor",
|
||||
"Series": "Serie",
|
||||
"Enter series name": "Ingresa el nombre de la serie",
|
||||
"Series Index": "Número de serie",
|
||||
"Enter series index": "Ingresa el número de serie",
|
||||
"Total in Series": "Total en la serie",
|
||||
"Enter total books in series": "Ingresa el total de libros en la serie",
|
||||
"Enter publisher": "Ingresa la editorial",
|
||||
"Publication Date": "Fecha de publicación",
|
||||
"Identifier": "Identificador",
|
||||
"Enter book description": "Ingresa la descripción del libro",
|
||||
"Change cover image": "Cambiar imagen de portada",
|
||||
"Replace": "Reemplazar",
|
||||
"Unlock cover": "Desbloquear portada",
|
||||
"Lock cover": "Bloquear portada",
|
||||
"Auto-Retrieve Metadata": "Recuperar metadatos automáticamente",
|
||||
"Auto-Retrieve": "Recuperar automáticamente",
|
||||
"Unlock all fields": "Desbloquear todos los campos",
|
||||
"Unlock All": "Desbloquear todo",
|
||||
"Lock all fields": "Bloquear todos los campos",
|
||||
"Lock All": "Bloquear todo",
|
||||
"Reset": "Restablecer",
|
||||
"Edit Metadata": "Editar metadatos",
|
||||
"Locked": "Bloqueado",
|
||||
"Select Metadata Source": "Seleccionar fuente de metadatos",
|
||||
"Keep manual input": "Mantener entrada manual",
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Ficción, Ciencia, Historia",
|
||||
"Select Cover Image": "Seleccionar imagen de portada",
|
||||
"Open Book in New Window": "Abre el libro en una nueva ventana",
|
||||
"Voices for {{lang}}": "Voces para {{lang}}",
|
||||
"Yandex Translate": "Yandex Translate",
|
||||
"YYYY or YYYY-MM-DD": "YYYY o YYYY-MM-DD",
|
||||
"Restore Purchase": "Restaurar compra",
|
||||
"No purchases found to restore.": "No se encontraron compras para restaurar.",
|
||||
"Failed to restore purchases.": "Error al restaurar compras.",
|
||||
"Failed to manage subscription.": "Error al gestionar la suscripción.",
|
||||
"Failed to load subscription plans.": "Error al cargar los planes de suscripción.",
|
||||
"year": "año",
|
||||
"Failed to create checkout session": "Error al crear la sesión de pago",
|
||||
"Storage": "Almacenamiento",
|
||||
"Terms of Service": "Condiciones del servicio",
|
||||
"Privacy Policy": "Política de privacidad",
|
||||
"Disable Double Click": "Deshabilitar Doble Clic"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "Direction horizontale",
|
||||
"Hyphenation": "Césure",
|
||||
"Import Books": "Importer des livres",
|
||||
"Language:": "Langue :",
|
||||
"Layout": "Mise en page",
|
||||
"Light Mode": "Mode clair",
|
||||
"Loading...": "Chargement...",
|
||||
@@ -68,8 +67,8 @@
|
||||
"Paging Animation": "Animation de page",
|
||||
"Paragraph": "Paragraphe",
|
||||
"Parallel Read": "Lecture parallèle",
|
||||
"Published:": "Publié :",
|
||||
"Publisher:": "Éditeur :",
|
||||
"Published": "Publié",
|
||||
"Publisher": "Éditeur",
|
||||
"Reading Progress Synced": "Progression de lecture synchronisée",
|
||||
"Reload Page": "Recharger la page",
|
||||
"Reveal in File Explorer": "Afficher dans l'explorateur de fichiers",
|
||||
@@ -94,7 +93,7 @@
|
||||
"Slow": "Lent",
|
||||
"Solarized": "Solarisé",
|
||||
"Speak": "Lire",
|
||||
"Subjects:": "Sujets :",
|
||||
"Subjects": "Sujets",
|
||||
"System Fonts": "Polices système",
|
||||
"TTS not supported in this device": "La synthèse vocale n'est pas prise en charge sur cet appareil",
|
||||
"Theme Color": "Couleur du thème",
|
||||
@@ -103,7 +102,7 @@
|
||||
"Translated Text": "Texte traduit",
|
||||
"Unknown": "Inconnu",
|
||||
"Untitled": "Sans titre",
|
||||
"Updated:": "Mis à jour :",
|
||||
"Updated": "Mis à jour",
|
||||
"Version {{version}}": "Version {{version}}",
|
||||
"Vertical Direction": "Direction verticale",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenue dans votre bibliothèque. Vous pouvez importer vos livres ici et les lire à tout moment.",
|
||||
@@ -243,7 +242,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Téléchargement {{downloaded}} sur {{contentLength}}",
|
||||
"Download finished": "Téléchargement terminé",
|
||||
"DOWNLOAD & INSTALL": "TÉLÉCHARGER ET INSTALLER",
|
||||
"Changelog:": "Journal des modifications :",
|
||||
"Changelog": "Journal des modifications",
|
||||
"Software Update": "Mise à jour logicielle",
|
||||
"Title": "Titre",
|
||||
"Date Read": "Date de lecture",
|
||||
@@ -252,10 +251,9 @@
|
||||
"Ascending": "Ascendant",
|
||||
"Descending": "Descendant",
|
||||
"Sort by...": "Trier par...",
|
||||
"Added:": "Ajouté :",
|
||||
"Format:": "Format :",
|
||||
"Added": "Ajouté",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Description:": "Description :",
|
||||
"Description": "Description",
|
||||
"No description available": "Aucune description disponible",
|
||||
"List": "Liste",
|
||||
"Grid": "Grille",
|
||||
@@ -272,7 +270,7 @@
|
||||
"Checking for updates...": "Vérification des mises à jour...",
|
||||
"Error checking for updates": "Erreur lors de la vérification des mises à jour",
|
||||
"Details": "Détails",
|
||||
"File Size:": "Taille du fichier :",
|
||||
"File Size": "Taille du fichier",
|
||||
"Auto Detect": "Détection automatique",
|
||||
"Next Section": "Section suivante",
|
||||
"Previous Section": "Section précédente",
|
||||
@@ -362,7 +360,6 @@
|
||||
"Upgrade to Plus or Pro": "Passer à Plus ou Pro",
|
||||
"Current Plan": "Abonnement actuel",
|
||||
"Plan Limits": "Limites de l’abonnement",
|
||||
"Failed to manage subscription. Please try again later.": "Échec de la gestion de l’abonnement. Veuillez réessayer plus tard.",
|
||||
"Processing your payment...": "Traitement de votre paiement...",
|
||||
"Please wait while we confirm your subscription.": "Veuillez patienter pendant la confirmation de votre abonnement.",
|
||||
"Payment Processing": "Traitement du paiement",
|
||||
@@ -405,7 +402,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "Convertissez le texte en audio sans limite.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Plus de traductions et d’options avancées.",
|
||||
"DeepL Pro Access": "Accès DeepL Pro",
|
||||
"2 GB Cloud Sync Storage": "2 Go de stockage cloud",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "Support prioritaire et réponses rapides.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Limite quotidienne atteinte. Passez à un plan supérieur pour continuer.",
|
||||
"Includes All Plus Plan Benefits": "Inclut Tous les Avantages du Plan Plus",
|
||||
@@ -415,11 +411,59 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Exploitez des outils IA puissants pour une lecture intelligente, traduction et découverte de contenu.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Traduisez jusqu'à 100 000 caractères par jour avec le moteur de traduction le plus précis.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Traduisez jusqu'à 500 000 caractères par jour avec le moteur de traduction le plus précis.",
|
||||
"10 GB Cloud Sync Storage": "10 GB de Stockage Cloud Sync",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Stockez et accédez en sécurité à votre collection de lecture avec jusqu'à 2 GB de stockage cloud.",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Stockez et accédez en sécurité à votre collection de lecture avec jusqu'à 10 GB de stockage cloud.",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Stockez et accédez en sécurité à votre collection de lecture avec jusqu'à 5 GB de stockage cloud.",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Stockez et accédez en sécurité à votre collection de lecture avec jusqu'à 20 GB de stockage cloud.",
|
||||
"Deleted cloud backup of the book: {{title}}": "Suppression de la sauvegarde cloud du livre : {{title}}",
|
||||
"Failed to delete cloud backup of the book": "Échec de la suppression de la sauvegarde cloud du livre",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Êtes-vous sûr de vouloir supprimer la sauvegarde cloud du livre sélectionné ?",
|
||||
"Don't have an account? Sign up": "Vous n'avez pas de compte ? Inscrivez-vous"
|
||||
"Don't have an account? Sign up": "Vous n'avez pas de compte ? Inscrivez-vous",
|
||||
"What's New in Readest": "Quoi de neuf dans Readest",
|
||||
"Enter book title": "Saisir le titre du livre",
|
||||
"Subtitle": "Sous-titre",
|
||||
"Enter book subtitle": "Saisir le sous-titre du livre",
|
||||
"Enter author name": "Saisir le nom de l'auteur",
|
||||
"Series": "Série",
|
||||
"Enter series name": "Saisir le nom de la série",
|
||||
"Series Index": "Numéro de série",
|
||||
"Enter series index": "Saisir le numéro de série",
|
||||
"Total in Series": "Total dans la série",
|
||||
"Enter total books in series": "Saisir le nombre total de livres dans la série",
|
||||
"Enter publisher": "Saisir l'éditeur",
|
||||
"Publication Date": "Date de publication",
|
||||
"Identifier": "Identifiant",
|
||||
"Enter book description": "Saisir la description du livre",
|
||||
"Change cover image": "Changer l'image de couverture",
|
||||
"Replace": "Remplacer",
|
||||
"Unlock cover": "Déverrouiller la couverture",
|
||||
"Lock cover": "Verrouiller la couverture",
|
||||
"Auto-Retrieve Metadata": "Récupérer les métadonnées automatiquement",
|
||||
"Auto-Retrieve": "Récupération automatique",
|
||||
"Unlock all fields": "Déverrouiller tous les champs",
|
||||
"Unlock All": "Tout déverrouiller",
|
||||
"Lock all fields": "Verrouiller tous les champs",
|
||||
"Lock All": "Tout verrouiller",
|
||||
"Reset": "Réinitialiser",
|
||||
"Edit Metadata": "Modifier les métadonnées",
|
||||
"Locked": "Verrouillé",
|
||||
"Select Metadata Source": "Sélectionner la source des métadonnées",
|
||||
"Keep manual input": "Conserver la saisie manuelle",
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Fiction, Science, Histoire",
|
||||
"Select Cover Image": "Sélectionner l'image de couverture",
|
||||
"Open Book in New Window": "Ouvrir dans une nouvelle fenêtre",
|
||||
"Voices for {{lang}}": "Voix pour {{lang}}",
|
||||
"Yandex Translate": "Yandex Traduction",
|
||||
"YYYY or YYYY-MM-DD": "YYYY ou YYYY-MM-DD",
|
||||
"Restore Purchase": "Restaurer l'achat",
|
||||
"No purchases found to restore.": "Aucun achat trouvé à restaurer.",
|
||||
"Failed to restore purchases.": "Échec de la restauration des achats.",
|
||||
"Failed to manage subscription.": "Échec de la gestion de l'abonnement.",
|
||||
"Failed to load subscription plans.": "Échec du chargement des plans d'abonnement.",
|
||||
"year": "année",
|
||||
"Failed to create checkout session": "Échec de la création de la session de paiement",
|
||||
"Storage": "Stockage",
|
||||
"Terms of Service": "Conditions d'utilisation",
|
||||
"Privacy Policy": "Politique de confidentialité",
|
||||
"Disable Double Click": "Désactiver le double-clic"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "क्षैतिज दिशा",
|
||||
"Hyphenation": "हाइफ़नेशन",
|
||||
"Import Books": "पुस्तकें आयात करें",
|
||||
"Language:": "भाषा:",
|
||||
"Layout": "लेआउट",
|
||||
"Light Mode": "लाइट मोड",
|
||||
"Loading...": "लोड हो रहा है...",
|
||||
@@ -68,8 +67,8 @@
|
||||
"Paging Animation": "पेजिंग एनीमेशन",
|
||||
"Paragraph": "पैराग्राफ",
|
||||
"Parallel Read": "समानांतर पढ़ें",
|
||||
"Published:": "प्रकाशित:",
|
||||
"Publisher:": "प्रकाशक:",
|
||||
"Published": "प्रकाशित",
|
||||
"Publisher": "प्रकाशक",
|
||||
"Reading Progress Synced": "पढ़ने की प्रगति सिंक की गई",
|
||||
"Reload Page": "पृष्ठ रीलोड करें",
|
||||
"Reveal in File Explorer": "फ़ाइल एक्सप्लोरर में दिखाएं",
|
||||
@@ -94,7 +93,7 @@
|
||||
"Slow": "धीमा",
|
||||
"Solarized": "सोलराइज्ड",
|
||||
"Speak": "बोलें",
|
||||
"Subjects:": "विषय:",
|
||||
"Subjects": "विषय",
|
||||
"System Fonts": "सिस्टम फ़ॉन्ट्स",
|
||||
"TTS not supported in this device": "यह डिवाइस TTS का समर्थन नहीं करता",
|
||||
"Theme Color": "थीम रंग",
|
||||
@@ -103,7 +102,7 @@
|
||||
"Translated Text": "अनुवादित पाठ",
|
||||
"Unknown": "अज्ञात",
|
||||
"Untitled": "शीर्षकहीन",
|
||||
"Updated:": "अपडेट किया गया:",
|
||||
"Updated": "अपडेट किया गया",
|
||||
"Version {{version}}": "संस्करण {{version}}",
|
||||
"Vertical Direction": "ऊर्ध्वाधर दिशा",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "आपकी लाइब्रेरी में आपका स्वागत है। आप यहां अपनी पुस्तकें आयात कर सकते हैं और कभी भी पढ़ सकते हैं।",
|
||||
@@ -244,7 +243,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "डाउनलोड कर रहा है {{downloaded}} का {{contentLength}}",
|
||||
"Download finished": "डाउनलोड पूरा हुआ",
|
||||
"DOWNLOAD & INSTALL": "डाउनलोड और इंस्टॉल करें",
|
||||
"Changelog:": "चेंजलॉग:",
|
||||
"Changelog": "चेंजलॉग",
|
||||
"Software Update": "सॉफ़्टवेयर अपडेट",
|
||||
"Title": "शीर्षक",
|
||||
"Date Read": "पढ़ने की तारीख",
|
||||
@@ -253,10 +252,9 @@
|
||||
"Ascending": "आरोही",
|
||||
"Descending": "अवरोही",
|
||||
"Sort by...": "द्वारा क्रमबद्ध करें...",
|
||||
"Added:": "जोड़ा गया:",
|
||||
"Format:": "फॉर्मेट:",
|
||||
"Added": "जोड़ा गया",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Description:": "विवरण:",
|
||||
"Description": "विवरण",
|
||||
"No description available": "कोई विवरण उपलब्ध नहीं है",
|
||||
"List": "सूची",
|
||||
"Grid": "ग्रिड",
|
||||
@@ -273,7 +271,7 @@
|
||||
"Checking for updates...": "अपडेट की जांच कर रहा है...",
|
||||
"Error checking for updates": "अपडेट की जांच करते समय त्रुटि",
|
||||
"Details": "विवरण",
|
||||
"File Size:": "फ़ाइल आकार:",
|
||||
"File Size": "फ़ाइल आकार",
|
||||
"Auto Detect": "स्वचालित पहचान",
|
||||
"Next Section": "अगला अनुभाग",
|
||||
"Previous Section": "पिछला अनुभाग",
|
||||
@@ -359,7 +357,6 @@
|
||||
"Upgrade to Plus or Pro": "Plus या Pro में अपग्रेड करें",
|
||||
"Current Plan": "वर्तमान योजना",
|
||||
"Plan Limits": "योजना की सीमाएँ",
|
||||
"Failed to manage subscription. Please try again later.": "सदस्यता प्रबंधित करने में विफल। कृपया बाद में पुनः प्रयास करें।",
|
||||
"Processing your payment...": "आपका भुगतान संसाधित हो रहा है...",
|
||||
"Please wait while we confirm your subscription.": "कृपया प्रतीक्षा करें, हम आपकी सदस्यता की पुष्टि कर रहे हैं।",
|
||||
"Payment Processing": "भुगतान संसाधित हो रहा है",
|
||||
@@ -402,7 +399,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "जितना चाहें उतना ऑडियो में सुनें।",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "अधिक अनुवाद और उन्नत विकल्प पाएं।",
|
||||
"DeepL Pro Access": "DeepL प्रो एक्सेस",
|
||||
"2 GB Cloud Sync Storage": "2 जीबी क्लाउड स्टोरेज",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "प्राथमिक सहायता पाएं।",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "दैनिक सीमा पूरी हुई। योजना बढ़ाएं।",
|
||||
"Includes All Plus Plan Benefits": "सभी प्लस प्लान लाभ शामिल",
|
||||
@@ -412,10 +408,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "स्मार्ट पढ़ने, अनुवाद और कंटेंट खोजने के लिए शक्तिशाली AI उपकरण का उपयोग करें।",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "सबसे सटीक अनुवाद इंजन के साथ दैनिक 1 लाख तक अक्षर अनुवाद करें।",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "सबसे सटीक अनुवाद इंजन के साथ दैनिक 5 लाख तक अक्षर अनुवाद करें।",
|
||||
"10 GB Cloud Sync Storage": "10 GB क्लाउड सिंक स्टोरेज",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "2 GB सुरक्षित क्लाउड स्टोरेज के साथ अपना संपूर्ण पठन संग्रह संग्रहीत करें।",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "10 GB सुरक्षित क्लाउड स्टोरेज के साथ अपना संपूर्ण पठन संग्रह संग्रहीत करें।",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "5 GB सुरक्षित क्लाउड स्टोरेज के साथ अपना संपूर्ण पठन संग्रह संग्रहीत करें।",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "20 GB सुरक्षित क्लाउड स्टोरेज के साथ अपना संपूर्ण पठन संग्रह संग्रहीत करें।",
|
||||
"Deleted cloud backup of the book: {{title}}": "पुस्तक का क्लाउड बैकअप हटाया गया: {{title}}",
|
||||
"Failed to delete cloud backup of the book": "पुस्तक का क्लाउड बैकअप हटाने में विफल",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "क्या आप सुनिश्चित हैं कि आप चयनित पुस्तक का क्लाउड बैकअप हटाना चाहते हैं?"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "क्या आप सुनिश्चित हैं कि आप चयनित पुस्तक का क्लाउड बैकअप हटाना चाहते हैं?",
|
||||
"What's New in Readest": "Readest में नया क्या है",
|
||||
"Enter book title": "पुस्तक का शीर्षक दर्ज करें",
|
||||
"Subtitle": "उपशीर्षक",
|
||||
"Enter book subtitle": "पुस्तक का उपशीर्षक दर्ज करें",
|
||||
"Enter author name": "लेखक का नाम दर्ज करें",
|
||||
"Series": "श्रृंखला",
|
||||
"Enter series name": "श्रृंखला का नाम दर्ज करें",
|
||||
"Series Index": "श्रृंखला संख्या",
|
||||
"Enter series index": "श्रृंखला संख्या दर्ज करें",
|
||||
"Total in Series": "श्रृंखला में कुल",
|
||||
"Enter total books in series": "श्रृंखला में कुल पुस्तकों की संख्या दर्ज करें",
|
||||
"Enter publisher": "प्रकाशक का नाम दर्ज करें",
|
||||
"Publication Date": "प्रकाशन दिनांक",
|
||||
"Identifier": "पहचानकर्ता",
|
||||
"Enter book description": "पुस्तक का विवरण दर्ज करें",
|
||||
"Change cover image": "कवर इमेज बदलें",
|
||||
"Replace": "बदलें",
|
||||
"Unlock cover": "कवर अनलॉक करें",
|
||||
"Lock cover": "कवर लॉक करें",
|
||||
"Auto-Retrieve Metadata": "मेटाडेटा स्वचालित रूप से प्राप्त करें",
|
||||
"Auto-Retrieve": "स्वचालित प्राप्ति",
|
||||
"Unlock all fields": "सभी फील्ड अनलॉक करें",
|
||||
"Unlock All": "सभी अनलॉक करें",
|
||||
"Lock all fields": "सभी फील्ड लॉक करें",
|
||||
"Lock All": "सभी लॉक करें",
|
||||
"Reset": "रीसेट",
|
||||
"Edit Metadata": "मेटाडेटा संपादित करें",
|
||||
"Locked": "लॉक है",
|
||||
"Select Metadata Source": "मेटाडेटा स्रोत चुनें",
|
||||
"Keep manual input": "मैन्युअल इनपुट रखें",
|
||||
"Google Books": "गूगल बुक्स",
|
||||
"Open Library": "ओपन लाइब्रेरी",
|
||||
"Fiction, Science, History": "काल्पनिक, विज्ञान, इतिहास",
|
||||
"Select Cover Image": "कवर इमेज चुनें",
|
||||
"Open Book in New Window": "नई विंडो में पुस्तक खोलें",
|
||||
"Voices for {{lang}}": "{{lang}} के लिए आवाज़ें",
|
||||
"Yandex Translate": "यांडेक्स अनुवाद",
|
||||
"YYYY or YYYY-MM-DD": "YYYY या YYYY-MM-DD",
|
||||
"Restore Purchase": "खरीदारी पुनर्स्थापित करें",
|
||||
"No purchases found to restore.": "पुनर्स्थापित करने के लिए कोई खरीदारी नहीं मिली।",
|
||||
"Failed to restore purchases.": "खरीदारी पुनर्स्थापित करने में विफल।",
|
||||
"Failed to manage subscription.": "सदस्यता प्रबंधित करने में विफल।",
|
||||
"Failed to load subscription plans.": "सदस्यता योजनाएँ लोड करने में विफल।",
|
||||
"year": "वर्ष",
|
||||
"Failed to create checkout session": "चेकआउट सत्र बनाने में विफल",
|
||||
"Storage": "भंडारण",
|
||||
"Terms of Service": "सेवा की शर्तें",
|
||||
"Privacy Policy": "गोपनीयता नीति",
|
||||
"Disable Double Click": "डबल क्लिक अक्षम करें"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "Arah Horizontal",
|
||||
"Hyphenation": "Pemenggalan Kata",
|
||||
"Import Books": "Impor Buku",
|
||||
"Language:": "Bahasa:",
|
||||
"Layout": "Tata Letak",
|
||||
"Light Mode": "Mode Terang",
|
||||
"Loading...": "Memuat...",
|
||||
@@ -68,8 +67,8 @@
|
||||
"Paging Animation": "Animasi Halaman",
|
||||
"Paragraph": "Paragraf",
|
||||
"Parallel Read": "Baca Paralel",
|
||||
"Published:": "Diterbitkan:",
|
||||
"Publisher:": "Penerbit:",
|
||||
"Published": "Diterbitkan",
|
||||
"Publisher": "Penerbit",
|
||||
"Reading Progress Synced": "Progres membaca disinkronkan",
|
||||
"Reload Page": "Muat Ulang Halaman",
|
||||
"Reveal in File Explorer": "Tampilkan di File Explorer",
|
||||
@@ -94,7 +93,7 @@
|
||||
"Slow": "Lambat",
|
||||
"Solarized": "Solarized",
|
||||
"Speak": "Bicara",
|
||||
"Subjects:": "Subjek:",
|
||||
"Subjects": "Subjek",
|
||||
"System Fonts": "Font Sistem",
|
||||
"TTS not supported in this device": "TTS tidak didukung di perangkat ini",
|
||||
"Theme Color": "Warna Tema",
|
||||
@@ -103,7 +102,7 @@
|
||||
"Translated Text": "Teks Terjemahan",
|
||||
"Unknown": "Tidak Diketahui",
|
||||
"Untitled": "Tanpa Judul",
|
||||
"Updated:": "Diperbarui:",
|
||||
"Updated": "Diperbarui",
|
||||
"Version {{version}}": "Versi {{version}}",
|
||||
"Vertical Direction": "Arah Vertikal",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Selamat datang di perpustakaan Anda. Anda dapat mengimpor buku-buku Anda di sini dan membacanya kapan saja.",
|
||||
@@ -244,7 +243,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Mengunduh {{downloaded}} dari {{contentLength}}",
|
||||
"Download finished": "Unduhan selesai",
|
||||
"DOWNLOAD & INSTALL": "UNDUH & INSTAL",
|
||||
"Changelog:": "Catatan Perubahan:",
|
||||
"Changelog": "Catatan Perubahan",
|
||||
"Software Update": "Pembaruan Perangkat Lunak",
|
||||
"Title": "Judul",
|
||||
"Date Read": "Tanggal Dibaca",
|
||||
@@ -253,10 +252,9 @@
|
||||
"Ascending": "Naik",
|
||||
"Descending": "Turun",
|
||||
"Sort by...": "Urutkan berdasarkan...",
|
||||
"Added:": "Ditambahkan:",
|
||||
"Format:": "Format:",
|
||||
"Added": "Ditambahkan",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Description:": "Deskripsi:",
|
||||
"Description": "Deskripsi",
|
||||
"No description available": "Tidak ada deskripsi yang tersedia",
|
||||
"List": "Daftar",
|
||||
"Grid": "Jala",
|
||||
@@ -273,7 +271,7 @@
|
||||
"Checking for updates...": "Memeriksa pembaruan...",
|
||||
"Error checking for updates": "Kesalahan saat memeriksa pembaruan",
|
||||
"Details": "Detail",
|
||||
"File Size:": "Ukuran File:",
|
||||
"File Size": "Ukuran File",
|
||||
"Auto Detect": "Deteksi Otomatis",
|
||||
"Next Section": "Bagian Selanjutnya",
|
||||
"Previous Section": "Bagian Sebelumnya",
|
||||
@@ -355,7 +353,6 @@
|
||||
"Upgrade to Plus or Pro": "Tingkatkan ke Plus atau Pro",
|
||||
"Current Plan": "Paket Saat Ini",
|
||||
"Plan Limits": "Batas Paket",
|
||||
"Failed to manage subscription. Please try again later.": "Gagal mengelola langganan. Silakan coba lagi nanti.",
|
||||
"Processing your payment...": "Memproses pembayaran Anda...",
|
||||
"Please wait while we confirm your subscription.": "Mohon tunggu saat kami mengonfirmasi langganan Anda.",
|
||||
"Payment Processing": "Memproses Pembayaran",
|
||||
@@ -380,7 +377,7 @@
|
||||
"More AI Translations": "Lebih Banyak Terjemahan AI",
|
||||
"Complete Your Subscription": "Selesaikan Langganan Anda",
|
||||
"{{percentage}}% of Cloud Sync Space Used.": "{{percentage}}% ruang sinkronisasi cloud terpakai",
|
||||
"Cloud Sync Storage": "Ruang cloud",
|
||||
"Cloud Sync Storage": "Penyimpanan Sinkronisasi Cloud",
|
||||
"Disable": "Nonaktifkan",
|
||||
"Enable": "Aktifkan",
|
||||
"Upgrade to Readest Premium": "Tingkatkan ke Readest Premium",
|
||||
@@ -397,7 +394,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "Dengarkan sebanyak yang Anda mau tanpa batas.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Dapatkan lebih banyak kuota dan fitur terjemahan lanjutan.",
|
||||
"DeepL Pro Access": "Akses DeepL Pro",
|
||||
"2 GB Cloud Sync Storage": "Penyimpanan Sinkronisasi Cloud 2 GB",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "Nikmati dukungan prioritas kapan saja.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Kuota harian habis. Tingkatkan paket Anda untuk lanjut.",
|
||||
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Terjemahkan teks apa pun secara instan dengan kekuatan Google, Azure, atau DeepL—pahami konten dalam bahasa apa pun.",
|
||||
@@ -408,10 +404,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Manfaatkan alat AI canggih untuk membaca cerdas, terjemahan, dan penemuan konten.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Terjemahkan hingga 100.000 karakter harian dengan mesin terjemahan paling akurat.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Terjemahkan hingga 500.000 karakter harian dengan mesin terjemahan paling akurat.",
|
||||
"10 GB Cloud Sync Storage": "Penyimpanan Cloud Sync 10 GB",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Simpan dan akses koleksi bacaan lengkap dengan aman menggunakan penyimpanan cloud 2 GB.",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Simpan dan akses koleksi bacaan lengkap dengan aman menggunakan penyimpanan cloud 10 GB.",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Simpan dan akses koleksi bacaan lengkap dengan aman menggunakan penyimpanan cloud 5 GB.",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Simpan dan akses koleksi bacaan lengkap dengan aman menggunakan penyimpanan cloud 20 GB.",
|
||||
"Deleted cloud backup of the book: {{title}}": "Cadangan cloud buku dihapus: {{title}}",
|
||||
"Failed to delete cloud backup of the book": "Cadangan cloud buku gagal dihapus",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Apakah Anda yakin ingin menghapus cadangan cloud dari buku yang dipilih?"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Apakah Anda yakin ingin menghapus cadangan cloud dari buku yang dipilih?",
|
||||
"What's New in Readest": "Yang Baru di Readest",
|
||||
"Enter book title": "Masukkan judul buku",
|
||||
"Subtitle": "Subjudul",
|
||||
"Enter book subtitle": "Masukkan subjudul buku",
|
||||
"Enter author name": "Masukkan nama penulis",
|
||||
"Series": "Seri",
|
||||
"Enter series name": "Masukkan nama seri",
|
||||
"Series Index": "Nomor seri",
|
||||
"Enter series index": "Masukkan nomor seri",
|
||||
"Total in Series": "Total dalam seri",
|
||||
"Enter total books in series": "Masukkan total buku dalam seri",
|
||||
"Enter publisher": "Masukkan penerbit",
|
||||
"Publication Date": "Tanggal terbit",
|
||||
"Identifier": "Pengidentifikasi",
|
||||
"Enter book description": "Masukkan deskripsi buku",
|
||||
"Change cover image": "Ubah gambar sampul",
|
||||
"Replace": "Ganti",
|
||||
"Unlock cover": "Buka kunci sampul",
|
||||
"Lock cover": "Kunci sampul",
|
||||
"Auto-Retrieve Metadata": "Ambil metadata otomatis",
|
||||
"Auto-Retrieve": "Ambil otomatis",
|
||||
"Unlock all fields": "Buka kunci semua field",
|
||||
"Unlock All": "Buka semua",
|
||||
"Lock all fields": "Kunci semua field",
|
||||
"Lock All": "Kunci semua",
|
||||
"Reset": "Reset",
|
||||
"Edit Metadata": "Edit metadata",
|
||||
"Locked": "Terkunci",
|
||||
"Select Metadata Source": "Pilih sumber metadata",
|
||||
"Keep manual input": "Pertahankan input manual",
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Fiksi, Sains, Sejarah",
|
||||
"Select Cover Image": "Pilihan Gambar Sampul",
|
||||
"Open Book in New Window": "Buka Buku di Jendela Baru",
|
||||
"Voices for {{lang}}": "Suara untuk {{lang}}",
|
||||
"Yandex Translate": "Yandex Translate",
|
||||
"YYYY or YYYY-MM-DD": "YYYY atau YYYY-MM-DD",
|
||||
"Restore Purchase": "Pulihkan Pembelian",
|
||||
"No purchases found to restore.": "Tidak ada pembelian yang ditemukan untuk dipulihkan.",
|
||||
"Failed to restore purchases.": "Gagal memulihkan pembelian.",
|
||||
"Failed to manage subscription.": "Gagal mengelola langganan.",
|
||||
"Failed to load subscription plans.": "Gagal memuat rencana langganan.",
|
||||
"year": "tahun",
|
||||
"Failed to create checkout session": "Gagal membuat sesi checkout",
|
||||
"Storage": "Penyimpanan",
|
||||
"Terms of Service": "Ketentuan Layanan",
|
||||
"Privacy Policy": "Kebijakan Privasi",
|
||||
"Disable Double Click": "Nonaktifkan Klik Ganda"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "Direzione orizzontale",
|
||||
"Hyphenation": "Sillabazione",
|
||||
"Import Books": "Importa libri",
|
||||
"Language:": "Lingua:",
|
||||
"Layout": "Layout",
|
||||
"Light Mode": "Modalità chiara",
|
||||
"Loading...": "Caricamento...",
|
||||
@@ -68,8 +67,8 @@
|
||||
"Paging Animation": "Animazione cambio pagina",
|
||||
"Paragraph": "Paragrafo",
|
||||
"Parallel Read": "Lettura parallela",
|
||||
"Published:": "Pubblicato:",
|
||||
"Publisher:": "Editore:",
|
||||
"Published": "Pubblicato",
|
||||
"Publisher": "Editore",
|
||||
"Reading Progress Synced": "Progresso lettura sincronizzato",
|
||||
"Reload Page": "Ricarica pagina",
|
||||
"Reveal in File Explorer": "Mostra in Esplora file",
|
||||
@@ -94,7 +93,7 @@
|
||||
"Slow": "Lento",
|
||||
"Solarized": "Solarizzato",
|
||||
"Speak": "Leggi",
|
||||
"Subjects:": "Argomenti:",
|
||||
"Subjects": "Argomenti",
|
||||
"System Fonts": "Font di sistema",
|
||||
"TTS not supported in this device": "TTS non supportato su questo dispositivo",
|
||||
"Theme Color": "Colore tema",
|
||||
@@ -103,7 +102,7 @@
|
||||
"Translated Text": "Testo tradotto",
|
||||
"Unknown": "Sconosciuto",
|
||||
"Untitled": "Senza titolo",
|
||||
"Updated:": "Aggiornato:",
|
||||
"Updated": "Aggiornato",
|
||||
"Version {{version}}": "Versione {{version}}",
|
||||
"Vertical Direction": "Direzione verticale",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Benvenuto nella tua biblioteca. Puoi importare i tuoi libri qui e leggerli in qualsiasi momento.",
|
||||
@@ -244,7 +243,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Scaricando {{downloaded}} di {{contentLength}}",
|
||||
"Download finished": "Download completato",
|
||||
"DOWNLOAD & INSTALL": "SCARICA E INSTALLA",
|
||||
"Changelog:": "Note di rilascio:",
|
||||
"Changelog": "Note di rilascio",
|
||||
"Software Update": "Aggiornamento software",
|
||||
"Title": "Titolo",
|
||||
"Date Read": "Data di lettura",
|
||||
@@ -253,10 +252,9 @@
|
||||
"Ascending": "Crescente",
|
||||
"Descending": "Decrescente",
|
||||
"Sort by...": "Ordina per...",
|
||||
"Added:": "Aggiunto:",
|
||||
"Format:": "Formato:",
|
||||
"Added": "Aggiunto",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Description:": "Descrizione:",
|
||||
"Description": "Descrizione",
|
||||
"No description available": "Nessuna descrizione disponibile",
|
||||
"List": "Elenco",
|
||||
"Grid": "Griglia",
|
||||
@@ -273,7 +271,7 @@
|
||||
"Checking for updates...": "Controllo aggiornamenti...",
|
||||
"Error checking for updates": "Errore durante il controllo degli aggiornamenti",
|
||||
"Details": "Dettagli",
|
||||
"File Size:": "Dimensione file:",
|
||||
"File Size": "Dimensione file",
|
||||
"Auto Detect": "Rilevamento automatico",
|
||||
"Next Section": "Sezione successiva",
|
||||
"Previous Section": "Sezione precedente",
|
||||
@@ -363,7 +361,6 @@
|
||||
"Upgrade to Plus or Pro": "Aggiorna a Plus o Pro",
|
||||
"Current Plan": "Piano Attuale",
|
||||
"Plan Limits": "Limiti del Piano",
|
||||
"Failed to manage subscription. Please try again later.": "Impossibile gestire l'abbonamento. Riprova più tardi.",
|
||||
"Processing your payment...": "Elaborazione del pagamento...",
|
||||
"Please wait while we confirm your subscription.": "Attendi mentre confermiamo il tuo abbonamento.",
|
||||
"Payment Processing": "Elaborazione Pagamento",
|
||||
@@ -405,7 +402,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "Ascolta senza limiti quanto vuoi.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Sblocca traduzioni avanzate e più utilizzo giornaliero.",
|
||||
"DeepL Pro Access": "Accesso DeepL Pro",
|
||||
"2 GB Cloud Sync Storage": "2 GB di Archiviazione Cloud",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "Ricevi supporto prioritario quando serve.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Quota giornaliera esaurita. Passa a un piano superiore per continuare.",
|
||||
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Traduci qualsiasi testo istantaneamente con la potenza di Google, Azure o DeepL—comprendi contenuti in qualsiasi lingua.",
|
||||
@@ -416,10 +412,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Sfrutta potenti strumenti AI per lettura intelligente, traduzione e scoperta di contenuti.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Traduci fino a 100.000 caratteri al giorno con il motore di traduzione più accurato.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Traduci fino a 500.000 caratteri al giorno con il motore di traduzione più accurato.",
|
||||
"10 GB Cloud Sync Storage": "Archiviazione Cloud Sync da 10 GB",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Archivia e accedi in sicurezza alla tua intera collezione di lettura con fino a 2 GB di storage cloud.",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Archivia e accedi in sicurezza alla tua intera collezione di lettura con fino a 10 GB di storage cloud.",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Archivia e accedi in sicurezza alla tua intera collezione di lettura con fino a 5 GB di storage cloud.",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Archivia e accedi in sicurezza alla tua intera collezione di lettura con fino a 20 GB di storage cloud.",
|
||||
"Deleted cloud backup of the book: {{title}}": "Backup cloud del libro eliminato: {{title}}",
|
||||
"Failed to delete cloud backup of the book": "Impossibile eliminare il backup cloud del libro",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Sei sicuro di voler eliminare il backup cloud del libro selezionato?"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Sei sicuro di voler eliminare il backup cloud del libro selezionato?",
|
||||
"What's New in Readest": "Novità in Readest",
|
||||
"Enter book title": "Inserisci il titolo del libro",
|
||||
"Subtitle": "Sottotitolo",
|
||||
"Enter book subtitle": "Inserisci il sottotitolo del libro",
|
||||
"Enter author name": "Inserisci il nome dell'autore",
|
||||
"Series": "Serie",
|
||||
"Enter series name": "Inserisci il nome della serie",
|
||||
"Series Index": "Numero della serie",
|
||||
"Enter series index": "Inserisci il numero della serie",
|
||||
"Total in Series": "Totale nella serie",
|
||||
"Enter total books in series": "Inserisci il numero totale di libri nella serie",
|
||||
"Enter publisher": "Inserisci l'editore",
|
||||
"Publication Date": "Data di pubblicazione",
|
||||
"Identifier": "Identificatore",
|
||||
"Enter book description": "Inserisci la descrizione del libro",
|
||||
"Change cover image": "Cambia immagine di copertina",
|
||||
"Replace": "Sostituisci",
|
||||
"Unlock cover": "Sblocca copertina",
|
||||
"Lock cover": "Blocca copertina",
|
||||
"Auto-Retrieve Metadata": "Recupera metadati automaticamente",
|
||||
"Auto-Retrieve": "Recupero automatico",
|
||||
"Unlock all fields": "Sblocca tutti i campi",
|
||||
"Unlock All": "Sblocca tutto",
|
||||
"Lock all fields": "Blocca tutti i campi",
|
||||
"Lock All": "Blocca tutto",
|
||||
"Reset": "Ripristina",
|
||||
"Edit Metadata": "Modifica metadati",
|
||||
"Locked": "Bloccato",
|
||||
"Select Metadata Source": "Seleziona fonte metadati",
|
||||
"Keep manual input": "Mantieni input manuale",
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Narrativa, Scienza, Storia",
|
||||
"Select Cover Image": "Seleziona immagine di copertina",
|
||||
"Open Book in New Window": "Apri libro in una nuova finestra",
|
||||
"Voices for {{lang}}": "Voci per {{lang}}",
|
||||
"Yandex Translate": "Yandex Translate",
|
||||
"YYYY or YYYY-MM-DD": "AAAA o AAAA-MM-GG",
|
||||
"Restore Purchase": "Ripristina acquisto",
|
||||
"No purchases found to restore.": "Nessun acquisto trovato da ripristinare.",
|
||||
"Failed to restore purchases.": "Impossibile ripristinare gli acquisti.",
|
||||
"Failed to manage subscription.": "Impossibile gestire l'abbonamento.",
|
||||
"Failed to load subscription plans.": "Impossibile caricare i piani di abbonamento.",
|
||||
"year": "anno",
|
||||
"Failed to create checkout session": "Impossibile creare la sessione di checkout",
|
||||
"Storage": "Archiviazione",
|
||||
"Terms of Service": "Termini di servizio",
|
||||
"Privacy Policy": "Informativa sulla privacy",
|
||||
"Disable Double Click": "Disabilita Doppio Clic"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "横書",
|
||||
"Hyphenation": "ハイフネーション",
|
||||
"Import Books": "書籍をインポート",
|
||||
"Language:": "言語:",
|
||||
"Layout": "レイアウト",
|
||||
"Light Mode": "ライトモード",
|
||||
"Loading...": "読み込み中...",
|
||||
@@ -68,8 +67,8 @@
|
||||
"Paging Animation": "ページめくりアニメーション",
|
||||
"Paragraph": "段落",
|
||||
"Parallel Read": "並列読書",
|
||||
"Published:": "出版日:",
|
||||
"Publisher:": "出版社:",
|
||||
"Published": "出版日",
|
||||
"Publisher": "出版社",
|
||||
"Reading Progress Synced": "読書進捗が同期されました",
|
||||
"Reload Page": "ページを再読み込み",
|
||||
"Reveal in File Explorer": "エクスプローラーで表示",
|
||||
@@ -94,7 +93,7 @@
|
||||
"Slow": "低速",
|
||||
"Solarized": "ソーラライズド",
|
||||
"Speak": "読み上げ",
|
||||
"Subjects:": "主題:",
|
||||
"Subjects": "主題",
|
||||
"System Fonts": "システムフォント",
|
||||
"TTS not supported in this device": "このデバイスではTTSがサポートされていません",
|
||||
"Theme Color": "テーマカラー",
|
||||
@@ -103,7 +102,7 @@
|
||||
"Translated Text": "翻訳されたテキスト",
|
||||
"Unknown": "不明",
|
||||
"Untitled": "無題",
|
||||
"Updated:": "更新日:",
|
||||
"Updated": "更新日",
|
||||
"Version {{version}}": "バージョン {{version}}",
|
||||
"Vertical Direction": "縦書",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "ライブラリーへようこそ。ここに書籍をインポートして、いつでも読むことができます。",
|
||||
@@ -244,7 +243,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "ダウンロード中 {{downloaded}} / {{contentLength}}",
|
||||
"Download finished": "ダウンロード完了",
|
||||
"DOWNLOAD & INSTALL": "更新",
|
||||
"Changelog:": "変更ログ:",
|
||||
"Changelog": "変更ログ",
|
||||
"Software Update": "ソフトウェアの更新",
|
||||
"Title": "タイトル",
|
||||
"Date Read": "読書日",
|
||||
@@ -253,10 +252,9 @@
|
||||
"Ascending": "昇順",
|
||||
"Descending": "降順",
|
||||
"Sort by...": "並べ替え...",
|
||||
"Added:": "追加日:",
|
||||
"Format:": "形式:",
|
||||
"Added": "追加日",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Description:": "説明:",
|
||||
"Description": "説明",
|
||||
"No description available": "説明はありません",
|
||||
"List": "リスト",
|
||||
"Grid": "グリッド",
|
||||
@@ -273,7 +271,7 @@
|
||||
"Checking for updates...": "更新を確認中...",
|
||||
"Error checking for updates": "更新の確認中にエラーが発生しました",
|
||||
"Details": "詳細",
|
||||
"File Size:": "ファイルサイズ:",
|
||||
"File Size": "ファイルサイズ",
|
||||
"Auto Detect": "自動検出",
|
||||
"Next Section": "次のセクション",
|
||||
"Previous Section": "前のセクション",
|
||||
@@ -355,7 +353,6 @@
|
||||
"Upgrade to Plus or Pro": "Plus または Pro にアップグレード",
|
||||
"Current Plan": "現在のプラン",
|
||||
"Plan Limits": "プランの上限",
|
||||
"Failed to manage subscription. Please try again later.": "サブスクリプションの管理に失敗しました。後でもう一度お試しください。",
|
||||
"Processing your payment...": "お支払いを処理中...",
|
||||
"Please wait while we confirm your subscription.": "サブスクリプションを確認しています。しばらくお待ちください。",
|
||||
"Payment Processing": "支払いを処理中",
|
||||
@@ -398,7 +395,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "制限なくテキストを音声化。",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "高度な翻訳機能を利用可能に。",
|
||||
"DeepL Pro Access": "DeepL Pro利用",
|
||||
"2 GB Cloud Sync Storage": "2GBクラウド保存",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "優先サポートを利用可能。",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "翻訳上限に達しました。アップグレードで継続可能です。",
|
||||
"Includes All Plus Plan Benefits": "Plusプランの全特典を含む",
|
||||
@@ -408,10 +404,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "スマートな読書、翻訳、コンテンツ発見のための強力なAIツールを活用。",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "最も正確な翻訳エンジンで1日最大10万文字を翻訳。",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "最も正確な翻訳エンジンで1日最大50万文字を翻訳。",
|
||||
"10 GB Cloud Sync Storage": "10GBクラウド同期ストレージ",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "最大2GBの安全なクラウドストレージで読書コレクション全体を保存・アクセス。",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "最大10GBの安全なクラウドストレージで読書コレクション全体を保存・アクセス。",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "最大5GBの安全なクラウドストレージで読書コレクション全体を保存・アクセス。",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "最大20GBの安全なクラウドストレージで読書コレクション全体を保存・アクセス。",
|
||||
"Deleted cloud backup of the book: {{title}}": "書籍のクラウドバックアップを削除しました:{{title}}",
|
||||
"Failed to delete cloud backup of the book": "書籍のクラウドバックアップを削除できませんでした",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "選択した書籍のクラウドバックアップを削除してもよろしいですか?"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "選択した書籍のクラウドバックアップを削除してもよろしいですか?",
|
||||
"What's New in Readest": "Readestの新機能",
|
||||
"Enter book title": "本のタイトルを入力",
|
||||
"Subtitle": "サブタイトル",
|
||||
"Enter book subtitle": "本のサブタイトルを入力",
|
||||
"Enter author name": "著者名を入力",
|
||||
"Series": "シリーズ",
|
||||
"Enter series name": "シリーズ名を入力",
|
||||
"Series Index": "シリーズ番号",
|
||||
"Enter series index": "シリーズ番号を入力",
|
||||
"Total in Series": "シリーズ総数",
|
||||
"Enter total books in series": "シリーズの総冊数を入力",
|
||||
"Enter publisher": "出版社を入力",
|
||||
"Publication Date": "出版日",
|
||||
"Identifier": "識別子",
|
||||
"Enter book description": "本の説明を入力",
|
||||
"Change cover image": "表紙画像を変更",
|
||||
"Replace": "置き換え",
|
||||
"Unlock cover": "表紙のロックを解除",
|
||||
"Lock cover": "表紙をロック",
|
||||
"Auto-Retrieve Metadata": "メタデータを自動取得",
|
||||
"Auto-Retrieve": "自動取得",
|
||||
"Unlock all fields": "すべてのフィールドのロックを解除",
|
||||
"Unlock All": "すべてロック解除",
|
||||
"Lock all fields": "すべてのフィールドをロック",
|
||||
"Lock All": "すべてロック",
|
||||
"Reset": "リセット",
|
||||
"Edit Metadata": "メタデータを編集",
|
||||
"Locked": "ロック中",
|
||||
"Select Metadata Source": "メタデータソースを選択",
|
||||
"Keep manual input": "手動入力を保持",
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "小説、科学、歴史",
|
||||
"Select Cover Image": "表紙画像を選択",
|
||||
"Open Book in New Window": "新しいウィンドウで本を開く",
|
||||
"Voices for {{lang}}": "{{lang}}の音声",
|
||||
"Yandex Translate": "Yandex翻訳",
|
||||
"YYYY or YYYY-MM-DD": "YYYYまたはYYYY-MM-DD",
|
||||
"Restore Purchase": "購入を復元",
|
||||
"No purchases found to restore.": "復元する購入が見つかりません。",
|
||||
"Failed to restore purchases.": "購入の復元に失敗しました。",
|
||||
"Failed to manage subscription.": "サブスクリプションの管理に失敗しました。",
|
||||
"Failed to load subscription plans.": "サブスクリプションプランの読み込みに失敗しました。",
|
||||
"year": "年",
|
||||
"Failed to create checkout session": "チェックアウトセッションの作成に失敗しました",
|
||||
"Storage": "ストレージ",
|
||||
"Terms of Service": "利用規約",
|
||||
"Privacy Policy": "プライバシーポリシー",
|
||||
"Disable Double Click": "ダブルクリック無効"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "수평 방향",
|
||||
"Hyphenation": "하이픈 넣기",
|
||||
"Import Books": "책 가져오기",
|
||||
"Language:": "언어:",
|
||||
"Layout": "레이아웃",
|
||||
"Light Mode": "라이트 모드",
|
||||
"Loading...": "로딩 중...",
|
||||
@@ -68,8 +67,8 @@
|
||||
"Paging Animation": "페이지 넘김 애니메이션",
|
||||
"Paragraph": "단락",
|
||||
"Parallel Read": "병렬 읽기",
|
||||
"Published:": "출판일:",
|
||||
"Publisher:": "출판사:",
|
||||
"Published": "출판일",
|
||||
"Publisher": "출판사",
|
||||
"Reading Progress Synced": "읽기 진행 상황 동기화됨",
|
||||
"Reload Page": "페이지 새로고침",
|
||||
"Reveal in File Explorer": "파일 탐색기에서 표시",
|
||||
@@ -94,7 +93,7 @@
|
||||
"Slow": "느림",
|
||||
"Solarized": "솔라라이즈드",
|
||||
"Speak": "말하기",
|
||||
"Subjects:": "주제:",
|
||||
"Subjects": "주제",
|
||||
"System Fonts": "시스템 글꼴",
|
||||
"TTS not supported in this device": "이 장치에서 TTS가 지원되지 않음",
|
||||
"Theme Color": "테마 색상",
|
||||
@@ -103,7 +102,7 @@
|
||||
"Translated Text": "번역된 텍스트",
|
||||
"Unknown": "알 수 없음",
|
||||
"Untitled": "제목 없음",
|
||||
"Updated:": "업데이트일:",
|
||||
"Updated": "업데이트일",
|
||||
"Version {{version}}": "버전 {{version}}",
|
||||
"Vertical Direction": "수직 방향",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "내 서재에 오신 것을 환영합니다. 여기에서 책을 가져와 언제든지 읽을 수 있습니다.",
|
||||
@@ -244,7 +243,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "다운로드 중 {{downloaded}} / {{contentLength}}",
|
||||
"Download finished": "다운로드 완료",
|
||||
"DOWNLOAD & INSTALL": "다운로드 및 설치",
|
||||
"Changelog:": "변경 로그:",
|
||||
"Changelog": "변경 로그",
|
||||
"Software Update": "소프트웨어 업데이트",
|
||||
"Title": "제목",
|
||||
"Date Read": "읽은 날짜",
|
||||
@@ -253,10 +252,9 @@
|
||||
"Ascending": "오름차순",
|
||||
"Descending": "내림차순",
|
||||
"Sort by...": "정렬 기준...",
|
||||
"Added:": "추가됨:",
|
||||
"Format:": "형식:",
|
||||
"Added": "추가됨",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Description:": "설명:",
|
||||
"Description": "설명",
|
||||
"No description available": "설명 없음",
|
||||
"List": "목록",
|
||||
"Grid": "격자",
|
||||
@@ -273,7 +271,7 @@
|
||||
"Checking for updates...": "업데이트 확인 중...",
|
||||
"Error checking for updates": "업데이트 확인 중 오류 발생",
|
||||
"Details": "세부정보",
|
||||
"File Size:": "파일 크기:",
|
||||
"File Size": "파일 크기",
|
||||
"Auto Detect": "자동 감지",
|
||||
"Next Section": "다음 섹션",
|
||||
"Previous Section": "이전 섹션",
|
||||
@@ -355,7 +353,6 @@
|
||||
"Upgrade to Plus or Pro": "Plus 또는 Pro로 업그레이드",
|
||||
"Current Plan": "현재 요금제",
|
||||
"Plan Limits": "요금제 한도",
|
||||
"Failed to manage subscription. Please try again later.": "구독 관리에 실패했습니다. 나중에 다시 시도해주세요.",
|
||||
"Processing your payment...": "결제를 처리 중입니다...",
|
||||
"Please wait while we confirm your subscription.": "구독을 확인하는 중입니다. 잠시만 기다려주세요.",
|
||||
"Payment Processing": "결제 처리 중",
|
||||
@@ -398,7 +395,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "제한 없이 텍스트를 음성으로 변환하세요.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "고급 번역 기능을 더 많이 이용하세요.",
|
||||
"DeepL Pro Access": "DeepL Pro 이용",
|
||||
"2 GB Cloud Sync Storage": "2GB 클라우드 저장소",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "우선 지원을 이용하세요.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "일일 번역 한도에 도달했습니다. 업그레이드하여 계속 사용하세요.",
|
||||
"Includes All Plus Plan Benefits": "모든 플러스 플랜 혜택 포함",
|
||||
@@ -408,10 +404,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "스마트한 독서, 번역, 콘텐츠 발견을 위한 강력한 AI 도구를 활용하세요.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "가장 정확한 번역 엔진으로 매일 최대 10만 자를 번역하세요.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "가장 정확한 번역 엔진으로 매일 최대 50만 자를 번역하세요.",
|
||||
"10 GB Cloud Sync Storage": "10GB 클라우드 동기화 저장소",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "최대 2GB의 안전한 클라우드 저장소로 전체 독서 컬렉션을 저장하고 액세스하세요.",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "최대 10GB의 안전한 클라우드 저장소로 전체 독서 컬렉션을 저장하고 액세스하세요.",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "최대 5GB의 안전한 클라우드 저장소로 전체 독서 컬렉션을 저장하고 액세스하세요.",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "최대 20GB의 안전한 클라우드 저장소로 전체 독서 컬렉션을 저장하고 액세스하세요.",
|
||||
"Deleted cloud backup of the book: {{title}}": "책의 클라우드 백업이 삭제됨: {{title}}",
|
||||
"Failed to delete cloud backup of the book": "책의 클라우드 백업을 삭제하지 못했습니다.",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "선택한 책의 클라우드 백업을 삭제하시겠습니까?"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "선택한 책의 클라우드 백업을 삭제하시겠습니까?",
|
||||
"What's New in Readest": "Readest의 새로운 기능",
|
||||
"Enter book title": "도서 제목 입력",
|
||||
"Subtitle": "부제목",
|
||||
"Enter book subtitle": "도서 부제목 입력",
|
||||
"Enter author name": "저자명 입력",
|
||||
"Series": "시리즈",
|
||||
"Enter series name": "시리즈명 입력",
|
||||
"Series Index": "시리즈 번호",
|
||||
"Enter series index": "시리즈 번호 입력",
|
||||
"Total in Series": "시리즈 총권수",
|
||||
"Enter total books in series": "시리즈 총 도서 수 입력",
|
||||
"Enter publisher": "출판사 입력",
|
||||
"Publication Date": "출간일",
|
||||
"Identifier": "식별자",
|
||||
"Enter book description": "도서 설명 입력",
|
||||
"Change cover image": "표지 이미지 변경",
|
||||
"Replace": "교체",
|
||||
"Unlock cover": "표지 잠금 해제",
|
||||
"Lock cover": "표지 잠금",
|
||||
"Auto-Retrieve Metadata": "메타데이터 자동 검색",
|
||||
"Auto-Retrieve": "자동 검색",
|
||||
"Unlock all fields": "모든 필드 잠금 해제",
|
||||
"Unlock All": "모두 잠금 해제",
|
||||
"Lock all fields": "모든 필드 잠금",
|
||||
"Lock All": "모두 잠금",
|
||||
"Reset": "초기화",
|
||||
"Edit Metadata": "메타데이터 편집",
|
||||
"Locked": "잠김",
|
||||
"Select Metadata Source": "메타데이터 소스 선택",
|
||||
"Keep manual input": "수동 입력 유지",
|
||||
"Google Books": "구글 북스",
|
||||
"Open Library": "오픈 라이브러리",
|
||||
"Fiction, Science, History": "소설, 과학, 역사",
|
||||
"Select Cover Image": "표지 이미지 선택",
|
||||
"Open Book in New Window": "새 창에서 책 열기",
|
||||
"Voices for {{lang}}": "{{lang}}의 음성",
|
||||
"Yandex Translate": "Yandex 번역",
|
||||
"YYYY or YYYY-MM-DD": "YYYY 또는 YYYY-MM-DD",
|
||||
"Restore Purchase": "구매 복원",
|
||||
"No purchases found to restore.": "복원할 구매가 없습니다.",
|
||||
"Failed to restore purchases.": "구매 복원에 실패했습니다.",
|
||||
"Failed to manage subscription.": "구독 관리에 실패했습니다.",
|
||||
"Failed to load subscription plans.": "구독 플랜 로드에 실패했습니다.",
|
||||
"year": "년",
|
||||
"Failed to create checkout session": "체크아웃 세션 생성 실패",
|
||||
"Storage": "저장소",
|
||||
"Terms of Service": "서비스 약관",
|
||||
"Privacy Policy": "개인정보 보호정책",
|
||||
"Disable Double Click": "더블 클릭 비활성화"
|
||||
}
|
||||
|
||||
@@ -238,15 +238,13 @@
|
||||
"Error checking for updates": "Fout bij controleren op updates",
|
||||
"Book Details": "Boekdetails",
|
||||
"Unknown": "Onbekend",
|
||||
"Publisher:": "Uitgever:",
|
||||
"Published:": "Gepubliceerd:",
|
||||
"Updated:": "Bijgewerkt:",
|
||||
"Added:": "Toegevoegd:",
|
||||
"Language:": "Taal:",
|
||||
"Subjects:": "Onderwerpen:",
|
||||
"Format:": "Formaat:",
|
||||
"File Size:": "Bestandsgrootte:",
|
||||
"Description:": "Beschrijving:",
|
||||
"Publisher": "Uitgever",
|
||||
"Published": "Gepubliceerd",
|
||||
"Updated": "Bijgewerkt",
|
||||
"Added": "Toegevoegd",
|
||||
"Subjects": "Onderwerpen",
|
||||
"File Size": "Bestandsgrootte",
|
||||
"Description": "Beschrijving",
|
||||
"No description available": "Geen beschrijving beschikbaar",
|
||||
"Drop to Import Books": "Sleep om boeken te importeren",
|
||||
"A new version of Readest is available!": "Een nieuwe versie van Readest is beschikbaar!",
|
||||
@@ -255,7 +253,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Downloaden {{downloaded}} van {{contentLength}}",
|
||||
"Download finished": "Download voltooid",
|
||||
"DOWNLOAD & INSTALL": "DOWNLOADEN & INSTALLEREN",
|
||||
"Changelog:": "Wijzigingslogboek:",
|
||||
"Changelog": "Wijzigingslogboek",
|
||||
"Software Update": "Software-update",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai GB Scherm",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
@@ -359,7 +357,6 @@
|
||||
"Upgrade to Plus or Pro": "Upgrade naar Plus of Pro",
|
||||
"Current Plan": "Huidig abonnement",
|
||||
"Plan Limits": "Abonnementslimieten",
|
||||
"Failed to manage subscription. Please try again later.": "Abonnement beheren is mislukt. Probeer het later opnieuw.",
|
||||
"Processing your payment...": "Je betaling wordt verwerkt...",
|
||||
"Please wait while we confirm your subscription.": "Even geduld terwijl we je abonnement bevestigen.",
|
||||
"Payment Processing": "Betaling verwerken",
|
||||
@@ -402,7 +399,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "Luister zonder limieten—zet zoveel tekst als je wilt om in meeslepende audio.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Ontsluit verbeterde vertaalfuncties met meer dagelijks gebruik en geavanceerde opties.",
|
||||
"DeepL Pro Access": "DeepL Pro Toegang",
|
||||
"2 GB Cloud Sync Storage": "2 GB Cloud Sync Opslag",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "Geniet van snellere reacties en toegewijde hulp wanneer je hulp nodig hebt.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Dagelijks vertaalquotum bereikt. Upgrade je abonnement om AI vertalingen te blijven gebruiken.",
|
||||
"Includes All Plus Plan Benefits": "Inclusief Alle Plus Plan Voordelen",
|
||||
@@ -412,10 +408,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Benut krachtige AI tools voor slimmer lezen, vertalen en content ontdekking.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Vertaal dagelijks tot 100.000 tekens met de meest nauwkeurige vertaalengine.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Vertaal dagelijks tot 500.000 tekens met de meest nauwkeurige vertaalengine.",
|
||||
"10 GB Cloud Sync Storage": "10 GB Cloud Sync Opslag",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Sla je hele leesverzameling veilig op en benader deze met tot 2 GB cloudopslag.",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Sla je hele leesverzameling veilig op en benader deze met tot 10 GB cloudopslag.",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Sla je hele leesverzameling veilig op en benader deze met tot 5 GB cloudopslag.",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Sla je hele leesverzameling veilig op en benader deze met tot 20 GB cloudopslag.",
|
||||
"Deleted cloud backup of the book: {{title}}": "Verwijderde cloudback-up van het boek: {{title}}",
|
||||
"Failed to delete cloud backup of the book": "Verwijderen van cloudback-up van het boek is mislukt.",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Weet je zeker dat je de cloudback-up van het geselecteerde boek wilt verwijderen?"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Weet je zeker dat je de cloudback-up van het geselecteerde boek wilt verwijderen?",
|
||||
"What's New in Readest": "Wat is er nieuw in Readest",
|
||||
"Enter book title": "Voer boektitel in",
|
||||
"Subtitle": "Ondertitel",
|
||||
"Enter book subtitle": "Voer boek ondertitel in",
|
||||
"Enter author name": "Voer auteursnaam in",
|
||||
"Series": "Serie",
|
||||
"Enter series name": "Voer serienaam in",
|
||||
"Series Index": "Serie nummer",
|
||||
"Enter series index": "Voer serie nummer in",
|
||||
"Total in Series": "Totaal in serie",
|
||||
"Enter total books in series": "Voer totaal aantal boeken in serie in",
|
||||
"Enter publisher": "Voer uitgever in",
|
||||
"Publication Date": "Publicatiedatum",
|
||||
"Identifier": "Identificatie",
|
||||
"Enter book description": "Voer boekbeschrijving in",
|
||||
"Change cover image": "Wijzig omslagafbeelding",
|
||||
"Replace": "Vervangen",
|
||||
"Unlock cover": "Omslag ontgrendelen",
|
||||
"Lock cover": "Omslag vergrendelen",
|
||||
"Auto-Retrieve Metadata": "Metadata automatisch ophalen",
|
||||
"Auto-Retrieve": "Automatisch ophalen",
|
||||
"Unlock all fields": "Alle velden ontgrendelen",
|
||||
"Unlock All": "Alles ontgrendelen",
|
||||
"Lock all fields": "Alle velden vergrendelen",
|
||||
"Lock All": "Alles vergrendelen",
|
||||
"Reset": "Herstellen",
|
||||
"Edit Metadata": "Metadata bewerken",
|
||||
"Locked": "Vergrendeld",
|
||||
"Select Metadata Source": "Selecteer metadata bron",
|
||||
"Keep manual input": "Handmatige invoer behouden",
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Fictie, Wetenschap, Geschiedenis",
|
||||
"Select Cover Image": "Selecteer omslagafbeelding",
|
||||
"Open Book in New Window": "Open boek in nieuw venster",
|
||||
"Voices for {{lang}}": "Stemmen voor {{lang}}",
|
||||
"Yandex Translate": "Yandex Vertalen",
|
||||
"YYYY or YYYY-MM-DD": "YYYY of YYYY-MM-DD",
|
||||
"Restore Purchase": "Herstel aankoop",
|
||||
"No purchases found to restore.": "Geen aankopen gevonden om te herstellen.",
|
||||
"Failed to restore purchases.": "Herstellen van aankopen is mislukt.",
|
||||
"Failed to manage subscription.": "Beheren van abonnement is mislukt.",
|
||||
"Failed to load subscription plans.": "Laden van abonnement plannen is mislukt.",
|
||||
"year": "jaar",
|
||||
"Failed to create checkout session": "Kon de afreken sessie niet aanmaken",
|
||||
"Storage": "Opslag",
|
||||
"Terms of Service": "Gebruiksvoorwaarden",
|
||||
"Privacy Policy": "Privacybeleid",
|
||||
"Disable Double Click": "Dubbelklikken uitschakelen"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "Kierunek poziomy",
|
||||
"Hyphenation": "Dzielenie wyrazów",
|
||||
"Import Books": "Importuj książki",
|
||||
"Language:": "Język:",
|
||||
"Layout": "Układ",
|
||||
"Light Mode": "Tryb jasny",
|
||||
"Loading...": "Ładowanie...",
|
||||
@@ -68,8 +67,8 @@
|
||||
"Paging Animation": "Animacja przewracania stron",
|
||||
"Paragraph": "Akapit",
|
||||
"Parallel Read": "Czytanie równoległe",
|
||||
"Published:": "Opublikowano:",
|
||||
"Publisher:": "Wydawca:",
|
||||
"Published": "Opublikowano",
|
||||
"Publisher": "Wydawca",
|
||||
"Reading Progress Synced": "Zsynchronizowano postęp czytania",
|
||||
"Reload Page": "Odśwież stronę",
|
||||
"Reveal in File Explorer": "Pokaż w Eksploratorze plików",
|
||||
@@ -94,7 +93,7 @@
|
||||
"Slow": "Wolno",
|
||||
"Solarized": "Słoneczny",
|
||||
"Speak": "Czytaj",
|
||||
"Subjects:": "Tematy:",
|
||||
"Subjects": "Tematy",
|
||||
"System Fonts": "Czcionki systemowe",
|
||||
"TTS not supported in this device": "TTS nie jest obsługiwane na tym urządzeniu",
|
||||
"Theme Color": "Kolor motywu",
|
||||
@@ -103,7 +102,7 @@
|
||||
"Translated Text": "Przetłumaczony tekst",
|
||||
"Unknown": "Nieznane",
|
||||
"Untitled": "Bez tytułu",
|
||||
"Updated:": "Zaktualizowano:",
|
||||
"Updated": "Zaktualizowano",
|
||||
"Version {{version}}": "Wersja {{version}}",
|
||||
"Vertical Direction": "Kierunek pionowy",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Witaj w swojej bibliotece. Możesz tutaj zaimportować swoje książki i czytać je w dowolnym momencie.",
|
||||
@@ -244,7 +243,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Pobieranie {{downloaded}} z {{contentLength}}",
|
||||
"Download finished": "Pobieranie zakończone",
|
||||
"DOWNLOAD & INSTALL": "POBIERZ I ZAINSTALUJ",
|
||||
"Changelog:": "Dziennik zmian:",
|
||||
"Changelog": "Dziennik zmian",
|
||||
"Software Update": "Aktualizacja oprogramowania",
|
||||
"Title": "Tytuł",
|
||||
"Date Read": "Data przeczytania",
|
||||
@@ -253,10 +252,9 @@
|
||||
"Ascending": "Rosnąco",
|
||||
"Descending": "Malejąco",
|
||||
"Sort by...": "Sortuj według...",
|
||||
"Added:": "Data dodania:",
|
||||
"Format:": "Format:",
|
||||
"Added": "Data dodania",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Description:": "Opis:",
|
||||
"Description": "Opis",
|
||||
"No description available": "Brak opisu",
|
||||
"List": "Lista",
|
||||
"Grid": "Siatka",
|
||||
@@ -273,7 +271,7 @@
|
||||
"Checking for updates...": "Sprawdzanie aktualizacji...",
|
||||
"Error checking for updates": "Błąd podczas sprawdzania aktualizacji",
|
||||
"Details": "Szczegóły",
|
||||
"File Size:": "Rozmiar pliku:",
|
||||
"File Size": "Rozmiar pliku",
|
||||
"Auto Detect": "Automatyczne wykrywanie",
|
||||
"Next Section": "Następna sekcja",
|
||||
"Previous Section": "Poprzednia sekcja",
|
||||
@@ -367,7 +365,6 @@
|
||||
"Upgrade to Plus or Pro": "Uaktualnij do Plus lub Pro",
|
||||
"Current Plan": "Obecny plan",
|
||||
"Plan Limits": "Limity planu",
|
||||
"Failed to manage subscription. Please try again later.": "Nie udało się zarządzać subskrypcją. Spróbuj ponownie później.",
|
||||
"Processing your payment...": "Przetwarzanie płatności...",
|
||||
"Please wait while we confirm your subscription.": "Proszę czekać, potwierdzamy subskrypcję.",
|
||||
"Payment Processing": "Przetwarzanie płatności",
|
||||
@@ -410,7 +407,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "Słuchaj bez ograniczeń—przekształć tyle tekstu, ile chcesz, w wciągające audio.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Odblokuj ulepszone możliwości tłumaczenia z większym dziennym użyciem i zaawansowanymi opcjami.",
|
||||
"DeepL Pro Access": "Dostęp do DeepL Pro",
|
||||
"2 GB Cloud Sync Storage": "2 GB Pamięci Cloud Sync",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "Ciesz się szybszymi odpowiedziami i dedykowaną pomocą, kiedy tylko potrzebujesz wsparcia.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Osiągnięto dzienny limit tłumaczeń. Uaktualnij swój plan, aby kontynuować korzystanie z tłumaczeń AI.",
|
||||
"Includes All Plus Plan Benefits": "Zawiera Wszystkie Korzyści Planu Plus",
|
||||
@@ -420,10 +416,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Wykorzystuj potężne narzędzia AI do inteligentnego czytania, tłumaczenia i odkrywania treści.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Tłumacz do 100 000 znaków dziennie z najdokładniejszą dostępną maszyną tłumaczeniową.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Tłumacz do 500 000 znaków dziennie z najdokładniejszą dostępną maszyną tłumaczeniową.",
|
||||
"10 GB Cloud Sync Storage": "10 GB Pamięci Cloud Sync",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Bezpiecznie przechowuj i uzyskuj dostęp do całej kolekcji czytelniczej z do 2 GB pamięci w chmurze.",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Bezpiecznie przechowuj i uzyskuj dostęp do całej kolekcji czytelniczej z do 10 GB pamięci w chmurze.",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Bezpiecznie przechowuj i uzyskuj dostęp do całej kolekcji czytelniczej z do 5 GB pamięci w chmurze.",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Bezpiecznie przechowuj i uzyskuj dostęp do całej kolekcji czytelniczej z do 20 GB pamięci w chmurze.",
|
||||
"Deleted cloud backup of the book: {{title}}": "Usunięto kopię zapasową książki w chmurze: {{title}}",
|
||||
"Failed to delete cloud backup of the book": "Nie udało się usunąć kopii zapasowej książki w chmurze.",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Czy na pewno chcesz usunąć kopię zapasową chmury wybranej książki?"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Czy na pewno chcesz usunąć kopię zapasową chmury wybranej książki?",
|
||||
"What's New in Readest": "Co nowego w Readest",
|
||||
"Enter book title": "Wprowadź tytuł książki",
|
||||
"Subtitle": "Podtytuł",
|
||||
"Enter book subtitle": "Wprowadź podtytuł książki",
|
||||
"Enter author name": "Wprowadź imię autora",
|
||||
"Series": "Seria",
|
||||
"Enter series name": "Wprowadź nazwę serii",
|
||||
"Series Index": "Numer w serii",
|
||||
"Enter series index": "Wprowadź numer w serii",
|
||||
"Total in Series": "Łącznie w serii",
|
||||
"Enter total books in series": "Wprowadź łączną liczbę książek w serii",
|
||||
"Enter publisher": "Wprowadź wydawcę",
|
||||
"Publication Date": "Data publikacji",
|
||||
"Identifier": "Identyfikator",
|
||||
"Enter book description": "Wprowadź opis książki",
|
||||
"Change cover image": "Zmień obraz okładki",
|
||||
"Replace": "Zastąp",
|
||||
"Unlock cover": "Odblokuj okładkę",
|
||||
"Lock cover": "Zablokuj okładkę",
|
||||
"Auto-Retrieve Metadata": "Automatycznie pobierz metadane",
|
||||
"Auto-Retrieve": "Automatyczne pobieranie",
|
||||
"Unlock all fields": "Odblokuj wszystkie pola",
|
||||
"Unlock All": "Odblokuj wszystko",
|
||||
"Lock all fields": "Zablokuj wszystkie pola",
|
||||
"Lock All": "Zablokuj wszystko",
|
||||
"Reset": "Resetuj",
|
||||
"Edit Metadata": "Edytuj metadane",
|
||||
"Locked": "Zablokowane",
|
||||
"Select Metadata Source": "Wybierz źródło metadanych",
|
||||
"Keep manual input": "Zachowaj wprowadzenie ręczne",
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Beletrystyka, Nauka, Historia",
|
||||
"Select Cover Image": "Zaznacz obraz okładki",
|
||||
"Open Book in New Window": "Otwórz książkę w nowym oknie",
|
||||
"Voices for {{lang}}": "{{lang}} Głosy",
|
||||
"Yandex Translate": "Yandex Tłumacz",
|
||||
"YYYY or YYYY-MM-DD": "YYYY lub YYYY-MM-DD",
|
||||
"Restore Purchase": "Przywróć zakup",
|
||||
"No purchases found to restore.": "Nie znaleziono zakupów do przywrócenia.",
|
||||
"Failed to restore purchases.": "Nie udało się przywrócić zakupów.",
|
||||
"Failed to manage subscription.": "Nie udało się zarządzać subskrypcją.",
|
||||
"Failed to load subscription plans.": "Nie udało się załadować planów subskrypcyjnych.",
|
||||
"year": "rok",
|
||||
"Failed to create checkout session": "Nie udało się utworzyć sesji zakupu",
|
||||
"Storage": "Przechowywanie",
|
||||
"Terms of Service": "Warunki korzystania z usługi",
|
||||
"Privacy Policy": "Polityka prywatności",
|
||||
"Disable Double Click": "Wyłącz podwójne kliknięcie"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "Direção Horizontal",
|
||||
"Hyphenation": "Hifenização",
|
||||
"Import Books": "Importar Livros",
|
||||
"Language:": "Idioma:",
|
||||
"Layout": "Layout",
|
||||
"Light Mode": "Modo Claro",
|
||||
"Loading...": "Carregando...",
|
||||
@@ -68,8 +67,8 @@
|
||||
"Paging Animation": "Animação de Página",
|
||||
"Paragraph": "Parágrafo",
|
||||
"Parallel Read": "Leitura Paralela",
|
||||
"Published:": "Publicado:",
|
||||
"Publisher:": "Editora:",
|
||||
"Published": "Publicado",
|
||||
"Publisher": "Editora",
|
||||
"Reading Progress Synced": "Progresso de leitura sincronizado",
|
||||
"Reload Page": "Recarregar Página",
|
||||
"Reveal in File Explorer": "Mostrar no Explorador de Arquivos",
|
||||
@@ -94,7 +93,7 @@
|
||||
"Slow": "Lento",
|
||||
"Solarized": "Solarizado",
|
||||
"Speak": "Falar",
|
||||
"Subjects:": "Assuntos:",
|
||||
"Subjects": "Assuntos",
|
||||
"System Fonts": "Fontes do Sistema",
|
||||
"TTS not supported in this device": "TTS não suportado neste dispositivo",
|
||||
"Theme Color": "Cor do Tema",
|
||||
@@ -103,7 +102,7 @@
|
||||
"Translated Text": "Texto Traduzido",
|
||||
"Unknown": "Desconhecido",
|
||||
"Untitled": "Sem Título",
|
||||
"Updated:": "Atualizado:",
|
||||
"Updated": "Atualizado",
|
||||
"Version {{version}}": "Versão {{version}}",
|
||||
"Vertical Direction": "Direção Vertical",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Bem-vindo à sua biblioteca. Você pode importar seus livros aqui e lê-los a qualquer momento.",
|
||||
@@ -244,7 +243,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Baixando {{downloaded}} de {{contentLength}}",
|
||||
"Download finished": "Download concluído",
|
||||
"DOWNLOAD & INSTALL": "BAIXAR E INSTALAR",
|
||||
"Changelog:": "Notas de versão:",
|
||||
"Changelog": "Notas de versão",
|
||||
"Software Update": "Atualização de Software",
|
||||
"Title": "Título",
|
||||
"Date Read": "Data de leitura",
|
||||
@@ -253,10 +252,9 @@
|
||||
"Ascending": "Crescente",
|
||||
"Descending": "Decrescente",
|
||||
"Sort by...": "Ordenar por...",
|
||||
"Added:": "Adicionado:",
|
||||
"Format:": "Formato:",
|
||||
"Added": "Adicionado",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Description:": "Descrição:",
|
||||
"Description": "Descrição",
|
||||
"No description available": "Nenhuma descrição disponível",
|
||||
"List": "Lista",
|
||||
"Grid": "Grade",
|
||||
@@ -273,7 +271,7 @@
|
||||
"Checking for updates...": "Verificando atualizações...",
|
||||
"Error checking for updates": "Erro ao verificar atualizações",
|
||||
"Details": "Detalhes",
|
||||
"File Size:": "Tamanho do arquivo:",
|
||||
"File Size": "Tamanho do arquivo",
|
||||
"Auto Detect": "Detectar Automaticamente",
|
||||
"Next Section": "Próxima seção",
|
||||
"Previous Section": "Seção anterior",
|
||||
@@ -363,7 +361,6 @@
|
||||
"Upgrade to Plus or Pro": "Atualizar para Plus ou Pro",
|
||||
"Current Plan": "Plano atual",
|
||||
"Plan Limits": "Limites do plano",
|
||||
"Failed to manage subscription. Please try again later.": "Não foi possível gerenciar a assinatura. Tente novamente mais tarde.",
|
||||
"Processing your payment...": "Processando seu pagamento...",
|
||||
"Please wait while we confirm your subscription.": "Por favor, aguarde enquanto confirmamos sua assinatura.",
|
||||
"Payment Processing": "Processando pagamento",
|
||||
@@ -406,7 +403,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "Ouça sem limites—converta tanto texto quanto quiser em áudio envolvente.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Desbloqueie recursos aprimorados de tradução com mais uso diário e opções avançadas.",
|
||||
"DeepL Pro Access": "Acesso ao DeepL Pro",
|
||||
"2 GB Cloud Sync Storage": "2 GB de Armazenamento na Nuvem",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "Desfrute de respostas mais rápidas e assistência dedicada sempre que precisar de ajuda.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Cota diária de tradução atingida. Atualize seu plano para continuar usando traduções por IA.",
|
||||
"Includes All Plus Plan Benefits": "Inclui Todos os Benefícios do Plano Plus",
|
||||
@@ -416,10 +412,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Aproveite ferramentas de IA poderosas para leitura inteligente, tradução e descoberta de conteúdo.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Traduza até 100.000 caracteres diariamente com o motor de tradução mais preciso disponível.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Traduza até 500.000 caracteres diariamente com o motor de tradução mais preciso disponível.",
|
||||
"10 GB Cloud Sync Storage": "10 GB de Armazenamento na Nuvem",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Armazene e acesse com segurança sua coleção completa de leitura com até 2 GB de armazenamento na nuvem.",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Armazene e acesse com segurança sua coleção completa de leitura com até 10 GB de armazenamento na nuvem.",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Armazene e acesse com segurança sua coleção completa de leitura com até 5 GB de armazenamento na nuvem.",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Armazene e acesse com segurança sua coleção completa de leitura com até 20 GB de armazenamento na nuvem.",
|
||||
"Deleted cloud backup of the book: {{title}}": "Backup na nuvem do livro excluído: {{title}}",
|
||||
"Failed to delete cloud backup of the book": "Falha ao excluir o backup na nuvem do livro.",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Tem certeza de que deseja excluir o backup na nuvem do livro selecionado?"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Tem certeza de que deseja excluir o backup na nuvem do livro selecionado?",
|
||||
"What's New in Readest": "Novidades no Readest",
|
||||
"Enter book title": "Digite o título do livro",
|
||||
"Subtitle": "Subtítulo",
|
||||
"Enter book subtitle": "Digite o subtítulo do livro",
|
||||
"Enter author name": "Digite o nome do autor",
|
||||
"Series": "Série",
|
||||
"Enter series name": "Digite o nome da série",
|
||||
"Series Index": "Número da série",
|
||||
"Enter series index": "Digite o número da série",
|
||||
"Total in Series": "Total na série",
|
||||
"Enter total books in series": "Digite o total de livros na série",
|
||||
"Enter publisher": "Digite a editora",
|
||||
"Publication Date": "Data de publicação",
|
||||
"Identifier": "Identificador",
|
||||
"Enter book description": "Digite a descrição do livro",
|
||||
"Change cover image": "Alterar imagem da capa",
|
||||
"Replace": "Substituir",
|
||||
"Unlock cover": "Desbloquear capa",
|
||||
"Lock cover": "Bloquear capa",
|
||||
"Auto-Retrieve Metadata": "Recuperar metadados automaticamente",
|
||||
"Auto-Retrieve": "Recuperação automática",
|
||||
"Unlock all fields": "Desbloquear todos os campos",
|
||||
"Unlock All": "Desbloquear tudo",
|
||||
"Lock all fields": "Bloquear todos os campos",
|
||||
"Lock All": "Bloquear tudo",
|
||||
"Reset": "Redefinir",
|
||||
"Edit Metadata": "Editar metadados",
|
||||
"Locked": "Bloqueado",
|
||||
"Select Metadata Source": "Selecionar fonte de metadados",
|
||||
"Keep manual input": "Manter entrada manual",
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Ficção, Ciência, História",
|
||||
"Select Cover Image": "Selecionar imagem da capa",
|
||||
"Open Book in New Window": "Abrir livro em nova janela",
|
||||
"Voices for {{lang}}": "Vozes para {{lang}}",
|
||||
"Yandex Translate": "Yandex Translate",
|
||||
"YYYY or YYYY-MM-DD": "YYYY ou YYYY-MM-DD",
|
||||
"Restore Purchase": "Restaurar Compra",
|
||||
"No purchases found to restore.": "Nenhuma compra encontrada para restaurar.",
|
||||
"Failed to restore purchases.": "Falha ao restaurar compras.",
|
||||
"Failed to manage subscription.": "Falha ao gerenciar assinatura.",
|
||||
"Failed to load subscription plans.": "Falha ao carregar planos de assinatura.",
|
||||
"year": "ano",
|
||||
"Failed to create checkout session": "Falha ao criar sessão de checkout",
|
||||
"Storage": "Armazenamento",
|
||||
"Terms of Service": "Termos de Serviço",
|
||||
"Privacy Policy": "Política de Privacidade",
|
||||
"Disable Double Click": "Desativar Clique Duplo"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "Горизонтальное направление",
|
||||
"Hyphenation": "Перенос слов",
|
||||
"Import Books": "Импорт книг",
|
||||
"Language:": "Язык:",
|
||||
"Layout": "Макет",
|
||||
"Light Mode": "Светлая тема",
|
||||
"Loading...": "Загрузка...",
|
||||
@@ -68,8 +67,8 @@
|
||||
"Paging Animation": "Анимация перелистывания",
|
||||
"Paragraph": "Абзац",
|
||||
"Parallel Read": "Параллельное чтение",
|
||||
"Published:": "Опубликовано:",
|
||||
"Publisher:": "Издатель:",
|
||||
"Published": "Опубликовано",
|
||||
"Publisher": "Издатель",
|
||||
"Reading Progress Synced": "Синхронизирован прогресс чтения",
|
||||
"Reload Page": "Перезагрузить страницу",
|
||||
"Reveal in File Explorer": "Показать в проводнике",
|
||||
@@ -94,7 +93,7 @@
|
||||
"Slow": "Медленно",
|
||||
"Solarized": "Солнечный",
|
||||
"Speak": "Произнести",
|
||||
"Subjects:": "Темы:",
|
||||
"Subjects": "Темы",
|
||||
"System Fonts": "Системные шрифты",
|
||||
"TTS not supported in this device": "TTS не поддерживается на этом устройстве",
|
||||
"Theme Color": "Цвет темы",
|
||||
@@ -103,7 +102,7 @@
|
||||
"Translated Text": "Переведённый текст",
|
||||
"Unknown": "Неизвестно",
|
||||
"Untitled": "Без названия",
|
||||
"Updated:": "Обновлено:",
|
||||
"Updated": "Обновлено",
|
||||
"Version {{version}}": "Версия {{version}}",
|
||||
"Vertical Direction": "Вертикальное направление",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Добро пожаловать в вашу библиотеку. Вы можете импортировать сюда книги и читать их в любое время.",
|
||||
@@ -244,7 +243,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Скачано {{downloaded}} из {{contentLength}}",
|
||||
"Download finished": "Загрузка завершена",
|
||||
"DOWNLOAD & INSTALL": "СКАЧАТЬ И УСТАНОВИТЬ",
|
||||
"Changelog:": "Изменения:",
|
||||
"Changelog": "Изменения",
|
||||
"Software Update": "Обновление программного обеспечения",
|
||||
"Title": "Название",
|
||||
"Date Read": "Дата прочтения",
|
||||
@@ -253,10 +252,9 @@
|
||||
"Ascending": "По возрастанию",
|
||||
"Descending": "По убыванию",
|
||||
"Sort by...": "Сортировать по...",
|
||||
"Added:": "Добавлено:",
|
||||
"Format:": "Формат:",
|
||||
"Added": "Добавлено",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Description:": "Описание:",
|
||||
"Description": "Описание",
|
||||
"No description available": "Нет доступного описания",
|
||||
"List": "Список",
|
||||
"Grid": "Сетка",
|
||||
@@ -273,7 +271,7 @@
|
||||
"Checking for updates...": "Проверка обновлений...",
|
||||
"Error checking for updates": "Ошибка при проверке обновлений",
|
||||
"Details": "Инфо",
|
||||
"File Size:": "Размер файла:",
|
||||
"File Size": "Размер файла",
|
||||
"Auto Detect": "Автоопределение",
|
||||
"Next Section": "Следующий раздел",
|
||||
"Previous Section": "Предыдущий раздел",
|
||||
@@ -367,7 +365,6 @@
|
||||
"Upgrade to Plus or Pro": "Обновить до Plus или Pro",
|
||||
"Current Plan": "Текущий план",
|
||||
"Plan Limits": "Лимиты плана",
|
||||
"Failed to manage subscription. Please try again later.": "Не удалось управлять подпиской. Пожалуйста, попробуйте позже.",
|
||||
"Processing your payment...": "Обработка платежа...",
|
||||
"Please wait while we confirm your subscription.": "Пожалуйста, подождите, пока мы подтверждаем вашу подписку.",
|
||||
"Payment Processing": "Обработка платежа",
|
||||
@@ -410,7 +407,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "Слушайте без ограничений—преобразуйте столько текста, сколько хотите, в захватывающее аудио.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Разблокируйте улучшенные возможности перевода с большим ежедневным использованием и расширенными опциями.",
|
||||
"DeepL Pro Access": "Доступ к DeepL Pro",
|
||||
"2 GB Cloud Sync Storage": "2 ГБ Облачного Хранилища",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "Наслаждайтесь более быстрыми ответами и персональной помощью, когда вам нужна поддержка.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Достигнута дневная квота переводов. Обновите план, чтобы продолжить использовать переводы ИИ.",
|
||||
"Includes All Plus Plan Benefits": "Включает Все Преимущества Plus Плана",
|
||||
@@ -420,10 +416,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Используйте мощные ИИ инструменты для умного чтения, перевода и поиска контента.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Переводите до 100 000 символов ежедневно с самым точным доступным движком перевода.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Переводите до 500 000 символов ежедневно с самым точным доступным движком перевода.",
|
||||
"10 GB Cloud Sync Storage": "10 ГБ Облачного Хранилища",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Безопасно храните и получайте доступ ко всей коллекции чтения с до 2 ГБ облачного хранилища.",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Безопасно храните и получайте доступ ко всей коллекции чтения с до 10 ГБ облачного хранилища.",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Безопасно храните и получайте доступ ко всей коллекции чтения с до 5 ГБ облачного хранилища.",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Безопасно храните и получайте доступ ко всей коллекции чтения с до 20 ГБ облачного хранилища.",
|
||||
"Deleted cloud backup of the book: {{title}}": "Удалено облачное резервное копирование книги: {{title}}",
|
||||
"Failed to delete cloud backup of the book": "Не удалось удалить облачное резервное копирование книги.",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Вы уверены, что хотите удалить облачное резервное копирование выбранной книги?"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Вы уверены, что хотите удалить облачное резервное копирование выбранной книги?",
|
||||
"What's New in Readest": "Что нового в Readest",
|
||||
"Enter book title": "Введите название книги",
|
||||
"Subtitle": "Подзаголовок",
|
||||
"Enter book subtitle": "Введите подзаголовок книги",
|
||||
"Enter author name": "Введите имя автора",
|
||||
"Series": "Серия",
|
||||
"Enter series name": "Введите название серии",
|
||||
"Series Index": "Номер в серии",
|
||||
"Enter series index": "Введите номер в серии",
|
||||
"Total in Series": "Всего в серии",
|
||||
"Enter total books in series": "Введите общее количество книг в серии",
|
||||
"Enter publisher": "Введите издательство",
|
||||
"Publication Date": "Дата публикации",
|
||||
"Identifier": "Идентификатор",
|
||||
"Enter book description": "Введите описание книги",
|
||||
"Change cover image": "Изменить обложку",
|
||||
"Replace": "Заменить",
|
||||
"Unlock cover": "Разблокировать обложку",
|
||||
"Lock cover": "Заблокировать обложку",
|
||||
"Auto-Retrieve Metadata": "Автоматически получить метаданные",
|
||||
"Auto-Retrieve": "Автоматическое получение",
|
||||
"Unlock all fields": "Разблокировать все поля",
|
||||
"Unlock All": "Разблокировать всё",
|
||||
"Lock all fields": "Заблокировать все поля",
|
||||
"Lock All": "Заблокировать всё",
|
||||
"Reset": "Сбросить",
|
||||
"Edit Metadata": "Редактировать метаданные",
|
||||
"Locked": "Заблокировано",
|
||||
"Select Metadata Source": "Выберите источник метаданных",
|
||||
"Keep manual input": "Сохранить ручной ввод",
|
||||
"Google Books": "Google Книги",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Художественная литература, Наука, История",
|
||||
"Select Cover Image": "Выбрать изображение обложки",
|
||||
"Open Book in New Window": "Открыть книгу в новом окне",
|
||||
"Voices for {{lang}}": "Голоса для {{lang}}",
|
||||
"Yandex Translate": "Yandex Translate",
|
||||
"YYYY or YYYY-MM-DD": "YYYY или YYYY-MM-DD",
|
||||
"Restore Purchase": "Восстановить покупку",
|
||||
"No purchases found to restore.": "Не найдено покупок для восстановления.",
|
||||
"Failed to restore purchases.": "Не удалось восстановить покупки.",
|
||||
"Failed to manage subscription.": "Не удалось управлять подпиской.",
|
||||
"Failed to load subscription plans.": "Не удалось загрузить планы подписки.",
|
||||
"year": "год",
|
||||
"Failed to create checkout session": "Не удалось создать сессию оформления заказа",
|
||||
"Storage": "Хранилище",
|
||||
"Terms of Service": "Условия использования",
|
||||
"Privacy Policy": "Политика конфиденциальности",
|
||||
"Disable Double Click": "Отключить двойной клик"
|
||||
}
|
||||
|
||||
@@ -306,7 +306,6 @@
|
||||
"Current Plan": "แผนปัจจุบัน",
|
||||
"Plan Limits": "ข้อจำกัดแผน",
|
||||
"Failed to delete user. Please try again later.": "ไม่สามารถลบผู้ใช้ ลองใหม่ภายหลัง",
|
||||
"Failed to manage subscription. Please try again later.": "ไม่สามารถจัดการสมาชิก ลองใหม่ภายหลัง",
|
||||
"Loading profile...": "กำลังโหลดโปรไฟล์...",
|
||||
"Processing your payment...": "กำลังประมวลผลการชำระเงิน...",
|
||||
"Please wait while we confirm your subscription.": "กรุณารอสักครู่เพื่อยืนยันการสมัครสมาชิก",
|
||||
@@ -345,8 +344,7 @@
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "ปลดล็อกความสามารถการแปลที่เพิ่มขึ้นพร้อมโควตารายวันและตัวเลือกขั้นสูง",
|
||||
"DeepL Pro Access": "การเข้าถึง DeepL Pro",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "แปลได้สูงสุด 100,000 อักขระต่อวันด้วยเครื่องมือแปลที่แม่นยำที่สุด",
|
||||
"2 GB Cloud Sync Storage": "พื้นที่ซิงค์คลาวด์ 2 GB",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "จัดเก็บและเข้าถึงคอลเลคชันการอ่านอย่างปลอดภัยด้วยพื้นที่คลาวด์ 2 GB",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "จัดเก็บและเข้าถึงคอลเลคชันการอ่านอย่างปลอดภัยด้วยพื้นที่คลาวด์ 5 GB",
|
||||
"Priority Support": "การสนับสนุนแบบพิเศษ",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "รับการตอบสนองที่รวดเร็วและความช่วยเหลือเฉพาะเมื่อต้องการ",
|
||||
"Pro Plan": "แผน Pro",
|
||||
@@ -356,8 +354,7 @@
|
||||
"Advanced AI Tools": "เครื่องมือ AI ขั้นสูง",
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "ใช้เครื่องมือ AI อันทรงพลังเพื่อการอ่าน การแปล และการค้นหาเนื้อหาที่ฉลาดขึ้น",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "แปลได้สูงสุด 500,000 อักขระต่อวันด้วยเครื่องมือแปลที่แม่นยำที่สุด",
|
||||
"10 GB Cloud Sync Storage": "พื้นที่ซิงค์คลาวด์ 10 GB",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "จัดเก็บและเข้าถึงคอลเลคชันการอ่านอย่างปลอดภัยด้วยพื้นที่คลาวด์ 10 GB",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "จัดเก็บและเข้าถึงคอลเลคชันการอ่านอย่างปลอดภัยด้วยพื้นที่คลาวด์ 20 GB",
|
||||
"Version {{version}}": "เวอร์ชัน {{version}}",
|
||||
"Check Update": "ตรวจสอบอัปเดต",
|
||||
"Already the latest version": "เป็นเวอร์ชันล่าสุดแล้ว",
|
||||
@@ -365,15 +362,13 @@
|
||||
"Error checking for updates": "เกิดข้อผิดพลาดในการตรวจสอบอัปเดต",
|
||||
"Book Details": "รายละเอียดหนังสือ",
|
||||
"Unknown": "ไม่ทราบ",
|
||||
"Publisher:": "สำนักพิมพ์:",
|
||||
"Published:": "วันที่พิมพ์:",
|
||||
"Updated:": "อัปเดต:",
|
||||
"Added:": "เพิ่มเมื่อ:",
|
||||
"Language:": "ภาษา:",
|
||||
"Subjects:": "หัวข้อ:",
|
||||
"Format:": "รูปแบบ:",
|
||||
"File Size:": "ขนาดไฟล์:",
|
||||
"Description:": "คำอธิบาย:",
|
||||
"Publisher": "สำนักพิมพ์",
|
||||
"Published": "วันที่พิมพ์",
|
||||
"Updated": "อัปเดต",
|
||||
"Added": "เพิ่มเมื่อ",
|
||||
"Subjects": "หัวข้อ",
|
||||
"File Size": "ขนาดไฟล์",
|
||||
"Description": "คำอธิบาย",
|
||||
"No description available": "ไม่มีคำอธิบาย",
|
||||
"Are you sure to delete the selected book?": "ยืนยันลบหนังสือที่เลือก?",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "ยืนยันลบสำรองคลาวด์ของหนังสือที่เลือก?",
|
||||
@@ -384,7 +379,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "กำลังดาวน์โหลด {{downloaded}} จาก {{contentLength}}",
|
||||
"Download finished": "ดาวน์โหลดเสร็จสิ้น",
|
||||
"DOWNLOAD & INSTALL": "ดาวน์โหลดและติดตั้ง",
|
||||
"Changelog:": "รายการเปลี่ยนแปลง:",
|
||||
"Changelog": "รายการเปลี่ยนแปลง",
|
||||
"Software Update": "อัปเดตซอฟต์แวร์",
|
||||
"{{percentage}}% of Cloud Sync Space Used.": "ใช้พื้นที่ซิงค์คลาวด์ {{percentage}}%",
|
||||
"Translation Characters": "อักขระการแปล",
|
||||
@@ -413,5 +408,54 @@
|
||||
"Reveal in File Explorer": "แสดงใน File Explorer",
|
||||
"Reveal in Folder": "แสดงในโฟลเดอร์",
|
||||
"Don't have an account? Sign up": "ยังไม่มีบัญชี? สมัครสมาชิก",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "โลกทั้งใบเป็นเวที\nและชายหญิงทั้งปวงเป็นนักแสดง\nมีทางเข้าและทางออก\nแต่ละคนในชีวิตเล่นหลายบท\nการกระทำของเขาแบ่งเป็นเจ็ดวัย\n\n— วิลเลียม เชกสเปียร์"
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "โลกทั้งใบเป็นเวที\nและชายหญิงทั้งปวงเป็นนักแสดง\nมีทางเข้าและทางออก\nแต่ละคนในชีวิตเล่นหลายบท\nการกระทำของเขาแบ่งเป็นเจ็ดวัย\n\n— วิลเลียม เชกสเปียร์",
|
||||
"What's New in Readest": "มีอะไรใหม่ใน Readest",
|
||||
"Enter book title": "ป้อนชื่อหนังสือ",
|
||||
"Subtitle": "หัวข้อรอง",
|
||||
"Enter book subtitle": "ป้อนหัวข้อรองของหนังสือ",
|
||||
"Enter author name": "ป้อนชื่อผู้แต่ง",
|
||||
"Series": "ชุด",
|
||||
"Enter series name": "ป้อนชื่อชุด",
|
||||
"Series Index": "เลขที่ในชุด",
|
||||
"Enter series index": "ป้อนเลขที่ในชุด",
|
||||
"Total in Series": "จำนวนรวมในชุด",
|
||||
"Enter total books in series": "ป้อนจำนวนหนังสือทั้งหมดในชุด",
|
||||
"Enter publisher": "ป้อนสำนักพิมพ์",
|
||||
"Publication Date": "วันที่พิมพ์",
|
||||
"Identifier": "ตัวระบุ",
|
||||
"Enter book description": "ป้อนคำอธิบายหนังสือ",
|
||||
"Change cover image": "เปลี่ยนรูปปก",
|
||||
"Replace": "แทนที่",
|
||||
"Unlock cover": "ปลดล็อคปก",
|
||||
"Lock cover": "ล็อคปก",
|
||||
"Auto-Retrieve Metadata": "ดึงข้อมูลเมตาอัตโนมัติ",
|
||||
"Auto-Retrieve": "ดึงข้อมูลอัตโนมัติ",
|
||||
"Unlock all fields": "ปลดล็อคทุกช่อง",
|
||||
"Unlock All": "ปลดล็อคทั้งหมด",
|
||||
"Lock all fields": "ล็อคทุกช่อง",
|
||||
"Lock All": "ล็อคทั้งหมด",
|
||||
"Reset": "รีเซ็ต",
|
||||
"Edit Metadata": "แก้ไขข้อมูลเมตา",
|
||||
"Locked": "ล็อคแล้ว",
|
||||
"Select Metadata Source": "เลือกแหล่งข้อมูลเมตา",
|
||||
"Keep manual input": "เก็บข้อมูลที่ป้อนด้วยตนเอง",
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "นิยาย, วิทยาศาสตร์, ประวัติศาสตร์",
|
||||
"Select Cover Image": "เลือกภาพปก",
|
||||
"Open Book in New Window": "เปิดหนังสือในหน้าต่างใหม่",
|
||||
"Voices for {{lang}}": "เสียงสำหรับ {{lang}}",
|
||||
"Yandex Translate": "Yandex Translate",
|
||||
"YYYY or YYYY-MM-DD": "YYYY หรือ YYYY-MM-DD",
|
||||
"Restore Purchase": "กู้คืนการซื้อ",
|
||||
"No purchases found to restore.": "ไม่พบการซื้อเพื่อกู้คืน",
|
||||
"Failed to restore purchases.": "ไม่สามารถกู้คืนการซื้อได้",
|
||||
"Failed to manage subscription.": "ไม่สามารถจัดการการสมัครสมาชิกได้",
|
||||
"Failed to load subscription plans.": "ไม่สามารถโหลดแผนการสมัครสมาชิกได้",
|
||||
"year": "ปี",
|
||||
"Failed to create checkout session": "ไม่สามารถสร้างเซสชันการชำระเงินได้",
|
||||
"Storage": "พื้นที่เก็บข้อมูล",
|
||||
"Terms of Service": "ข้อกำหนดในการให้บริการ",
|
||||
"Privacy Policy": "นโยบายความเป็นส่วนตัว",
|
||||
"Disable Double Click": "ปิดการคลิกสองครั้ง"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "Yatay Yön",
|
||||
"Hyphenation": "Heceleme",
|
||||
"Import Books": "Kitapları İçe Aktar",
|
||||
"Language:": "Dil:",
|
||||
"Layout": "Düzen",
|
||||
"Light Mode": "Aydınlık Mod",
|
||||
"Loading...": "Yükleniyor...",
|
||||
@@ -68,8 +67,8 @@
|
||||
"Paging Animation": "Sayfa Çevirme Animasyonu",
|
||||
"Paragraph": "Paragraf",
|
||||
"Parallel Read": "Paralel Okuma",
|
||||
"Published:": "Yayınlanma:",
|
||||
"Publisher:": "Yayıncı:",
|
||||
"Published": "Yayınlanma",
|
||||
"Publisher": "Yayıncı",
|
||||
"Reading Progress Synced": "Okuma ilerlemesi senkronize edildi",
|
||||
"Reload Page": "Sayfayı Yenile",
|
||||
"Reveal in File Explorer": "Dosya Gezgininde Göster",
|
||||
@@ -94,7 +93,7 @@
|
||||
"Slow": "Yavaş",
|
||||
"Solarized": "Solarized",
|
||||
"Speak": "Konuş",
|
||||
"Subjects:": "Konular:",
|
||||
"Subjects": "Konular",
|
||||
"System Fonts": "Sistem Yazı Tipleri",
|
||||
"TTS not supported in this device": "Bu cihazda TTS desteklenmiyor",
|
||||
"Theme Color": "Tema Rengi",
|
||||
@@ -103,7 +102,7 @@
|
||||
"Translated Text": "Çevrilen Metin",
|
||||
"Unknown": "Bilinmiyor",
|
||||
"Untitled": "Başlıksız",
|
||||
"Updated:": "Güncellendi:",
|
||||
"Updated": "Güncellendi",
|
||||
"Version {{version}}": "Sürüm {{version}}",
|
||||
"Vertical Direction": "Dikey Yön",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Kütüphanenize hoş geldiniz. Buradan kitaplarınızı içe aktarabilir ve istediğiniz zaman okuyabilirsiniz.",
|
||||
@@ -244,7 +243,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "İndiriliyor {{downloaded}}/{{contentLength}}",
|
||||
"Download finished": "İndirme tamamlandı",
|
||||
"DOWNLOAD & INSTALL": "İNDİR & YÜKLE",
|
||||
"Changelog:": "Değişiklik Günlüğü:",
|
||||
"Changelog": "Değişiklik Günlüğü",
|
||||
"Software Update": "Yazılım Güncellemesi",
|
||||
"Title": "Başlık",
|
||||
"Date Read": "Okunma Tarihi",
|
||||
@@ -253,10 +252,9 @@
|
||||
"Ascending": "Artan",
|
||||
"Descending": "Azalan",
|
||||
"Sort by...": "Şuna göre sırala...",
|
||||
"Added:": "Eklenme:",
|
||||
"Format:": "Biçim:",
|
||||
"Added": "Eklenme",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Description:": "Tanım:",
|
||||
"Description": "Tanım",
|
||||
"No description available": "Tanım mevcut değil",
|
||||
"List": "Liste",
|
||||
"Grid": "Izgara",
|
||||
@@ -273,7 +271,7 @@
|
||||
"Checking for updates...": "Yenilikler kontrol ediliyor...",
|
||||
"Error checking for updates": "Güncellemeleri kontrol ederken hata oluştu",
|
||||
"Details": "Detaylar",
|
||||
"File Size:": "Dosya Boyutu:",
|
||||
"File Size": "Dosya Boyutu",
|
||||
"Auto Detect": "Algıla",
|
||||
"Next Section": "Sonraki Bölüm",
|
||||
"Previous Section": "Önceki Bölüm",
|
||||
@@ -359,7 +357,6 @@
|
||||
"Upgrade to Plus or Pro": "Plus veya Pro'ya yükselt",
|
||||
"Current Plan": "Mevcut Plan",
|
||||
"Plan Limits": "Plan Limitleri",
|
||||
"Failed to manage subscription. Please try again later.": "Abonelik yönetimi başarısız oldu. Lütfen daha sonra tekrar deneyin.",
|
||||
"Processing your payment...": "Ödemeniz işleniyor...",
|
||||
"Please wait while we confirm your subscription.": "Aboneliğinizi onaylıyoruz, lütfen bekleyin.",
|
||||
"Payment Processing": "Ödeme İşleniyor",
|
||||
@@ -402,7 +399,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "Sınırsız dinleyin—istediğiniz kadar metni sürükleyici sese dönüştürün.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Daha fazla günlük kullanım ve gelişmiş seçeneklerle gelişmiş çeviri yeteneklerinin kilidini açın.",
|
||||
"DeepL Pro Access": "DeepL Pro Erişimi",
|
||||
"2 GB Cloud Sync Storage": "2 GB Bulut Senkronizasyon Depolama",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "Yardıma ihtiyacınız olduğunda daha hızlı yanıtlar ve özel destek keyfini çıkarın.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Günlük çeviri kotasına ulaşıldı. AI çevirileri kullanmaya devam etmek için planınızı yükseltin.",
|
||||
"Includes All Plus Plan Benefits": "Tüm Plus Plan Avantajları Dahil",
|
||||
@@ -412,10 +408,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Akıllı okuma, çeviri ve içerik keşfi için güçlü AI araçlarını kullanın.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Mevcut en doğru çeviri motoruyla günlük 100.000 karaktere kadar çeviri yapın.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Mevcut en doğru çeviri motoruyla günlük 500.000 karaktere kadar çeviri yapın.",
|
||||
"10 GB Cloud Sync Storage": "10 GB Bulut Senkronizasyon Depolama",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Tüm okuma koleksiyonunuzu 2 GB'a kadar güvenli bulut depolamayla saklayın ve erişin.",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Tüm okuma koleksiyonunuzu 10 GB'a kadar güvenli bulut depolamayla saklayın ve erişin.",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Tüm okuma koleksiyonunuzu 5 GB'a kadar güvenli bulut depolamayla saklayın ve erişin.",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Tüm okuma koleksiyonunuzu 20 GB'a kadar güvenli bulut depolamayla saklayın ve erişin.",
|
||||
"Deleted cloud backup of the book: {{title}}": "Kitabın bulut yedeği silindi: {{title}}",
|
||||
"Failed to delete cloud backup of the book": "Kitabın bulut yedeğini silme işlemi başarısız oldu.",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Seçilen kitabın bulut yedeğini silmek istediğinize emin misiniz?"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Seçilen kitabın bulut yedeğini silmek istediğinize emin misiniz?",
|
||||
"What's New in Readest": "Readest'te Neler Yeni",
|
||||
"Enter book title": "Kitap başlığını girin",
|
||||
"Subtitle": "Alt başlık",
|
||||
"Enter book subtitle": "Kitap alt başlığını girin",
|
||||
"Enter author name": "Yazar adını girin",
|
||||
"Series": "Seri",
|
||||
"Enter series name": "Seri adını girin",
|
||||
"Series Index": "Seri numarası",
|
||||
"Enter series index": "Seri numarasını girin",
|
||||
"Total in Series": "Serideki toplam",
|
||||
"Enter total books in series": "Serideki toplam kitap sayısını girin",
|
||||
"Enter publisher": "Yayınevini girin",
|
||||
"Publication Date": "Yayın tarihi",
|
||||
"Identifier": "Tanımlayıcı",
|
||||
"Enter book description": "Kitap açıklamasını girin",
|
||||
"Change cover image": "Kapak resmini değiştir",
|
||||
"Replace": "Değiştir",
|
||||
"Unlock cover": "Kapağın kilidini aç",
|
||||
"Lock cover": "Kapağı kilitle",
|
||||
"Auto-Retrieve Metadata": "Meta verileri otomatik al",
|
||||
"Auto-Retrieve": "Otomatik alma",
|
||||
"Unlock all fields": "Tüm alanların kilidini aç",
|
||||
"Unlock All": "Tümünün kilidini aç",
|
||||
"Lock all fields": "Tüm alanları kilitle",
|
||||
"Lock All": "Tümünü kilitle",
|
||||
"Reset": "Sıfırla",
|
||||
"Edit Metadata": "Meta verileri düzenle",
|
||||
"Locked": "Kilitli",
|
||||
"Select Metadata Source": "Meta veri kaynağını seç",
|
||||
"Keep manual input": "Manuel girişi koru",
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Kurgu, Bilim, Tarih",
|
||||
"Select Cover Image": "Kapak Resmi Seç",
|
||||
"Open Book in New Window": "Kitabı Yeni Pencerede Aç",
|
||||
"Voices for {{lang}}": "{{lang}} için Sesler",
|
||||
"Yandex Translate": "Yandex Çeviri",
|
||||
"YYYY or YYYY-MM-DD": "YYYY veya YYYY-MM-DD",
|
||||
"Restore Purchase": "Satın Almayı Geri Yükle",
|
||||
"No purchases found to restore.": "Geri yüklemek için satın alma bulunamadı.",
|
||||
"Failed to restore purchases.": "Satın alımları geri yükleme başarısız oldu.",
|
||||
"Failed to manage subscription.": "Aboneliği yönetme başarısız oldu.",
|
||||
"Failed to load subscription plans.": "Abonelik planlarını yükleme başarısız oldu.",
|
||||
"year": "yıl",
|
||||
"Failed to create checkout session": "Ödeme oturumu oluşturma başarısız oldu",
|
||||
"Storage": "Depolama",
|
||||
"Terms of Service": "Hizmet Şartları",
|
||||
"Privacy Policy": "Gizlilik Politikası",
|
||||
"Disable Double Click": "Çift Tıklamayı Devre Dışı Bırak"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"(detected)": "(виявлено)",
|
||||
"About Readest": "Про Readest",
|
||||
"Add your notes here...": "Додайте свої нотатки тут...",
|
||||
"Add your notes here...": "Додайте свої нотатки сюди...",
|
||||
"Animation": "Анімація",
|
||||
"Annotate": "Анотація",
|
||||
"Apply": "Застосувати",
|
||||
@@ -46,33 +46,32 @@
|
||||
"Horizontal Direction": "Горизонтальний напрямок",
|
||||
"Hyphenation": "Перенесення слів",
|
||||
"Import Books": "Імпортувати книги",
|
||||
"Language:": "Мова:",
|
||||
"Layout": "Макет",
|
||||
"Light Mode": "Світлий режим",
|
||||
"Loading...": "Завантаження...",
|
||||
"Logged in": "Увійшли",
|
||||
"Logged in as {{userDisplayName}}": "Увійшли як {{userDisplayName}}",
|
||||
"Match Case": "Враховувати регістр",
|
||||
"Logged in": "Увійдено",
|
||||
"Logged in as {{userDisplayName}}": "Увійдено як {{userDisplayName}}",
|
||||
"Match Case": "Враховувати реґістр",
|
||||
"Match Diacritics": "Враховувати діакритичні знаки",
|
||||
"Match Whole Words": "Шукати цілі слова",
|
||||
"Maximum Number of Columns": "Максимальна кількість колонок",
|
||||
"Minimum Font Size": "Мінімальний розмір шрифту",
|
||||
"Monospace Font": "Моноширинний шрифт",
|
||||
"More Info": "Більше інформації",
|
||||
"Nord": "Північний",
|
||||
"Nord": "Норд",
|
||||
"Notebook": "Нотатник",
|
||||
"Notes": "Нотатки",
|
||||
"Open": "Відкрити",
|
||||
"Original Text": "Оригінальний текст",
|
||||
"Original Text": "Ориґінальний текст",
|
||||
"Page": "Сторінка",
|
||||
"Paging Animation": "Анімація гортання",
|
||||
"Paragraph": "Абзац",
|
||||
"Parallel Read": "Паралельне читання",
|
||||
"Published:": "Опубліковано:",
|
||||
"Publisher:": "Видавець:",
|
||||
"Published": "Опубліковано",
|
||||
"Publisher": "Видавець",
|
||||
"Reading Progress Synced": "Прогрес читання синхронізовано",
|
||||
"Reload Page": "Перезавантажити сторінку",
|
||||
"Reveal in File Explorer": "Показати у провіднику",
|
||||
"Reveal in File Explorer": "Показати у Файловому менеджері",
|
||||
"Reveal in Finder": "Показати у Finder",
|
||||
"Reveal in Folder": "Показати в папці",
|
||||
"Sans-Serif Font": "Шрифт без засічок",
|
||||
@@ -94,7 +93,7 @@
|
||||
"Slow": "Повільно",
|
||||
"Solarized": "Сонячний",
|
||||
"Speak": "Озвучити",
|
||||
"Subjects:": "Теми:",
|
||||
"Subjects": "Теми",
|
||||
"System Fonts": "Системні шрифти",
|
||||
"TTS not supported in this device": "TTS не підтримується на цьому пристрої",
|
||||
"Theme Color": "Колір теми",
|
||||
@@ -103,7 +102,7 @@
|
||||
"Translated Text": "Перекладений текст",
|
||||
"Unknown": "Невідомо",
|
||||
"Untitled": "Без назви",
|
||||
"Updated:": "Оновлено:",
|
||||
"Updated": "Оновлено",
|
||||
"Version {{version}}": "Версія {{version}}",
|
||||
"Vertical Direction": "Вертикальний напрямок",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Ласкаво просимо до вашої бібліотеки. Ви можете імпортувати сюди свої книги та читати їх будь-коли.",
|
||||
@@ -113,10 +112,10 @@
|
||||
"TTS not supported for PDF": "TTS не підтримується для PDF",
|
||||
"Override Book Font": "Перевизначити шрифт книги",
|
||||
"Apply to All Books": "Застосувати до всіх книг",
|
||||
"Apply to This Book": "Застосувати до цієї книги",
|
||||
"Apply to This Book": "Застосувати лише до цієї книги",
|
||||
"Unable to fetch the translation. Try again later.": "Не вдалося отримати переклад. Спробуйте пізніше.",
|
||||
"Check Update": "Перевірити оновлення",
|
||||
"Already the latest version": "Вже остання версія",
|
||||
"Already the latest version": "У вас вже встановлена остання версія",
|
||||
"Book Details": "Деталі книги",
|
||||
"From Local File": "З локального файлу",
|
||||
"TOC": "Зміст",
|
||||
@@ -129,7 +128,7 @@
|
||||
"Book deleted: {{title}}": "Книгу видалено: {{title}}",
|
||||
"Failed to delete book: {{title}}": "Не вдалося видалити книгу: {{title}}",
|
||||
"Check Updates on Start": "Перевіряти оновлення при запуску",
|
||||
"Insufficient storage quota": "Недостатньо квоти сховища",
|
||||
"Insufficient storage quota": "Недостатньо місця у сховищі",
|
||||
"Font Weight": "Насиченість шрифту",
|
||||
"Line Spacing": "Міжрядковий інтервал",
|
||||
"Word Spacing": "Міжслівний інтервал",
|
||||
@@ -158,10 +157,10 @@
|
||||
"Sign up": "Зареєструватися",
|
||||
"Signing up...": "Реєстрація...",
|
||||
"Don't have an account? Sign up": "Немає акаунту? Зареєструйтеся",
|
||||
"Check your email for the confirmation link": "Перевірте свою електронну пошту для підтвердження посилання",
|
||||
"Check your email for the confirmation link": "Перевірте свою електронну пошту на посилання із підтвердженням",
|
||||
"Signing in ...": "Увійти...",
|
||||
"Send a magic link email": "Відправити магічне посилання на електронну пошту",
|
||||
"Check your email for the magic link": "Перевірте свою електронну пошту для магічного посилання",
|
||||
"Check your email for the magic link": "Ми надіслали вам магічне посилання. Перевірте свою електронну пошту",
|
||||
"Send reset password instructions": "Надіслати інструкції для скидання пароля",
|
||||
"Sending reset instructions ...": "Надсилаються інструкції для скидання пароля...",
|
||||
"Forgot your password?": "Забули пароль?",
|
||||
@@ -196,7 +195,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "Не знайдено підтримуваних файлів. Підтримувані формати: {{formats}}",
|
||||
"Drop to Import Books": "Перетягніть для імпорту книг",
|
||||
"Custom": "Користувацький",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Весь світ — театр,\nІ всі чоловіки та жінки — просто актори;\nУ них є свої виходи та входи,\nІ один чоловік у свій час грає багато ролей,\nЙого вчинки — це сім віков.\n\n— Вільям Шекспір",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Так, світ — театр,\nДе всі чоловіки й жінки — актори.\nТут кожному приписаний свій вихід,\nне одну з них кожне грає роль.\nСім дій в тій п’єсі...\nВільям Шекспір\n(переклад Олександра Мокровольського)",
|
||||
"Custom Theme": "Користувацька тема",
|
||||
"Theme Name": "Назва теми",
|
||||
"Text Color": "Колір тексту",
|
||||
@@ -205,8 +204,8 @@
|
||||
"Contrast": "Контраст",
|
||||
"Sunset": "Захід сонця",
|
||||
"Double Border": "Подвійна рамка",
|
||||
"Border Color": "Колір рамки",
|
||||
"Border Frame": "Рамка рамки",
|
||||
"Border Color": "Колір країв",
|
||||
"Border Frame": "Рамка по краям",
|
||||
"Show Header": "Показати заголовок",
|
||||
"Show Footer": "Показати нижню частину",
|
||||
"Small": "Малий",
|
||||
@@ -218,7 +217,7 @@
|
||||
"Exported from Readest": "Експортовано з Readest",
|
||||
"Highlights & Annotations": "Виділення та анотації",
|
||||
"Note": "Примітка",
|
||||
"Copied to clipboard": "Скопійовано в буфер обміну",
|
||||
"Copied to clipboard": "Скопійовано до буфера обміну",
|
||||
"Export Annotations": "Експортувати анотації",
|
||||
"Auto Import on File Open": "Автоматичний імпорт при відкритті файлу",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
@@ -239,13 +238,13 @@
|
||||
"Header & Footer": "Заголовок і нижній колонтитул",
|
||||
"Apply also in Scrolled Mode": "Застосувати також у прокрученому режимі",
|
||||
"A new version of Readest is available!": "Доступна нова версія Readest!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} доступна (встановлена версія {{currentVersion}}).",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} доступна (встановлено {{currentVersion}}).",
|
||||
"Download and install now?": "Завантажити та встановити зараз?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Завантаження {{downloaded}} з {{contentLength}}",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Завантаженно {{downloaded}} з {{contentLength}}",
|
||||
"Download finished": "Завантаження завершено",
|
||||
"DOWNLOAD & INSTALL": "ЗАВАНТАЖИТИ І ВСТАНОВИТИ",
|
||||
"Changelog:": "Журнал змін:",
|
||||
"Software Update": "Оновлення програмного забезпечення",
|
||||
"DOWNLOAD & INSTALL": "ЗАВАНТАЖИТИ ТА ВСТАНОВИТИ",
|
||||
"Changelog": "Список змін",
|
||||
"Software Update": "Оновлення ПЗ",
|
||||
"Title": "Назва",
|
||||
"Date Read": "Дата прочитання",
|
||||
"Date Added": "Дата додавання",
|
||||
@@ -253,27 +252,26 @@
|
||||
"Ascending": "За зростанням",
|
||||
"Descending": "За спаданням",
|
||||
"Sort by...": "Сортувати за...",
|
||||
"Added:": "Додано:",
|
||||
"Format:": "Формат:",
|
||||
"Added": "Додано",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Description:": "Опис:",
|
||||
"Description": "Опис",
|
||||
"No description available": "Опис недоступний",
|
||||
"List": "Список",
|
||||
"Grid": "Сітка",
|
||||
"(from 'As You Like It', Act II)": "(з 'Як вам це подобається', Акт II)",
|
||||
"(from 'As You Like It', Act II)": "(із 'Як вам це подобається', Дія II)",
|
||||
"Link Color": "Колір посилання",
|
||||
"Volume Keys for Page Flip": "Клавіші гучності для перевороту сторінки",
|
||||
"Clicks for Page Flip": "Клацання для перевороту сторінки",
|
||||
"Swap Clicks Area": "Змінити область клацання",
|
||||
"Volume Keys for Page Flip": "Перегортати сторінки клавішами гучності",
|
||||
"Clicks for Page Flip": "Перегортати сторінки натисканням",
|
||||
"Swap Clicks Area": "Поміняти області клацання місцями",
|
||||
"Screen": "Екран",
|
||||
"Orientation": "Орієнтація",
|
||||
"Portrait": "Портрет",
|
||||
"Landscape": "Ландшафт",
|
||||
"Open Last Book on Start": "Відкрити останню книгу при запуску",
|
||||
"Open Last Book on Start": "Відкривати останню книгу при запуску",
|
||||
"Checking for updates...": "Перевірка наявності оновлень...",
|
||||
"Error checking for updates": "Помилка перевірки наявності оновлень",
|
||||
"Details": "Деталі",
|
||||
"File Size:": "Розмір файлу:",
|
||||
"File Size": "Розмір файлу",
|
||||
"Auto Detect": "Авто виявлення",
|
||||
"Next Section": "Наступний розділ",
|
||||
"Previous Section": "Попередній розділ",
|
||||
@@ -287,22 +285,22 @@
|
||||
"Deselect": "Зняти",
|
||||
"Select All": "Усі",
|
||||
"No translation available.": "Переклад недоступний.",
|
||||
"Translated by {{provider}}.": "Перекладено {{provider}}.",
|
||||
"Translated by {{provider}}.": "Перекладено за допомогою {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Translate",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "Інвертувати зображення в темряві",
|
||||
"Google Translate": "Google Перекладач",
|
||||
"Azure Translator": "Azure Перекладач",
|
||||
"Invert Image In Dark Mode": "Інвертувати зображення в темному режимі",
|
||||
"Help improve Readest": "Допоможіть покращити Readest",
|
||||
"Sharing anonymized statistics": "Поділ анонімною статистикою",
|
||||
"Sharing anonymized statistics": "Ділитись анонімною статистикою",
|
||||
"Interface Language": "Мова інтерфейсу",
|
||||
"Translation": "Переклад",
|
||||
"Enable Translation": "Увімкнути переклад",
|
||||
"Translation Service": "Служба перекладу",
|
||||
"Translate To": "Перекласти на",
|
||||
"Translate To": "Мова перекладу",
|
||||
"Disable Translation": "Вимкнути переклад",
|
||||
"Scroll": "Прокрутка",
|
||||
"Overlap Pixels": "Пікселі перекриття",
|
||||
"Click": "Клацнути",
|
||||
"Overlap Pixels": "Перекривати пікселі",
|
||||
"Click": "Натиснути",
|
||||
"System Language": "Системна мова",
|
||||
"Security": "Безпека",
|
||||
"Allow JavaScript": "Дозволити JavaScript",
|
||||
@@ -329,12 +327,12 @@
|
||||
"{{engine}}: {{count}} voices_few": "{{engine}}: {{count}} голоси",
|
||||
"{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} голосів",
|
||||
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} голоси",
|
||||
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Щось пішло не так. Не хвилюйтеся, наша команда була сповіщена, і ми працюємо над виправленням.",
|
||||
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "От халепа! Щось пішло не так. Не хвилюйтеся, наша команда знає про це, ми вже працюємо над виправленням.",
|
||||
"Error Details:": "Деталі помилки:",
|
||||
"Try Again": "Спробувати ще раз",
|
||||
"Need help?": "Потрібна допомога?",
|
||||
"Contact Support": "Зв'язатися з підтримкою",
|
||||
"Code Highlighting": "Підсвічування коду",
|
||||
"Code Highlighting": "Підсвічувати код",
|
||||
"Enable Highlighting": "Увімкнути підсвічування",
|
||||
"Code Language": "Мова коду",
|
||||
"Top Margin (px)": "Верхній відступ (px)",
|
||||
@@ -344,13 +342,13 @@
|
||||
"Column Gap (%)": "Проміжок між стовпцями (%)",
|
||||
"Always Show Status Bar": "Завжди показувати панель стану",
|
||||
"Translation Not Available": "Переклад недоступний",
|
||||
"Custom Content CSS": "CSS вмісту",
|
||||
"Custom Content CSS": "Користуацький CSS вмісту",
|
||||
"Enter CSS for book content styling...": "Введіть CSS для стилізації вмісту книги...",
|
||||
"Custom Reader UI CSS": "CSS інтерфейсу",
|
||||
"Enter CSS for reader interface styling...": "Введіть CSS для стилізації інтерфейсу рідера...",
|
||||
"Enter CSS for reader interface styling...": "Введіть CSS для стилізації інтерфейсу читанки...",
|
||||
"Crop": "Обрізати",
|
||||
"Book Covers": "Обкладинки книг",
|
||||
"Fit": "Вписати",
|
||||
"Fit": "Припасувати",
|
||||
"Reset {{settings}}": "Скинути {{settings}}",
|
||||
"Reset Settings": "Скинути налаштування",
|
||||
"{{count}} pages left in chapter_one": "Залишилася {{count}} сторінка у розділі",
|
||||
@@ -367,7 +365,6 @@
|
||||
"Upgrade to Plus or Pro": "Перейти на Plus або Pro",
|
||||
"Current Plan": "Поточний план",
|
||||
"Plan Limits": "Обмеження плану",
|
||||
"Failed to manage subscription. Please try again later.": "Не вдалося керувати підпискою. Спробуйте ще раз пізніше.",
|
||||
"Processing your payment...": "Обробка вашого платежу...",
|
||||
"Please wait while we confirm your subscription.": "Зачекайте, ми підтверджуємо вашу підписку.",
|
||||
"Payment Processing": "Обробка платежу",
|
||||
@@ -385,45 +382,92 @@
|
||||
"Need help? Contact our support team at support@readest.com": "Потрібна допомога? Зверніться до служби підтримки: support@readest.com",
|
||||
"Free Plan": "Безкоштовний план",
|
||||
"month": "місяць",
|
||||
"AI Translations (per day)": "AI-переклади (на день)",
|
||||
"AI Translations (per day)": "ШІ-переклади (на день)",
|
||||
"Plus Plan": "План Plus",
|
||||
"Includes All Free Plan Benefits": "Включає всі переваги Безкоштовного плану",
|
||||
"Pro Plan": "План Pro",
|
||||
"More AI Translations": "Більше AI-перекладів",
|
||||
"More AI Translations": "Більше ШІ-перекладів",
|
||||
"Complete Your Subscription": "Завершіть підписку",
|
||||
"{{percentage}}% of Cloud Sync Space Used.": "Використано {{percentage}}% хмарного сховища.",
|
||||
"Cloud Sync Storage": "Облачне хранилище",
|
||||
"Cloud Sync Storage": "Хмарне сховище",
|
||||
"Disable": "Вимкнути",
|
||||
"Enable": "Увімкнути",
|
||||
"Upgrade to Readest Premium": "Оновити до Readest Premium",
|
||||
"Show Source Text": "Показати вихідний текст",
|
||||
"Cross-Platform Sync": "Міжплатформна Синхронізація",
|
||||
"Seamlessly sync your library, progress, highlights, and notes across all your devices—never lose your place again.": "Безперешкодно синхронізуйте свою бібліотеку, прогрес, виділення та нотатки на всіх своїх пристроях—ніколи більше не втрачайте своє місце.",
|
||||
"Customizable Reading": "Налаштовуване Читання",
|
||||
"Cross-Platform Sync": "Міжплатформова синхронізація",
|
||||
"Seamlessly sync your library, progress, highlights, and notes across all your devices—never lose your place again.": "Безперешкодно синхронізуйте свою бібліотеку, прогрес, виділення та нотатки на всіх своїх пристроях. Більше ніколи не втратите свої книги.",
|
||||
"Customizable Reading": "Користувацьке читання",
|
||||
"Personalize every detail with adjustable fonts, layouts, themes, and advanced display settings for the perfect reading experience.": "Персоналізуйте кожну деталь за допомогою налаштовуваних шрифтів, макетів, тем та розширених налаштувань відображення для ідеального читання.",
|
||||
"AI Read Aloud": "Озвучування ШІ",
|
||||
"Enjoy hands-free reading with natural-sounding AI voices that bring your books to life.": "Насолоджуйтесь читанням без використання рук з природно звучними голосами ШІ, які оживляють ваші книги.",
|
||||
"Enjoy hands-free reading with natural-sounding AI voices that bring your books to life.": "Насолоджуйтесь читанням без використання рук з природними звучаннями голосів, які оживляють ваші книги.",
|
||||
"AI Translations": "Переклади ШІ",
|
||||
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Перекладайте будь-який текст миттєво за допомогою Google, Azure або DeepL—розумійте зміст будь-якою мовою.",
|
||||
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Перекладайте будь-який текст миттєво за допомогою Google, Azure або DeepL. Розумійте зміст будь-якою мовою.",
|
||||
"Connect with fellow readers and get help fast in our friendly community channels.": "Спілкуйтеся з іншими читачами та отримуйте швидку допомогу в наших дружніх каналах спільноти.",
|
||||
"Unlimited AI Read Aloud Hours": "Необмежені Години Озвучування ШІ",
|
||||
"Unlimited AI Read Aloud Hours": "Необмежені години озвучування ШІ",
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "Слухайте без обмежень—перетворюйте стільки тексту, скільки хочете, в захоплююче аудіо.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Розблокуйте покращені можливості перекладу з більшим щоденним використанням та розширеними опціями.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Розблокуйте покращені можливості перекладу з більшим щоденним використанням та розширеними функціями.",
|
||||
"DeepL Pro Access": "Доступ до DeepL Pro",
|
||||
"2 GB Cloud Sync Storage": "2 ГБ Хмарного Сховища",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "Насолоджуйтеся швидшими відповідями та персональною допомогою, коли вам потрібна підтримка.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Досягнуто денну квоту перекладів. Оновіть свій план, щоб продовжити використовувати переклади ШІ.",
|
||||
"Includes All Plus Plan Benefits": "Включає Всі Переваги Plus Плану",
|
||||
"Early Feature Access": "Ранній Доступ до Функцій",
|
||||
"Includes All Plus Plan Benefits": "Включає усі переваги Plus плану",
|
||||
"Early Feature Access": "Ранній Доступ до функцій",
|
||||
"Be the first to explore new features, updates, and innovations before anyone else.": "Досліджуйте нові функції, оновлення та інновації першими.",
|
||||
"Advanced AI Tools": "Розширені ШІ Інструменти",
|
||||
"Advanced AI Tools": "Розширені ШІ інструменти",
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Використовуйте потужні ШІ інструменти для розумного читання, перекладу та пошуку контенту.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Перекладайте до 100 000 символів щодня з найточнішим доступним движком перекладу.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Перекладайте до 500 000 символів щодня з найточнішим доступним движком перекладу.",
|
||||
"10 GB Cloud Sync Storage": "10 ГБ Хмарного Сховища",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Безпечно зберігайте та отримуйте доступ до всієї колекції читання з до 2 ГБ хмарного сховища.",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Безпечно зберігайте та отримуйте доступ до всієї колекції читання з до 10 ГБ хмарного сховища.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Перекладайте до 100 000 символів щодня з найточнішим доступним рушієм перекладу.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Перекладайте до 500 000 символів щодня з найточнішим доступним рушієм перекладу.",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Безпечно зберігайте та отримуйте доступ до всієї колекції читання з до 5 ГБ хмарного сховища.",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Безпечно зберігайте та отримуйте доступ до всієї колекції читання з до 20 ГБ хмарного сховища.",
|
||||
"Deleted cloud backup of the book: {{title}}": "Видалено хмарну резервну копію книги: {{title}}",
|
||||
"Failed to delete cloud backup of the book": "Не вдалося видалити хмарну резервну копію книги.",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Ви впевнені, що хочете видалити хмарну резервну копію вибраної книги?"
|
||||
"Failed to delete cloud backup of the book": "Не вдалося видалити хмарну резервну копію.",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Ви впевнені, що хочете видалити хмарну резервну копію вибраної книги?",
|
||||
"What's New in Readest": "Що нового в Readest",
|
||||
"Enter book title": "Введіть назву книги",
|
||||
"Subtitle": "Підзаголовок",
|
||||
"Enter book subtitle": "Введіть підзаголовок книги",
|
||||
"Enter author name": "Введіть ім'я автора",
|
||||
"Series": "Цикл",
|
||||
"Enter series name": "Введіть назву циклу",
|
||||
"Series Index": "Номер циклу",
|
||||
"Enter series index": "Введіть номер циклу",
|
||||
"Total in Series": "Всього у циклі",
|
||||
"Enter total books in series": "Введіть загальну кількість книг у циклі",
|
||||
"Enter publisher": "Введіть видавництвця",
|
||||
"Publication Date": "Дата видання",
|
||||
"Identifier": "Ідентифікатор",
|
||||
"Enter book description": "Введіть опис книги",
|
||||
"Change cover image": "Змінити зображення обкладинки",
|
||||
"Replace": "Замінити",
|
||||
"Unlock cover": "Розблокувати обкладинку",
|
||||
"Lock cover": "Заблокувати обкладинку",
|
||||
"Auto-Retrieve Metadata": "Автоматично підтягнути метадані",
|
||||
"Auto-Retrieve": "Автоматичне підтягування",
|
||||
"Unlock all fields": "Розблокувати всі поля",
|
||||
"Unlock All": "Розблокувати все",
|
||||
"Lock all fields": "Заблокувати всі поля",
|
||||
"Lock All": "Заблокувати все",
|
||||
"Reset": "Скинути",
|
||||
"Edit Metadata": "Редагувати метадані",
|
||||
"Locked": "Заблоковано",
|
||||
"Select Metadata Source": "Оберіть джерело метаданих",
|
||||
"Keep manual input": "Зберегти ручне введення",
|
||||
"Google Books": "Google Книги",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Художня література, Наукова фантастика, Історія",
|
||||
"Select Cover Image": "Оберіть зображення обкладинки",
|
||||
"Open Book in New Window": "Відкрити книгу в новому вікні",
|
||||
"Voices for {{lang}}": "Голоси для {{lang}}",
|
||||
"Yandex Translate": "Yandex Перекладач",
|
||||
"YYYY or YYYY-MM-DD": "YYYY або YYYY-MM-DD",
|
||||
"Restore Purchase": "Відновити покупку",
|
||||
"No purchases found to restore.": "Не знайдено покупок для відновлення.",
|
||||
"Failed to restore purchases.": "Не вдалося відновити покупки.",
|
||||
"Failed to manage subscription.": "Не вдалося керувати підпискою.",
|
||||
"Failed to load subscription plans.": "Не вдалося завантажити плани підписки.",
|
||||
"year": "рік",
|
||||
"Failed to create checkout session": "Не вдалося створити сесію оформлення замовлення",
|
||||
"Storage": "Сховище",
|
||||
"Terms of Service": "Умови обслуговування",
|
||||
"Privacy Policy": "Політика конфіденційності",
|
||||
"Disable Double Click": "Вимкнути подвійне клацання"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "Hướng ngang",
|
||||
"Hyphenation": "Gạch nối từ",
|
||||
"Import Books": "Nhập sách",
|
||||
"Language:": "Ngôn ngữ:",
|
||||
"Layout": "Bố cục",
|
||||
"Light Mode": "Chế độ sáng",
|
||||
"Loading...": "Đang tải...",
|
||||
@@ -68,8 +67,8 @@
|
||||
"Paging Animation": "Hiệu ứng lật trang",
|
||||
"Paragraph": "Đoạn văn",
|
||||
"Parallel Read": "Đọc song song",
|
||||
"Published:": "Xuất bản:",
|
||||
"Publisher:": "Nhà xuất bản:",
|
||||
"Published": "Xuất bản",
|
||||
"Publisher": "Nhà xuất bản",
|
||||
"Reading Progress Synced": "Tiến độ đọc đã được đồng bộ",
|
||||
"Reload Page": "Tải lại trang",
|
||||
"Reveal in File Explorer": "Hiển thị trong File Explorer",
|
||||
@@ -94,7 +93,7 @@
|
||||
"Slow": "Chậm",
|
||||
"Solarized": "Solarized",
|
||||
"Speak": "Đọc",
|
||||
"Subjects:": "Chủ đề:",
|
||||
"Subjects": "Chủ đề",
|
||||
"System Fonts": "Phông chữ hệ thống",
|
||||
"TTS not supported in this device": "TTS không được hỗ trợ trên thiết bị này",
|
||||
"Theme Color": "Màu chủ đề",
|
||||
@@ -103,7 +102,7 @@
|
||||
"Translated Text": "Văn bản dịch",
|
||||
"Unknown": "Không xác định",
|
||||
"Untitled": "Không có tiêu đề",
|
||||
"Updated:": "Cập nhật:",
|
||||
"Updated": "Cập nhật",
|
||||
"Version {{version}}": "Phiên bản {{version}}",
|
||||
"Vertical Direction": "Hướng dọc",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Chào mừng đến với thư viện của bạn. Bạn có thể nhập sách vào đây và đọc bất cứ lúc nào.",
|
||||
@@ -244,7 +243,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Đang tải {{downloaded}} trong {{contentLength}}",
|
||||
"Download finished": "Tải xuống hoàn tất",
|
||||
"DOWNLOAD & INSTALL": "TẢI XUỐNG & CÀI ĐẶT",
|
||||
"Changelog:": "Thay đổi:",
|
||||
"Changelog": "Thay đổi",
|
||||
"Software Update": "Cập nhật phần mềm",
|
||||
"Title": "Tiêu đề",
|
||||
"Date Read": "Ngày đã đọc",
|
||||
@@ -253,10 +252,9 @@
|
||||
"Ascending": "Tăng dần",
|
||||
"Descending": "Giảm dần",
|
||||
"Sort by...": "Sắp xếp theo...",
|
||||
"Added:": "Đã thêm:",
|
||||
"Format:": "Định dạng:",
|
||||
"Added": "Đã thêm",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Description:": "Miêu tả:",
|
||||
"Description": "Miêu tả",
|
||||
"No description available": "Không có mô tả nào",
|
||||
"List": "Danh sách",
|
||||
"Grid": "Lưới",
|
||||
@@ -273,7 +271,7 @@
|
||||
"Checking for updates...": "Đang kiểm tra cập nhật...",
|
||||
"Error checking for updates": "Lỗi khi kiểm tra cập nhật",
|
||||
"Details": "Chi tiết",
|
||||
"File Size:": "Kích thước tệp:",
|
||||
"File Size": "Kích thước tệp",
|
||||
"Auto Detect": "Phát hiện tự động",
|
||||
"Next Section": "Phần tiếp theo",
|
||||
"Previous Section": "Phần trước",
|
||||
@@ -355,7 +353,6 @@
|
||||
"Upgrade to Plus or Pro": "Nâng cấp lên Plus hoặc Pro",
|
||||
"Current Plan": "Gói hiện tại",
|
||||
"Plan Limits": "Giới hạn gói",
|
||||
"Failed to manage subscription. Please try again later.": "Không thể quản lý đăng ký. Vui lòng thử lại sau.",
|
||||
"Processing your payment...": "Đang xử lý thanh toán...",
|
||||
"Please wait while we confirm your subscription.": "Vui lòng chờ trong khi chúng tôi xác nhận đăng ký của bạn.",
|
||||
"Payment Processing": "Đang xử lý thanh toán",
|
||||
@@ -398,7 +395,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "Nghe không giới hạn—chuyển đổi bao nhiêu văn bản bạn muốn thành âm thanh sống động.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Mở khóa khả năng dịch thuật nâng cao với nhiều lượt sử dụng hàng ngày và tùy chọn nâng cao.",
|
||||
"DeepL Pro Access": "Truy Cập DeepL Pro",
|
||||
"2 GB Cloud Sync Storage": "Lưu Trữ Đồng Bộ Đám Mây 2 GB",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "Tận hưởng phản hồi nhanh hơn và hỗ trợ chuyên biệt khi bạn cần giúp đỡ.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Đã đạt hạn ngạch dịch thuật hàng ngày. Nâng cấp gói để tiếp tục sử dụng dịch thuật AI.",
|
||||
"Includes All Plus Plan Benefits": "Bao Gồm Tất Cả Lợi Ích Gói Plus",
|
||||
@@ -408,10 +404,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Khai thác công cụ AI mạnh mẽ cho đọc thông minh, dịch thuật và khám phá nội dung.",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Dịch tới 100.000 ký tự hàng ngày với công cụ dịch chính xác nhất hiện có.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Dịch tới 500.000 ký tự hàng ngày với công cụ dịch chính xác nhất hiện có.",
|
||||
"10 GB Cloud Sync Storage": "Lưu Trữ Đồng Bộ Đám Mây 10 GB",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Lưu trữ và truy cập an toàn toàn bộ bộ sưu tập đọc với tới 2 GB lưu trữ đám mây.",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Lưu trữ và truy cập an toàn toàn bộ bộ sưu tập đọc với tới 10 GB lưu trữ đám mây.",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Lưu trữ và truy cập an toàn toàn bộ bộ sưu tập đọc với tới 5 GB lưu trữ đám mây.",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Lưu trữ và truy cập an toàn toàn bộ bộ sưu tập đọc với tới 20 GB lưu trữ đám mây.",
|
||||
"Deleted cloud backup of the book: {{title}}": "Đã xóa bản sao lưu đám mây của sách: {{title}}",
|
||||
"Failed to delete cloud backup of the book": "Không thể xóa bản sao lưu đám mây của sách.",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Bạn có chắc chắn muốn xóa bản sao lưu đám mây của sách đã chọn không?"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Bạn có chắc chắn muốn xóa bản sao lưu đám mây của sách đã chọn không?",
|
||||
"What's New in Readest": "Những gì mới trong Readest",
|
||||
"Enter book title": "Nhập tiêu đề sách",
|
||||
"Subtitle": "Phụ đề",
|
||||
"Enter book subtitle": "Nhập phụ đề sách",
|
||||
"Enter author name": "Nhập tên tác giả",
|
||||
"Series": "Bộ sách",
|
||||
"Enter series name": "Nhập tên bộ sách",
|
||||
"Series Index": "Số thứ tự trong bộ",
|
||||
"Enter series index": "Nhập số thứ tự trong bộ",
|
||||
"Total in Series": "Tổng số trong bộ",
|
||||
"Enter total books in series": "Nhập tổng số sách trong bộ",
|
||||
"Enter publisher": "Nhập nhà xuất bản",
|
||||
"Publication Date": "Ngày xuất bản",
|
||||
"Identifier": "Định danh",
|
||||
"Enter book description": "Nhập mô tả sách",
|
||||
"Change cover image": "Thay đổi ảnh bìa",
|
||||
"Replace": "Thay thế",
|
||||
"Unlock cover": "Mở khóa bìa",
|
||||
"Lock cover": "Khóa bìa",
|
||||
"Auto-Retrieve Metadata": "Tự động lấy siêu dữ liệu",
|
||||
"Auto-Retrieve": "Tự động lấy",
|
||||
"Unlock all fields": "Mở khóa tất cả trường",
|
||||
"Unlock All": "Mở khóa tất cả",
|
||||
"Lock all fields": "Khóa tất cả trường",
|
||||
"Lock All": "Khóa tất cả",
|
||||
"Reset": "Đặt lại",
|
||||
"Edit Metadata": "Chỉnh sửa siêu dữ liệu",
|
||||
"Locked": "Đã khóa",
|
||||
"Select Metadata Source": "Chọn nguồn siêu dữ liệu",
|
||||
"Keep manual input": "Giữ nhập liệu thủ công",
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Tiểu thuyết, Khoa học, Lịch sử",
|
||||
"Select Cover Image": "Chọn ảnh bìa",
|
||||
"Open Book in New Window": "Mở Sách trong Cửa sổ Mới",
|
||||
"Voices for {{lang}}": "Giọng nói cho {{lang}}",
|
||||
"Yandex Translate": "Yandex Dịch",
|
||||
"YYYY or YYYY-MM-DD": "YYYY hoặc YYYY-MM-DD",
|
||||
"Restore Purchase": "Khôi phục mua hàng",
|
||||
"No purchases found to restore.": "Không tìm thấy giao dịch mua nào để khôi phục.",
|
||||
"Failed to restore purchases.": "Khôi phục giao dịch mua không thành công.",
|
||||
"Failed to manage subscription.": "Quản lý đăng ký không thành công.",
|
||||
"Failed to load subscription plans.": "Tải kế hoạch đăng ký không thành công.",
|
||||
"year": "năm",
|
||||
"Failed to create checkout session": "Không thể tạo phiên làm việc thanh toán",
|
||||
"Storage": "Lưu trữ",
|
||||
"Terms of Service": "Điều khoản dịch vụ",
|
||||
"Privacy Policy": "Chính sách bảo mật",
|
||||
"Disable Double Click": "Tắt Nhấp Đôi"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "横排",
|
||||
"Hyphenation": "断字",
|
||||
"Import Books": "导入书籍",
|
||||
"Language:": "语言",
|
||||
"Layout": "布局",
|
||||
"Light Mode": "浅色主题",
|
||||
"Loading...": "加载中...",
|
||||
@@ -68,8 +67,8 @@
|
||||
"Paging Animation": "翻页动画",
|
||||
"Paragraph": "段落",
|
||||
"Parallel Read": "并排阅读",
|
||||
"Published:": "出版日期",
|
||||
"Publisher:": "出版商",
|
||||
"Published": "出版日期",
|
||||
"Publisher": "出版商",
|
||||
"Reading Progress Synced": "阅读进度已同步",
|
||||
"Reload Page": "重新加载页面",
|
||||
"Reveal in File Explorer": "在文件管理器中显示",
|
||||
@@ -94,7 +93,7 @@
|
||||
"Slow": "慢",
|
||||
"Solarized": "日晖",
|
||||
"Speak": "朗读",
|
||||
"Subjects:": "主题",
|
||||
"Subjects": "主题",
|
||||
"System Fonts": "系统字体",
|
||||
"TTS not supported in this device": "此设备不支持 TTS",
|
||||
"Theme Color": "主题颜色",
|
||||
@@ -103,7 +102,7 @@
|
||||
"Translated Text": "译文",
|
||||
"Unknown": "未知",
|
||||
"Untitled": "无标题",
|
||||
"Updated:": "更新日期",
|
||||
"Updated": "更新日期",
|
||||
"Version {{version}}": "版本 {{version}}",
|
||||
"Vertical Direction": "竖排",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "书库空空如也,您可以导入您的书籍并随时阅读。",
|
||||
@@ -244,7 +243,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "正在下载 {{downloaded}} / {{contentLength}}",
|
||||
"Download finished": "下载完成",
|
||||
"DOWNLOAD & INSTALL": "下载并安装",
|
||||
"Changelog:": "更新日志:",
|
||||
"Changelog": "更新日志",
|
||||
"Software Update": "软件更新",
|
||||
"Title": "书名",
|
||||
"Date Read": "阅读日期",
|
||||
@@ -253,10 +252,9 @@
|
||||
"Ascending": "升序",
|
||||
"Descending": "降序",
|
||||
"Sort by...": "排序方式...",
|
||||
"Added:": "添加日期",
|
||||
"Format:": "格式",
|
||||
"Added": "添加日期",
|
||||
"GuanKiapTsingKhai-T": "原俠正楷-T",
|
||||
"Description:": "简介",
|
||||
"Description": "简介",
|
||||
"No description available": "暂无简介",
|
||||
"List": "列表",
|
||||
"Grid": "网格",
|
||||
@@ -273,7 +271,7 @@
|
||||
"Checking for updates...": "检查更新中...",
|
||||
"Error checking for updates": "检查更新时出错",
|
||||
"Details": "详情",
|
||||
"File Size:": "文件大小",
|
||||
"File Size": "文件大小",
|
||||
"Auto Detect": "自动检测",
|
||||
"Next Section": "下一章",
|
||||
"Previous Section": "上一章",
|
||||
@@ -319,7 +317,7 @@
|
||||
"Quota Exceeded": "额度不足",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "已使用 {{percentage}}% 的每日翻译字符数",
|
||||
"Translation Characters": "翻译字数",
|
||||
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} 种声音",
|
||||
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} 种语音",
|
||||
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "出了点问题。请不要担心,我们的团队已经收到通知并正在修复中。",
|
||||
"Error Details:": "错误详情:",
|
||||
"Try Again": "重试",
|
||||
@@ -355,7 +353,6 @@
|
||||
"Upgrade to Plus or Pro": "升级到 Plus 或 Pro",
|
||||
"Current Plan": "当前套餐",
|
||||
"Plan Limits": "套餐限制",
|
||||
"Failed to manage subscription. Please try again later.": "无法管理订阅,请稍后再试。",
|
||||
"Processing your payment...": "正在处理付款...",
|
||||
"Please wait while we confirm your subscription.": "请稍候,我们正在确认您的订阅。",
|
||||
"Payment Processing": "付款处理中",
|
||||
@@ -398,7 +395,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "将任意数量的文本转换为沉浸式音频。",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "增强翻译功能、更多翻译用量和高级选项。",
|
||||
"DeepL Pro Access": "DeepL Pro 访问权限",
|
||||
"2 GB Cloud Sync Storage": "2GB 云同步存储",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "享受更快响应和专属帮助,随时获得支持。",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "已达每日翻译配额。升级套餐以继续使用 AI 翻译。",
|
||||
"Includes All Plus Plan Benefits": "包含所有 Plus 套餐功能",
|
||||
@@ -408,10 +404,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "利用强大的 AI 工具实现智能阅读、翻译和内容发现。",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "使用最精准的翻译引擎每天可翻译10万字。",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "使用最精准的翻译引擎每天可翻译50万字。",
|
||||
"10 GB Cloud Sync Storage": "10GB 云同步存储",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "使用多达 2GB 的安全云存储保存和访问您的整个阅读收藏。",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "使用多达 10GB 的安全云存储保存和访问您的整个阅读收藏。",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "使用多达 5GB 的安全云存储保存和访问您的整个阅读收藏。",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "使用多达 20GB 的安全云存储保存和访问您的整个阅读收藏。",
|
||||
"Deleted cloud backup of the book: {{title}}": "已删除书籍云备份:{{title}}",
|
||||
"Failed to delete cloud backup of the book": "无法删除书籍云备份。",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "您确定要删除所选书籍的云备份吗?"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "您确定要删除所选书籍的云备份吗?",
|
||||
"What's New in Readest": "Readest 更新功能",
|
||||
"Enter book title": "输入书名",
|
||||
"Subtitle": "副标题",
|
||||
"Enter book subtitle": "输入书籍副标题",
|
||||
"Enter author name": "输入作者姓名",
|
||||
"Series": "系列",
|
||||
"Enter series name": "输入系列名称",
|
||||
"Series Index": "系列编号",
|
||||
"Enter series index": "输入系列编号",
|
||||
"Total in Series": "系列总数",
|
||||
"Enter total books in series": "输入系列中书籍总数",
|
||||
"Enter publisher": "输入出版社",
|
||||
"Publication Date": "出版日期",
|
||||
"Identifier": "标识符",
|
||||
"Enter book description": "输入书籍描述",
|
||||
"Change cover image": "替换封面图片",
|
||||
"Replace": "替换",
|
||||
"Unlock cover": "解锁封面",
|
||||
"Lock cover": "锁定封面",
|
||||
"Auto-Retrieve Metadata": "自动获取元数据",
|
||||
"Auto-Retrieve": "自动获取",
|
||||
"Unlock all fields": "解锁所有字段",
|
||||
"Unlock All": "全部解锁",
|
||||
"Lock all fields": "锁定所有字段",
|
||||
"Lock All": "全部锁定",
|
||||
"Reset": "重置",
|
||||
"Edit Metadata": "编辑元数据",
|
||||
"Locked": "已锁定",
|
||||
"Select Metadata Source": "选择元数据来源",
|
||||
"Keep manual input": "保留手动输入",
|
||||
"Google Books": "谷歌图书",
|
||||
"Open Library": "开放图书馆",
|
||||
"Fiction, Science, History": "小说,科学,历史",
|
||||
"Select Cover Image": "选择封面图片",
|
||||
"Open Book in New Window": "在新窗口中打开书籍",
|
||||
"Voices for {{lang}}": "{{lang}} 的语音",
|
||||
"Yandex Translate": "Yandex 翻译",
|
||||
"YYYY or YYYY-MM-DD": "YYYY 或 YYYY-MM-DD",
|
||||
"Restore Purchase": "恢复购买",
|
||||
"No purchases found to restore.": "未找到可恢复的购买。",
|
||||
"Failed to restore purchases.": "恢复购买失败。",
|
||||
"Failed to manage subscription.": "管理订阅失败。",
|
||||
"Failed to load subscription plans.": "加载订阅计划失败。",
|
||||
"year": "年",
|
||||
"Failed to create checkout session": "创建结账会话失败",
|
||||
"Storage": "云同步空间",
|
||||
"Terms of Service": "服务条款",
|
||||
"Privacy Policy": "隐私政策",
|
||||
"Disable Double Click": "禁用双击"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Horizontal Direction": "橫排",
|
||||
"Hyphenation": "斷字",
|
||||
"Import Books": "導入書籍",
|
||||
"Language:": "語言",
|
||||
"Layout": "版面",
|
||||
"Light Mode": "淺色主題",
|
||||
"Loading...": "載入中...",
|
||||
@@ -68,8 +67,8 @@
|
||||
"Paging Animation": "翻頁動畫",
|
||||
"Paragraph": "段落",
|
||||
"Parallel Read": "並排閱讀",
|
||||
"Published:": "出版日期",
|
||||
"Publisher:": "出版商",
|
||||
"Published": "出版日期",
|
||||
"Publisher": "出版商",
|
||||
"Reading Progress Synced": "閱讀進度已同步",
|
||||
"Reload Page": "重新載入頁面",
|
||||
"Reveal in File Explorer": "在檔案管理器中顯示",
|
||||
@@ -94,7 +93,7 @@
|
||||
"Slow": "慢",
|
||||
"Solarized": "日暉",
|
||||
"Speak": "朗讀",
|
||||
"Subjects:": "主題",
|
||||
"Subjects": "主題",
|
||||
"System Fonts": "系統字體",
|
||||
"TTS not supported in this device": "此設備不支持 TTS",
|
||||
"Theme Color": "主題顏色",
|
||||
@@ -103,7 +102,7 @@
|
||||
"Translated Text": "譯文",
|
||||
"Unknown": "未知",
|
||||
"Untitled": "無標題",
|
||||
"Updated:": "更新日期",
|
||||
"Updated": "更新日期",
|
||||
"Version {{version}}": "版本 {{version}}",
|
||||
"Vertical Direction": "豎排",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "書庫空空如也,您可以導入您的書籍並隨時閱讀。",
|
||||
@@ -244,7 +243,7 @@
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "正在下載 {{downloaded}} / {{contentLength}}",
|
||||
"Download finished": "下載完成",
|
||||
"DOWNLOAD & INSTALL": "下載並安裝",
|
||||
"Changelog:": "更新日誌:",
|
||||
"Changelog": "更新日誌:",
|
||||
"Software Update": "軟體更新",
|
||||
"Title": "書名",
|
||||
"Date Read": "閱讀日期",
|
||||
@@ -253,10 +252,9 @@
|
||||
"Ascending": "升序",
|
||||
"Descending": "降序",
|
||||
"Sort by...": "排序方式...",
|
||||
"Added:": "添加日期",
|
||||
"Format:": "格式",
|
||||
"Added": "添加日期",
|
||||
"GuanKiapTsingKhai-T": "原俠正楷-T",
|
||||
"Description:": "簡介",
|
||||
"Description": "簡介",
|
||||
"No description available": "暫無簡介",
|
||||
"List": "列表",
|
||||
"Grid": "網格",
|
||||
@@ -273,7 +271,7 @@
|
||||
"Checking for updates...": "檢查更新中...",
|
||||
"Error checking for updates": "檢查更新時出錯",
|
||||
"Details": "詳情",
|
||||
"File Size:": "檔案大小",
|
||||
"File Size": "檔案大小",
|
||||
"Auto Detect": "自動檢測",
|
||||
"Next Section": "下一章",
|
||||
"Previous Section": "上一章",
|
||||
@@ -355,7 +353,6 @@
|
||||
"Upgrade to Plus or Pro": "升級到 Plus 或 Pro",
|
||||
"Current Plan": "當前方案",
|
||||
"Plan Limits": "方案限制",
|
||||
"Failed to manage subscription. Please try again later.": "無法管理訂閱,請稍後再試。",
|
||||
"Processing your payment...": "正在處理付款...",
|
||||
"Please wait while we confirm your subscription.": "請稍候,我們正在確認您的訂閱。",
|
||||
"Payment Processing": "付款處理中",
|
||||
@@ -398,7 +395,6 @@
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "無限制聆聽——將任意數量的文字轉換為沉浸式音訊。",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "解鎖增強翻譯功能,享受更多翻譯用量和進階選項。",
|
||||
"DeepL Pro Access": "DeepL Pro 存取權限",
|
||||
"2 GB Cloud Sync Storage": "2GB 雲端同步儲存",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "享受更快回應和專屬幫助,隨時獲得所需支援。",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "已達每日翻譯配額。升級方案以繼續使用 AI 翻譯。",
|
||||
"Includes All Plus Plan Benefits": "包含所有 Plus 方案優惠",
|
||||
@@ -408,10 +404,58 @@
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "運用強大的 AI 工具實現智慧閱讀、翻譯和內容探索。",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "使用最精準的翻譯引擎每天翻譯多達10萬字元。",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "使用最精準的翻譯引擎每天翻譯多達50萬字元。",
|
||||
"10 GB Cloud Sync Storage": "10GB 雲端同步儲存",
|
||||
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "使用多達 2GB 的安全雲端儲存保存和存取您的整個閱讀收藏。",
|
||||
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "使用多達 10GB 的安全雲端儲存保存和存取您的整個閱讀收藏。",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "使用多達 5GB 的安全雲端儲存保存和存取您的整個閱讀收藏。",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "使用多達 20GB 的安全雲端儲存保存和存取您的整個閱讀收藏。",
|
||||
"Deleted cloud backup of the book: {{title}}": "已刪除書籍的雲端備份:{{title}}",
|
||||
"Failed to delete cloud backup of the book": "無法刪除書籍的雲端備份。",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "您確定要刪除所選書籍的雲端備份嗎?"
|
||||
"Are you sure to delete the cloud backup of the selected book?": "您確定要刪除所選書籍的雲端備份嗎?",
|
||||
"What's New in Readest": "Readest 更新功能",
|
||||
"Enter book title": "輸入書名",
|
||||
"Subtitle": "副標題",
|
||||
"Enter book subtitle": "輸入書籍副標題",
|
||||
"Enter author name": "輸入作者姓名",
|
||||
"Series": "系列",
|
||||
"Enter series name": "輸入系列名稱",
|
||||
"Series Index": "系列編號",
|
||||
"Enter series index": "輸入系列編號",
|
||||
"Total in Series": "系列總數",
|
||||
"Enter total books in series": "輸入系列中書籍總數",
|
||||
"Enter publisher": "輸入出版社",
|
||||
"Publication Date": "出版日期",
|
||||
"Identifier": "識別碼",
|
||||
"Enter book description": "輸入書籍描述",
|
||||
"Change cover image": "替換封面圖片",
|
||||
"Replace": "替換",
|
||||
"Unlock cover": "解鎖封面",
|
||||
"Lock cover": "鎖定封面",
|
||||
"Auto-Retrieve Metadata": "自動擷取中繼資料",
|
||||
"Auto-Retrieve": "自動擷取",
|
||||
"Unlock all fields": "解鎖所有欄位",
|
||||
"Unlock All": "全部解鎖",
|
||||
"Lock all fields": "鎖定所有欄位",
|
||||
"Lock All": "全部鎖定",
|
||||
"Reset": "重設",
|
||||
"Edit Metadata": "編輯中繼資料",
|
||||
"Locked": "已鎖定",
|
||||
"Select Metadata Source": "選擇中繼資料來源",
|
||||
"Keep manual input": "保留手動輸入",
|
||||
"Google Books": "Google 圖書",
|
||||
"Open Library": "開放圖書館",
|
||||
"Fiction, Science, History": "小說,科學,歷史",
|
||||
"Select Cover Image": "選擇封面圖片",
|
||||
"Open Book in New Window": "在新視窗中打開書籍",
|
||||
"Voices for {{lang}}": "{{lang}} 的語音",
|
||||
"Yandex Translate": "Yandex 翻譯",
|
||||
"YYYY or YYYY-MM-DD": "YYYY 或 YYYY-MM-DD",
|
||||
"Restore Purchase": "恢復購買",
|
||||
"No purchases found to restore.": "未找到可恢復的購買。",
|
||||
"Failed to restore purchases.": "恢復購買失敗。",
|
||||
"Failed to manage subscription.": "管理訂閱失敗。",
|
||||
"Failed to load subscription plans.": "加載訂閱計劃失敗。",
|
||||
"year": "年",
|
||||
"Failed to create checkout session": "創建結帳會話失敗",
|
||||
"Storage": "雲同步空間",
|
||||
"Terms of Service": "服務條款",
|
||||
"Privacy Policy": "隱私政策",
|
||||
"Disable Double Click": "禁用雙擊"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,75 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.69": {
|
||||
"date": "2025-08-02",
|
||||
"notes": [
|
||||
"Updater: Fixed permission issue when updating in standalone reader window",
|
||||
"Config: Added option to disable double‑click actions",
|
||||
"EPUB: Fixed missing images in some EPUB files",
|
||||
"EPUB: Corrected formatting for book subjects",
|
||||
"TXT: Improved language detection when parsing TXT files",
|
||||
"CSS: Resolved issue where custom font colors could not be applied",
|
||||
"Layout: Corrected cover image width display in 'Fit' mode for list view",
|
||||
"Layout: Fixed unintended scrolling in reader view when highlighting text"
|
||||
]
|
||||
},
|
||||
"0.9.68": {
|
||||
"date": "2025-07-30",
|
||||
"notes": [
|
||||
"Reader: Files now open in a new window by default on Desktop",
|
||||
"Translation: Skips translating content inside <pre>, <code>, and <math> tags",
|
||||
"Performance: Improved multi-part downloading for large books",
|
||||
"Sync: Resolved issue preventing some PDF files from syncing",
|
||||
"CSS: Resolved issue with fixed font color in some EPUB files",
|
||||
"TXT: Enhanced TXT parsing for better compatibility"
|
||||
]
|
||||
},
|
||||
"0.9.67": {
|
||||
"date": "2025-07-22",
|
||||
"notes": [
|
||||
"Reader: Fixed file opening issue when filenames contain commas",
|
||||
"Translation: Added Yandex Translator as a new translation provider",
|
||||
"Layout: Hovering over header now reveals macOS traffic light window controls",
|
||||
"Theme: Reader background color is now customizable for EPUB and PDF",
|
||||
"Library: Search now includes book format, group names, and descriptions",
|
||||
"Library: Various improvements to metadata editor and bookshelf management",
|
||||
"Linux: Fixed social login failure on Linux systems"
|
||||
]
|
||||
},
|
||||
"0.9.66": {
|
||||
"date": "2025-07-21",
|
||||
"notes": [
|
||||
"Layout: Fixed nested table of contents items not expanding in long TOC lists",
|
||||
"Reader: Opening files from the OS now takes highest priority",
|
||||
"TTS: Improved voice filtering for some language codes",
|
||||
"App: Fixed loading issues on older browsers",
|
||||
"App: Fixed wrong MIME types generated on Linux"
|
||||
]
|
||||
},
|
||||
"0.9.65": {
|
||||
"date": "2025-07-19",
|
||||
"notes": [
|
||||
"Reader: Added support for opening multiple reader windows on desktop",
|
||||
"Layout: Added visible window borders on Linux",
|
||||
"TTS: Improved handling of invalid book language",
|
||||
"TTS: Skipped reading footnote anchor links",
|
||||
"Translation: Fixed an issue where table of contents entries were not translated",
|
||||
"Sync: Edited book metadata is now included in sync across devices",
|
||||
"iOS: Disabled context menu when long-pressing on book covers"
|
||||
]
|
||||
},
|
||||
"0.9.64": {
|
||||
"date": "2025-07-16",
|
||||
"notes": [
|
||||
"Library: Added support to edit book metadata",
|
||||
"Layout: Fixed bookmark icon placement when the sidebar is pinned",
|
||||
"Reader: Fixed an issue where links to other chapters in EPUB files did not work",
|
||||
"Reader: Lightened the highlight color in dark mode for better contrast",
|
||||
"Reader: Pull-to-refresh gesture is now smoother and more responsive",
|
||||
"Reader: Removed unintended indentation on images",
|
||||
"Search: Improved responsiveness of full-text search for faster results"
|
||||
]
|
||||
},
|
||||
"0.9.63": {
|
||||
"date": "2025-07-07",
|
||||
"notes": [
|
||||
@@ -8,7 +78,7 @@
|
||||
"Library: You can now delete only the cloud backup of a book without removing the local copy",
|
||||
"Performance: Fonts in the font list now load only when displayed for faster startup",
|
||||
"Performance: Opening large MOBI books is now significantly faster",
|
||||
"Layout: Fixed overlay scrollbar display in virtualized book lists",
|
||||
"Layout: Fixed overlay scrollbar display in longer TOC items list",
|
||||
"Layout: Improved grid cover image height in crop mode",
|
||||
"Layout: Removed unsupported screen orientation lock option on iPad",
|
||||
"Layout: System context menu no longer appears when selecting text on iPad",
|
||||
@@ -282,7 +352,7 @@
|
||||
"0.9.30": {
|
||||
"date": "2025-04-05",
|
||||
"notes": [
|
||||
"Added fixed storage quata when running the app in self-hosted mode",
|
||||
"Added fixed storage quota when running the app in self-hosted mode",
|
||||
"Link colors will only change when you override fonts",
|
||||
"Added cache for native files for better performance"
|
||||
]
|
||||
|
||||
@@ -38,3 +38,11 @@ echo "✅ Updated $PROPERTIES_FILE"
|
||||
|
||||
echo "🚀 Running: pnpm tauri android build"
|
||||
pnpm tauri android build
|
||||
|
||||
source .env.google-play.local
|
||||
if [[ -z "$GOOGLE_PLAY_JSON_KEY_FILE" ]]; then
|
||||
echo "❌ GOOGLE_PLAY_JSON_KEY_FILE is not set"
|
||||
exit 1
|
||||
fi
|
||||
cd ../../
|
||||
fastlane android upload_production
|
||||
|
||||
@@ -14,8 +14,12 @@ rust-version = "1.77.2"
|
||||
name = "readestlib"
|
||||
crate-type = ["staticlib", "cdylib", "lib"]
|
||||
|
||||
[features]
|
||||
# Internal feature to suppress warnings from old objc crate
|
||||
cargo-clippy = []
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.1.1", features = [] }
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
@@ -25,26 +29,26 @@ thiserror = "2"
|
||||
tokio = { version = "1", features = ["fs"] }
|
||||
tokio-util = { version = "0.7", features = ["codec"] }
|
||||
futures-util = "0.3"
|
||||
futures = "0.3.31"
|
||||
read-progress-stream = "1.0.0"
|
||||
reqwest = { version = "0.12", default-features = false, features = [
|
||||
"json",
|
||||
"stream",
|
||||
] }
|
||||
# FIXME: remove the devtools feature in production
|
||||
tauri = { version = "2.5.1", features = [ "protocol-asset", "devtools"] }
|
||||
tauri-build = "2.3.0"
|
||||
tauri-plugin-log = "2.4.0"
|
||||
tauri-plugin-fs = "2.2.1"
|
||||
tauri-plugin-dialog = "2.2.1"
|
||||
tauri-plugin-os = "2.2.1"
|
||||
tauri-plugin-http = "2.4.3"
|
||||
tauri-plugin-shell = "2.2.1"
|
||||
tauri-plugin-process = "2.2.1"
|
||||
tauri = { version = "2.5.1", features = [ "protocol-asset" ] }
|
||||
tauri-build = "2"
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-os = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-oauth = "2"
|
||||
tauri-plugin-opener = "2.2.6"
|
||||
tauri-plugin-deep-link = "2.2.1"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-sign-in-with-apple = "1.0.2"
|
||||
tauri-plugin-haptics = "2.2.4"
|
||||
tauri-plugin-haptics = "2"
|
||||
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
|
||||
tauri-plugin-native-tts = { path = "./plugins/tauri-plugin-native-tts" }
|
||||
|
||||
@@ -61,6 +65,6 @@ objc2-foundation = { version = "0.3", features = ["NSError", "NSArray"] }
|
||||
|
||||
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-cli = "2"
|
||||
tauri-plugin-single-instance = "2.2.3"
|
||||
tauri-plugin-updater = "2.7.0"
|
||||
tauri-plugin-window-state = "2.2.2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": ["main", "updater"],
|
||||
"windows": ["main", "updater", "reader-*"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"fs:default",
|
||||
@@ -43,7 +43,18 @@
|
||||
"identifier": "fs:allow-cache-read",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$CACHEDIR/**/*"
|
||||
"path": "$APPCACHE/**/*"
|
||||
},
|
||||
{
|
||||
"path": "/private/var/mobile/Containers/Data/Application/**/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-cache-write",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$APPCACHE/**/*"
|
||||
},
|
||||
{
|
||||
"path": "/private/var/mobile/Containers/Data/Application/**/*"
|
||||
@@ -68,6 +79,9 @@
|
||||
{
|
||||
"url": "https://edge.microsoft.com"
|
||||
},
|
||||
{
|
||||
"url": "https://translate.toil.cc"
|
||||
},
|
||||
{
|
||||
"url": "https://*.microsofttranslator.com"
|
||||
},
|
||||
@@ -91,6 +105,7 @@
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:allow-destroy",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:path:allow-resolve-directory",
|
||||
"log:default",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "desktop-capability",
|
||||
"windows": ["main", "updater"],
|
||||
"windows": ["main", "updater", "reader-*"],
|
||||
"platforms": ["linux", "macOS", "windows"],
|
||||
"permissions": ["updater:default", "cli:default"]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ val tauriProperties = Properties().apply {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
namespace = "com.bilingify.readest"
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
val keystoreProperties = Properties()
|
||||
@@ -26,7 +26,7 @@ android {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
applicationId = "com.bilingify.readest"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
targetSdk = 36
|
||||
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
|
||||
links = "tauri-plugin-native-bridge"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.3.1" }
|
||||
tauri = { version = "2" }
|
||||
serde = "1.0"
|
||||
thiserror = "2"
|
||||
schemars = "0.8"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-plugin = { version = "2.0.4", features = ["build"] }
|
||||
tauri-plugin = { version = "2", features = ["build"] }
|
||||
schemars = "0.8"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.readest.native_bridge"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
|
||||
@@ -9,6 +9,10 @@ const COMMANDS: &[&str] = &[
|
||||
"get_sys_fonts_list",
|
||||
"intercept_keys",
|
||||
"lock_screen_orientation",
|
||||
"iap_initialize",
|
||||
"iap_fetch_products",
|
||||
"iap_purchase_product",
|
||||
"iap_restore_purchases",
|
||||
];
|
||||
|
||||
fn main() {
|
||||
|
||||
+111
@@ -3,6 +3,7 @@ import AuthenticationServices
|
||||
import CoreText
|
||||
import MediaPlayer
|
||||
import ObjectiveC
|
||||
import StoreKit
|
||||
import SwiftRs
|
||||
import Tauri
|
||||
import UIKit
|
||||
@@ -48,6 +49,37 @@ class LockScreenOrientationRequestArgs: Decodable {
|
||||
let orientation: String?
|
||||
}
|
||||
|
||||
struct InitializeRequest: Decodable {
|
||||
let publicKey: String?
|
||||
}
|
||||
|
||||
struct FetchProductsRequest: Decodable {
|
||||
let productIds: [String]
|
||||
}
|
||||
|
||||
struct PurchaseProductRequest: Decodable {
|
||||
let productId: String
|
||||
}
|
||||
|
||||
struct ProductData: Codable {
|
||||
let id: String
|
||||
let title: String
|
||||
let description: String
|
||||
let price: String
|
||||
let priceCurrencyCode: String?
|
||||
let priceAmountMicros: Int64
|
||||
let productType: String
|
||||
}
|
||||
|
||||
struct PurchaseData: Codable {
|
||||
let productId: String
|
||||
let transactionId: String
|
||||
let originalTransactionId: String
|
||||
let purchaseDate: String
|
||||
let purchaseState: String
|
||||
let platform: String
|
||||
}
|
||||
|
||||
class VolumeKeyHandler: NSObject {
|
||||
private var audioSession: AVAudioSession?
|
||||
private var originalVolume: Float = 0.0
|
||||
@@ -422,6 +454,85 @@ class NativeBridgePlugin: Plugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func iap_initialize(_ invoke: Invoke) {
|
||||
StoreKitManager.shared.initialize()
|
||||
invoke.resolve(["success": true])
|
||||
}
|
||||
|
||||
@objc public func iap_fetch_products(_ invoke: Invoke) {
|
||||
do {
|
||||
let args = try invoke.parseArgs(FetchProductsRequest.self)
|
||||
|
||||
StoreKitManager.shared.fetchProducts(productIds: args.productIds) { products in
|
||||
let productsData: [ProductData] = products.map { product in
|
||||
return ProductData(
|
||||
id: product.productIdentifier,
|
||||
title: product.localizedTitle,
|
||||
description: product.localizedDescription,
|
||||
price: product.price.stringValue,
|
||||
priceCurrencyCode: product.priceLocale.currencyCode,
|
||||
priceAmountMicros: Int64(product.price.doubleValue * 1_000_000),
|
||||
productType: product.productIdentifier.contains("monthly")
|
||||
|| product.productIdentifier.contains("yearly") ? "subscription" : "consumable"
|
||||
)
|
||||
}
|
||||
invoke.resolve(["products": productsData])
|
||||
}
|
||||
} catch {
|
||||
invoke.reject("Failed to parse fetch products arguments: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func iap_purchase_product(_ invoke: Invoke) {
|
||||
do {
|
||||
let args = try invoke.parseArgs(PurchaseProductRequest.self)
|
||||
|
||||
StoreKitManager.shared.fetchProducts(productIds: [args.productId]) { products in
|
||||
guard let product = products.first else {
|
||||
invoke.reject("Product not found")
|
||||
return
|
||||
}
|
||||
|
||||
StoreKitManager.shared.purchase(product: product) { result in
|
||||
switch result {
|
||||
case .success(let txn):
|
||||
let purchase = PurchaseData(
|
||||
productId: txn.payment.productIdentifier,
|
||||
transactionId: txn.transactionIdentifier ?? "",
|
||||
originalTransactionId: txn.original?.transactionIdentifier ?? txn
|
||||
.transactionIdentifier ?? "",
|
||||
purchaseDate: ISO8601DateFormatter().string(from: txn.transactionDate ?? Date()),
|
||||
purchaseState: "purchased",
|
||||
platform: "ios"
|
||||
)
|
||||
invoke.resolve(["purchase": purchase])
|
||||
case .failure(let error):
|
||||
invoke.reject("Purchase failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
invoke.reject("Failed to parse purchase arguments: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func iap_restore_purchases(_ invoke: Invoke) {
|
||||
StoreKitManager.shared.restorePurchases { transactions in
|
||||
let restored = transactions.map { txn -> PurchaseData in
|
||||
return PurchaseData(
|
||||
productId: txn.payment.productIdentifier,
|
||||
transactionId: txn.transactionIdentifier ?? "",
|
||||
originalTransactionId: txn.original?.transactionIdentifier ?? txn.transactionIdentifier
|
||||
?? "",
|
||||
purchaseDate: ISO8601DateFormatter().string(from: txn.transactionDate ?? Date()),
|
||||
purchaseState: "restored",
|
||||
platform: "ios"
|
||||
)
|
||||
}
|
||||
invoke.resolve(["purchases": restored])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_cdecl("init_plugin_native_bridge")
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import StoreKit
|
||||
import os
|
||||
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StoreKitManager")
|
||||
|
||||
class StoreKitManager: NSObject, SKProductsRequestDelegate, SKPaymentTransactionObserver {
|
||||
static let shared = StoreKitManager()
|
||||
|
||||
private var productsRequest: SKProductsRequest?
|
||||
private var productResponseHandler: (([SKProduct]) -> Void)?
|
||||
private var purchaseHandler: ((Result<SKPaymentTransaction, Error>) -> Void)?
|
||||
private var restoreHandler: (([SKPaymentTransaction]) -> Void)?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
func initialize() {
|
||||
SKPaymentQueue.default().add(self)
|
||||
}
|
||||
|
||||
deinit {
|
||||
SKPaymentQueue.default().remove(self)
|
||||
}
|
||||
|
||||
func fetchProducts(productIds: [String], completion: @escaping ([SKProduct]) -> Void) {
|
||||
let ids = Set(productIds)
|
||||
productsRequest = SKProductsRequest(productIdentifiers: ids)
|
||||
productsRequest?.delegate = self
|
||||
productResponseHandler = completion
|
||||
productsRequest?.start()
|
||||
}
|
||||
|
||||
func purchase(
|
||||
product: SKProduct, completion: @escaping (Result<SKPaymentTransaction, Error>) -> Void
|
||||
) {
|
||||
guard SKPaymentQueue.canMakePayments() else {
|
||||
completion(
|
||||
.failure(
|
||||
NSError(
|
||||
domain: "iap", code: 0, userInfo: [NSLocalizedDescriptionKey: "Purchases disabled."])))
|
||||
return
|
||||
}
|
||||
purchaseHandler = completion
|
||||
let payment = SKPayment(product: product)
|
||||
SKPaymentQueue.default().add(payment)
|
||||
}
|
||||
|
||||
func restorePurchases(completion: @escaping ([SKPaymentTransaction]) -> Void) {
|
||||
restoreHandler = completion
|
||||
SKPaymentQueue.default().restoreCompletedTransactions()
|
||||
}
|
||||
|
||||
// MARK: - SKProductsRequestDelegate
|
||||
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.productResponseHandler?(response.products)
|
||||
self?.productResponseHandler = nil
|
||||
}
|
||||
}
|
||||
|
||||
func request(_ request: SKRequest, didFailWithError error: Error) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
logger.error("Products request failed: \(error.localizedDescription)")
|
||||
self?.productResponseHandler?([])
|
||||
self?.productResponseHandler = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SKPaymentTransactionObserver
|
||||
func paymentQueue(
|
||||
_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]
|
||||
) {
|
||||
for transaction in transactions {
|
||||
switch transaction.transactionState {
|
||||
case .purchased:
|
||||
SKPaymentQueue.default().finishTransaction(transaction)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.purchaseHandler?(.success(transaction))
|
||||
self?.purchaseHandler = nil
|
||||
}
|
||||
case .failed:
|
||||
SKPaymentQueue.default().finishTransaction(transaction)
|
||||
let error =
|
||||
transaction.error
|
||||
?? NSError(
|
||||
domain: "iap", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unknown purchase error"]
|
||||
)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.purchaseHandler?(.failure(error))
|
||||
self?.purchaseHandler = nil
|
||||
}
|
||||
case .restored:
|
||||
SKPaymentQueue.default().finishTransaction(transaction)
|
||||
case .deferred, .purchasing:
|
||||
// Handle these states if needed
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
|
||||
let restored = queue.transactions.filter { $0.transactionState == .restored }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.restoreHandler?(restored)
|
||||
self?.restoreHandler = nil
|
||||
}
|
||||
}
|
||||
|
||||
func paymentQueue(
|
||||
_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error
|
||||
) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
logger.error("Restore purchases failed: \(error.localizedDescription)")
|
||||
self?.restoreHandler?([])
|
||||
self?.restoreHandler = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-iap-fetch-products"
|
||||
description = "Enables the iap_fetch_products command without any pre-configured scope."
|
||||
commands.allow = ["iap_fetch_products"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-iap-fetch-products"
|
||||
description = "Denies the iap_fetch_products command without any pre-configured scope."
|
||||
commands.deny = ["iap_fetch_products"]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-iap-initialize"
|
||||
description = "Enables the iap_initialize command without any pre-configured scope."
|
||||
commands.allow = ["iap_initialize"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-iap-initialize"
|
||||
description = "Denies the iap_initialize command without any pre-configured scope."
|
||||
commands.deny = ["iap_initialize"]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-iap-purchase-product"
|
||||
description = "Enables the iap_purchase_product command without any pre-configured scope."
|
||||
commands.allow = ["iap_purchase_product"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-iap-purchase-product"
|
||||
description = "Denies the iap_purchase_product command without any pre-configured scope."
|
||||
commands.deny = ["iap_purchase_product"]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-iap-restore-purchases"
|
||||
description = "Enables the iap_restore_purchases command without any pre-configured scope."
|
||||
commands.allow = ["iap_restore_purchases"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-iap-restore-purchases"
|
||||
description = "Denies the iap_restore_purchases command without any pre-configured scope."
|
||||
commands.deny = ["iap_restore_purchases"]
|
||||
+108
@@ -14,6 +14,10 @@ Default permissions for the plugin
|
||||
- `allow-get-sys-fonts-list`
|
||||
- `allow-intercept-keys`
|
||||
- `allow-lock-screen-orientation`
|
||||
- `allow-iap-initialize`
|
||||
- `allow-iap-fetch-products`
|
||||
- `allow-iap-purchase-product`
|
||||
- `allow-iap-restore-purchases`
|
||||
|
||||
## Permission Table
|
||||
|
||||
@@ -157,6 +161,110 @@ Denies the get_sys_fonts_list command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-iap-fetch-products`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the iap_fetch_products command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-iap-fetch-products`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the iap_fetch_products command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-iap-initialize`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the iap_initialize command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-iap-initialize`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the iap_initialize command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-iap-purchase-product`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the iap_purchase_product command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-iap-purchase-product`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the iap_purchase_product command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-iap-restore-purchases`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the iap_restore_purchases command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-iap-restore-purchases`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the iap_restore_purchases command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-install-package`
|
||||
|
||||
</td>
|
||||
|
||||
@@ -11,4 +11,8 @@ permissions = [
|
||||
"allow-get-sys-fonts-list",
|
||||
"allow-intercept-keys",
|
||||
"allow-lock-screen-orientation",
|
||||
"allow-iap-initialize",
|
||||
"allow-iap-fetch-products",
|
||||
"allow-iap-purchase-product",
|
||||
"allow-iap-restore-purchases",
|
||||
]
|
||||
|
||||
+50
-2
@@ -354,6 +354,54 @@
|
||||
"const": "deny-get-sys-fonts-list",
|
||||
"markdownDescription": "Denies the get_sys_fonts_list command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the iap_fetch_products command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-iap-fetch-products",
|
||||
"markdownDescription": "Enables the iap_fetch_products command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the iap_fetch_products command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-iap-fetch-products",
|
||||
"markdownDescription": "Denies the iap_fetch_products command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the iap_initialize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-iap-initialize",
|
||||
"markdownDescription": "Enables the iap_initialize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the iap_initialize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-iap-initialize",
|
||||
"markdownDescription": "Denies the iap_initialize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the iap_purchase_product command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-iap-purchase-product",
|
||||
"markdownDescription": "Enables the iap_purchase_product command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the iap_purchase_product command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-iap-purchase-product",
|
||||
"markdownDescription": "Denies the iap_purchase_product command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the iap_restore_purchases command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-iap-restore-purchases",
|
||||
"markdownDescription": "Enables the iap_restore_purchases command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the iap_restore_purchases command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-iap-restore-purchases",
|
||||
"markdownDescription": "Denies the iap_restore_purchases command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install_package command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -415,10 +463,10 @@
|
||||
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`",
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`",
|
||||
"type": "string",
|
||||
"const": "default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`"
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -81,3 +81,34 @@ pub(crate) async fn lock_screen_orientation<R: Runtime>(
|
||||
) -> Result<()> {
|
||||
app.native_bridge().lock_screen_orientation(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn iap_initialize<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: IAPInitializeRequest,
|
||||
) -> Result<IAPInitializeResponse> {
|
||||
app.native_bridge().iap_initialize(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn iap_fetch_products<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: IAPFetchProductsRequest,
|
||||
) -> Result<IAPFetchProductsResponse> {
|
||||
app.native_bridge().iap_fetch_products(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn iap_purchase_product<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: IAPPurchaseProductRequest,
|
||||
) -> Result<IAPPurchaseProductResponse> {
|
||||
app.native_bridge().iap_purchase_product(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn iap_restore_purchases<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
) -> Result<IAPRestorePurchasesResponse> {
|
||||
app.native_bridge().iap_restore_purchases()
|
||||
}
|
||||
|
||||
@@ -73,4 +73,29 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
) -> crate::Result<()> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
|
||||
pub fn iap_initialize(
|
||||
&self,
|
||||
_payload: IAPInitializeRequest,
|
||||
) -> crate::Result<IAPInitializeResponse> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
|
||||
pub fn iap_fetch_products(
|
||||
&self,
|
||||
_payload: IAPFetchProductsRequest,
|
||||
) -> crate::Result<IAPFetchProductsResponse> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
|
||||
pub fn iap_purchase_product(
|
||||
&self,
|
||||
_payload: IAPPurchaseProductRequest,
|
||||
) -> crate::Result<IAPPurchaseProductResponse> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
|
||||
pub fn iap_restore_purchases(&self) -> crate::Result<IAPRestorePurchasesResponse> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,10 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
commands::get_sys_fonts_list,
|
||||
commands::intercept_keys,
|
||||
commands::lock_screen_orientation,
|
||||
commands::iap_initialize,
|
||||
commands::iap_fetch_products,
|
||||
commands::iap_purchase_product,
|
||||
commands::iap_restore_purchases,
|
||||
])
|
||||
.setup(|app, api| {
|
||||
#[cfg(mobile)]
|
||||
|
||||
@@ -112,3 +112,46 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn iap_initialize(
|
||||
&self,
|
||||
payload: IAPInitializeRequest,
|
||||
) -> crate::Result<IAPInitializeResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("iap_initialize", payload)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn iap_fetch_products(
|
||||
&self,
|
||||
payload: IAPFetchProductsRequest,
|
||||
) -> crate::Result<IAPFetchProductsResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("iap_fetch_products", payload)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn iap_purchase_product(
|
||||
&self,
|
||||
payload: IAPPurchaseProductRequest,
|
||||
) -> crate::Result<IAPPurchaseProductResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("iap_purchase_product", payload)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn iap_restore_purchases(
|
||||
&self,
|
||||
) -> crate::Result<IAPRestorePurchasesResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("iap_restore_purchases", ())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,3 +86,69 @@ pub struct InterceptKeysRequest {
|
||||
pub struct LockScreenOrientationRequest {
|
||||
pub orientation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Product {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub price: String,
|
||||
pub price_currency_code: Option<String>,
|
||||
pub price_amount_micros: i64,
|
||||
pub product_type: String, // "consumable", "non_consumable", or "subscription"
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Purchase {
|
||||
pub product_id: String,
|
||||
pub transaction_id: String,
|
||||
pub purchase_date: String,
|
||||
pub original_transaction_id: String,
|
||||
pub purchase_state: String, // "purchased", "pending", "cancelled"
|
||||
pub platform: String, // "ios" or "android"
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IAPInitializeRequest {
|
||||
pub public_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IAPInitializeResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IAPFetchProductsRequest {
|
||||
pub product_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IAPFetchProductsResponse {
|
||||
pub products: Vec<Product>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IAPPurchaseProductRequest {
|
||||
pub product_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IAPPurchaseProductResponse {
|
||||
pub purchase: Option<Purchase>,
|
||||
pub cancelled_purchase: Option<Purchase>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IAPRestorePurchasesResponse {
|
||||
pub purchases: Vec<Purchase>,
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
|
||||
links = "tauri-plugin-native-tts"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.5.0" }
|
||||
tauri = { version = "2" }
|
||||
serde = "1.0"
|
||||
thiserror = "2"
|
||||
schemars = "0.8"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-plugin = { version = "2.2.0", features = ["build"] }
|
||||
tauri-plugin = { version = "2", features = ["build"] }
|
||||
schemars = "0.8"
|
||||
|
||||
@@ -5,7 +5,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.readest.native_tts"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
|
||||
@@ -105,6 +105,11 @@ async fn start_server(window: Window) -> Result<u16, String> {
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_environment_variable(name: &str) -> String {
|
||||
std::env::var(String::from(name)).unwrap_or(String::from(""))
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[allow(dead_code)]
|
||||
struct Payload {
|
||||
@@ -121,6 +126,7 @@ pub fn run() {
|
||||
start_server,
|
||||
download_file,
|
||||
upload_file,
|
||||
get_environment_variable,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos::safari_auth::auth_with_safari,
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -220,28 +226,10 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
{
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
app.deep_link().register_all()?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
fn has_xdg_mime() -> bool {
|
||||
std::process::Command::new("which")
|
||||
.arg("xdg-mime")
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
if has_xdg_mime() {
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
app.deep_link().register_all()?;
|
||||
} else {
|
||||
println!("xdg-mime not found; skipping deep link setup on Linux.");
|
||||
}
|
||||
let _ = app.deep_link().register_all();
|
||||
}
|
||||
|
||||
if let Err(e) = app.handle().plugin(
|
||||
@@ -265,25 +253,22 @@ pub fn run() {
|
||||
|
||||
#[cfg(all(not(target_os = "macos"), desktop))]
|
||||
let win_builder = {
|
||||
let mut win_builder = win_builder
|
||||
let mut builder = win_builder
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.visible(false)
|
||||
.shadow(true)
|
||||
.title("Readest");
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
if tauri_plugin_os::version()
|
||||
<= tauri_plugin_os::Version::from_string("10.0.19045")
|
||||
{
|
||||
win_builder = win_builder.shadow(false);
|
||||
} else {
|
||||
win_builder = win_builder.shadow(true);
|
||||
}
|
||||
} else {
|
||||
win_builder = win_builder.shadow(true);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
builder = builder.transparent(false);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
builder = builder.transparent(true);
|
||||
}
|
||||
|
||||
win_builder
|
||||
builder
|
||||
};
|
||||
|
||||
win_builder.build().unwrap();
|
||||
|
||||
@@ -144,7 +144,8 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
|
||||
.expect("NS window should exist on state to handle resize")
|
||||
as id;
|
||||
|
||||
if state.window.label() == "main" {
|
||||
if state.window.label() == "main" || state.window.label().starts_with("reader")
|
||||
{
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(id as *mut std::ffi::c_void),
|
||||
TRAFFIC_LIGHTS_VISIBLE,
|
||||
@@ -278,7 +279,8 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
|
||||
.expect("Failed to emit event");
|
||||
|
||||
let id = state.window.ns_window().expect("Failed to emit event") as id;
|
||||
if state.window.label() == "main" {
|
||||
if state.window.label() == "main" || state.window.label().starts_with("reader")
|
||||
{
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(id as *mut std::ffi::c_void),
|
||||
TRAFFIC_LIGHTS_VISIBLE,
|
||||
@@ -363,26 +365,26 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
|
||||
app_box: *mut c_void = app_box,
|
||||
toolbar: id = cocoa::base::nil,
|
||||
super_delegate: id = current_delegate,
|
||||
(windowShouldClose:) => on_window_should_close as extern fn(&Object, Sel, id) -> BOOL,
|
||||
(windowWillClose:) => on_window_will_close as extern fn(&Object, Sel, id),
|
||||
(windowDidResize:) => on_window_did_resize::<R> as extern fn(&Object, Sel, id),
|
||||
(windowDidMove:) => on_window_did_move as extern fn(&Object, Sel, id),
|
||||
(windowDidChangeBackingProperties:) => on_window_did_change_backing_properties as extern fn(&Object, Sel, id),
|
||||
(windowDidBecomeKey:) => on_window_did_become_key as extern fn(&Object, Sel, id),
|
||||
(windowDidResignKey:) => on_window_did_resign_key as extern fn(&Object, Sel, id),
|
||||
(draggingEntered:) => on_dragging_entered as extern fn(&Object, Sel, id) -> BOOL,
|
||||
(prepareForDragOperation:) => on_prepare_for_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
|
||||
(performDragOperation:) => on_perform_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
|
||||
(concludeDragOperation:) => on_conclude_drag_operation as extern fn(&Object, Sel, id),
|
||||
(draggingExited:) => on_dragging_exited as extern fn(&Object, Sel, id),
|
||||
(window:willUseFullScreenPresentationOptions:) => on_window_will_use_full_screen_presentation_options as extern fn(&Object, Sel, id, NSUInteger) -> NSUInteger,
|
||||
(windowDidEnterFullScreen:) => on_window_did_enter_full_screen::<R> as extern fn(&Object, Sel, id),
|
||||
(windowWillEnterFullScreen:) => on_window_will_enter_full_screen::<R> as extern fn(&Object, Sel, id),
|
||||
(windowDidExitFullScreen:) => on_window_did_exit_full_screen::<R> as extern fn(&Object, Sel, id),
|
||||
(windowWillExitFullScreen:) => on_window_will_exit_full_screen::<R> as extern fn(&Object, Sel, id),
|
||||
(windowDidFailToEnterFullScreen:) => on_window_did_fail_to_enter_full_screen as extern fn(&Object, Sel, id),
|
||||
(effectiveAppearanceDidChange:) => on_effective_appearance_did_change as extern fn(&Object, Sel, id),
|
||||
(effectiveAppearanceDidChangedOnMainThread:) => on_effective_appearance_did_changed_on_main_thread as extern fn(&Object, Sel, id)
|
||||
(windowShouldClose:) => on_window_should_close as extern "C" fn(&Object, Sel, id) -> BOOL,
|
||||
(windowWillClose:) => on_window_will_close as extern "C" fn(&Object, Sel, id),
|
||||
(windowDidResize:) => on_window_did_resize::<R> as extern "C" fn(&Object, Sel, id),
|
||||
(windowDidMove:) => on_window_did_move as extern "C" fn(&Object, Sel, id),
|
||||
(windowDidChangeBackingProperties:) => on_window_did_change_backing_properties as extern "C" fn(&Object, Sel, id),
|
||||
(windowDidBecomeKey:) => on_window_did_become_key as extern "C" fn(&Object, Sel, id),
|
||||
(windowDidResignKey:) => on_window_did_resign_key as extern "C" fn(&Object, Sel, id),
|
||||
(draggingEntered:) => on_dragging_entered as extern "C" fn(&Object, Sel, id) -> BOOL,
|
||||
(prepareForDragOperation:) => on_prepare_for_drag_operation as extern "C" fn(&Object, Sel, id) -> BOOL,
|
||||
(performDragOperation:) => on_perform_drag_operation as extern "C" fn(&Object, Sel, id) -> BOOL,
|
||||
(concludeDragOperation:) => on_conclude_drag_operation as extern "C" fn(&Object, Sel, id),
|
||||
(draggingExited:) => on_dragging_exited as extern "C" fn(&Object, Sel, id),
|
||||
(window:willUseFullScreenPresentationOptions:) => on_window_will_use_full_screen_presentation_options as extern "C" fn(&Object, Sel, id, NSUInteger) -> NSUInteger,
|
||||
(windowDidEnterFullScreen:) => on_window_did_enter_full_screen::<R> as extern "C" fn(&Object, Sel, id),
|
||||
(windowWillEnterFullScreen:) => on_window_will_enter_full_screen::<R> as extern "C" fn(&Object, Sel, id),
|
||||
(windowDidExitFullScreen:) => on_window_did_exit_full_screen::<R> as extern "C" fn(&Object, Sel, id),
|
||||
(windowWillExitFullScreen:) => on_window_will_exit_full_screen::<R> as extern "C" fn(&Object, Sel, id),
|
||||
(windowDidFailToEnterFullScreen:) => on_window_did_fail_to_enter_full_screen as extern "C" fn(&Object, Sel, id),
|
||||
(effectiveAppearanceDidChange:) => on_effective_appearance_did_change as extern "C" fn(&Object, Sel, id),
|
||||
(effectiveAppearanceDidChangedOnMainThread:) => on_effective_appearance_did_changed_on_main_thread as extern "C" fn(&Object, Sel, id)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ use tokio_util::codec::{BytesCodec, FramedRead};
|
||||
|
||||
use read_progress_stream::ReadProgressStream;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
@@ -109,41 +109,129 @@ pub async fn download_file(
|
||||
body: Option<String>,
|
||||
on_progress: Channel<ProgressPayload>,
|
||||
) -> Result<()> {
|
||||
use futures::stream::{self, StreamExt};
|
||||
use std::cmp::min;
|
||||
use tokio::io::AsyncSeekExt;
|
||||
|
||||
const PART_SIZE: u64 = 1024 * 1024;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = if let Some(body) = body {
|
||||
client.post(url).body(body)
|
||||
} else {
|
||||
client.get(url)
|
||||
};
|
||||
// Loop trought the headers keys and values
|
||||
// and add them to the request object.
|
||||
for (key, value) in headers {
|
||||
request = request.header(&key, value);
|
||||
|
||||
// Check if server supports range requests
|
||||
let range_resp = client.get(url).header("Range", "bytes=0-0").send().await?;
|
||||
let accept_ranges = range_resp
|
||||
.headers()
|
||||
.get("accept-ranges")
|
||||
.map(|v| v.to_str().unwrap_or(""))
|
||||
.unwrap_or("")
|
||||
.eq_ignore_ascii_case("bytes");
|
||||
let total = range_resp
|
||||
.headers()
|
||||
.get("content-range")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split('/').nth(1))
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
if !accept_ranges || total == 0 {
|
||||
// Fallback to original single-threaded logic
|
||||
let mut request = if let Some(body) = body {
|
||||
client.post(url).body(body)
|
||||
} else {
|
||||
client.get(url)
|
||||
};
|
||||
|
||||
for (key, value) in headers.iter() {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::HttpErrorCode(
|
||||
response.status().as_u16(),
|
||||
response.text().await.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
|
||||
let total = response.content_length().unwrap_or(0);
|
||||
let mut file = BufWriter::new(File::create(file_path).await?);
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
let mut stats = TransferStats::default();
|
||||
while let Some(chunk) = stream.try_next().await? {
|
||||
file.write_all(&chunk).await?;
|
||||
stats.record_chunk_transfer(chunk.len());
|
||||
let _ = on_progress.send(ProgressPayload {
|
||||
progress: stats.total_transferred,
|
||||
total,
|
||||
transfer_speed: stats.transfer_speed,
|
||||
});
|
||||
}
|
||||
file.flush().await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::HttpErrorCode(
|
||||
response.status().as_u16(),
|
||||
response.text().await.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
let total = response.content_length().unwrap_or(0);
|
||||
// Multi-part download with range access
|
||||
let part_count = total.div_ceil(PART_SIZE);
|
||||
let file = File::create(file_path).await?;
|
||||
file.set_len(total).await?;
|
||||
|
||||
let mut file = BufWriter::new(File::create(file_path).await?);
|
||||
let mut stream = response.bytes_stream();
|
||||
let file = Arc::new(tokio::sync::Mutex::new(file));
|
||||
let progress = Arc::new(tokio::sync::Mutex::new(TransferStats::default()));
|
||||
|
||||
let mut stats = TransferStats::default();
|
||||
while let Some(chunk) = stream.try_next().await? {
|
||||
file.write_all(&chunk).await?;
|
||||
stats.record_chunk_transfer(chunk.len());
|
||||
let _ = on_progress.send(ProgressPayload {
|
||||
progress: stats.total_transferred,
|
||||
total,
|
||||
transfer_speed: stats.transfer_speed,
|
||||
});
|
||||
}
|
||||
file.flush().await?;
|
||||
stream::iter(0..part_count)
|
||||
.for_each_concurrent(8, |i| {
|
||||
let client = client.clone();
|
||||
let file = Arc::clone(&file);
|
||||
let progress = Arc::clone(&progress);
|
||||
let headers = headers.clone();
|
||||
let url = url.to_string();
|
||||
let on_progress = on_progress.clone();
|
||||
|
||||
async move {
|
||||
let start = i * PART_SIZE;
|
||||
let end = min(start + PART_SIZE - 1, total - 1);
|
||||
let range_header = format!("bytes={start}-{end}");
|
||||
|
||||
let mut req = client.get(&url).header("Range", range_header);
|
||||
for (key, value) in headers {
|
||||
req = req.header(key, value);
|
||||
}
|
||||
|
||||
let resp = match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if !resp.status().is_success()
|
||||
&& resp.status() != reqwest::StatusCode::PARTIAL_CONTENT
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let bytes = match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
{
|
||||
let mut f = file.lock().await;
|
||||
f.seek(std::io::SeekFrom::Start(start)).await.unwrap();
|
||||
f.write_all(&bytes).await.unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
let mut stat = progress.lock().await;
|
||||
stat.record_chunk_transfer(bytes.len());
|
||||
let _ = on_progress.send(ProgressPayload {
|
||||
progress: stat.total_transferred,
|
||||
total,
|
||||
transfer_speed: stat.transfer_speed,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
"security": {
|
||||
"csp": {
|
||||
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
|
||||
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://*.microsofttranslator.com https://edge.microsoft.com https://*.googleusercontent.com",
|
||||
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://translate.toil.cc https://*.microsofttranslator.com https://edge.microsoft.com https://*.googleusercontent.com",
|
||||
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://ik.imagekit.io",
|
||||
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://ik.imagekit.io",
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://chinese-fonts-cdn.netlify.app",
|
||||
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://chinese-fonts-cdn.netlify.app",
|
||||
"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"
|
||||
},
|
||||
@@ -28,6 +28,7 @@
|
||||
"allow": [
|
||||
"$RESOURCE/**",
|
||||
"$APPDATA/**/*",
|
||||
"$APPCACHE/**/*",
|
||||
"$TEMP/**/*",
|
||||
"/private/var/mobile/Containers/Data/Application/**/*"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { POST } from '@/app/api/apple/iap-verify/route';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { setupSupabaseMocks } from '../helpers/supabase-mock';
|
||||
|
||||
const SKIP_IAP_API_TESTS = !process.env['ENABLE_IAP_API_TESTS'];
|
||||
vi.mock('@/utils/supabase', () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getUser: vi.fn(),
|
||||
refreshSession: vi.fn(),
|
||||
},
|
||||
from: vi.fn(),
|
||||
},
|
||||
createSupabaseAdminClient: vi.fn(),
|
||||
}));
|
||||
|
||||
describe.skipIf(SKIP_IAP_API_TESTS)('/api/apple/iap-verify', () => {
|
||||
it('should verify a valid transaction', async () => {
|
||||
setupSupabaseMocks();
|
||||
const request = new NextRequest('http://localhost:3000/api/apple/iap-verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer test-token',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
transactionId: '2000000969168810',
|
||||
originalTransactionId: '2000000968585424',
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
console.log('Response:', data);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.purchase).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export const setupSupabaseMocks = async (
|
||||
customResponses = {
|
||||
getUser: null,
|
||||
select: null,
|
||||
upsert: null,
|
||||
update: null,
|
||||
insert: null,
|
||||
adminUpsert: null,
|
||||
adminSelect: {
|
||||
data: [],
|
||||
error: null,
|
||||
},
|
||||
adminSelectMany: null,
|
||||
adminUpdate: null,
|
||||
adminInsert: null,
|
||||
adminDelete: null,
|
||||
adminSelectSingle: null,
|
||||
},
|
||||
) => {
|
||||
const { supabase, createSupabaseAdminClient } = await import('@/utils/supabase');
|
||||
|
||||
vi.mocked(supabase.auth.getUser).mockResolvedValue(
|
||||
customResponses.getUser || {
|
||||
data: {
|
||||
user: {
|
||||
id: 'test-user-123',
|
||||
email: 'test@example.com',
|
||||
app_metadata: {},
|
||||
user_metadata: {},
|
||||
aud: 'test-aud',
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
);
|
||||
|
||||
vi.mocked(supabase.from).mockReturnValue({
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
single: vi.fn().mockResolvedValue(customResponses.select || { data: null, error: null }),
|
||||
})),
|
||||
})),
|
||||
upsert: vi.fn().mockResolvedValue(customResponses.upsert || { data: {}, error: null }),
|
||||
update: vi.fn(() => ({
|
||||
eq: vi.fn().mockResolvedValue(customResponses.update || { data: {}, error: null }),
|
||||
})),
|
||||
insert: vi.fn().mockResolvedValue(customResponses.insert || { data: {}, error: null }),
|
||||
} as any);
|
||||
|
||||
vi.mocked(createSupabaseAdminClient).mockReturnValue({
|
||||
from: vi.fn(() => ({
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
single: vi.fn().mockResolvedValue({ data: null, error: null }),
|
||||
order: vi.fn().mockResolvedValue({ data: [], error: null }),
|
||||
limit: vi.fn().mockResolvedValue({ data: [], error: null }),
|
||||
})),
|
||||
single: vi
|
||||
.fn()
|
||||
.mockResolvedValue(customResponses.adminSelect || { data: null, error: null }),
|
||||
limit: vi.fn(() => ({
|
||||
single: vi
|
||||
.fn()
|
||||
.mockResolvedValue(customResponses.adminSelect || { data: null, error: null }),
|
||||
})),
|
||||
order: vi
|
||||
.fn()
|
||||
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
|
||||
})),
|
||||
neq: vi.fn(() => ({
|
||||
order: vi
|
||||
.fn()
|
||||
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
|
||||
})),
|
||||
gt: vi.fn(() => ({
|
||||
order: vi
|
||||
.fn()
|
||||
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
|
||||
})),
|
||||
in: vi.fn(() => ({
|
||||
order: vi
|
||||
.fn()
|
||||
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
|
||||
})),
|
||||
order: vi
|
||||
.fn()
|
||||
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
|
||||
limit: vi
|
||||
.fn()
|
||||
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
|
||||
})),
|
||||
upsert: vi.fn().mockResolvedValue(customResponses.adminUpsert || { data: [], error: null }),
|
||||
update: vi.fn(() => ({
|
||||
eq: vi.fn().mockResolvedValue(customResponses.adminUpdate || { data: {}, error: null }),
|
||||
match: vi.fn().mockResolvedValue(customResponses.adminUpdate || { data: {}, error: null }),
|
||||
})),
|
||||
insert: vi.fn().mockResolvedValue(customResponses.adminInsert || { data: {}, error: null }),
|
||||
delete: vi.fn(() => ({
|
||||
eq: vi.fn().mockResolvedValue(customResponses.adminDelete || { data: {}, error: null }),
|
||||
match: vi.fn().mockResolvedValue(customResponses.adminDelete || { data: {}, error: null }),
|
||||
})),
|
||||
})),
|
||||
} as any);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { AppleIAPVerifier, createAppleIAPVerifier } from '@/libs/iap/apple/verifier';
|
||||
|
||||
const SKIP_IAP_API_TESTS = !process.env['ENABLE_IAP_API_TESTS'];
|
||||
const REAL_TEST_DATA = {
|
||||
validSubscription: {
|
||||
transactionId: '2000000969168810',
|
||||
originalTransactionId: '2000000968585424',
|
||||
productId: 'com.bilingify.readest.monthly.plus',
|
||||
},
|
||||
|
||||
expiredSubscription: {
|
||||
transactionId: '2000000969189989',
|
||||
originalTransactionId: '2000000969189989',
|
||||
productId: 'com.bilingify.readest.monthly.plus',
|
||||
},
|
||||
|
||||
refundedTransaction: {
|
||||
transactionId: '1000000555666777',
|
||||
originalTransactionId: '1000000555666777',
|
||||
productId: 'com.bilingify.readest.monthly.plus',
|
||||
},
|
||||
};
|
||||
|
||||
describe.skipIf(SKIP_IAP_API_TESTS)('Apple IAP Integration Tests', () => {
|
||||
let verifier: AppleIAPVerifier;
|
||||
|
||||
beforeAll(() => {
|
||||
verifier = createAppleIAPVerifier({
|
||||
keyId: process.env['APPLE_IAP_KEY_ID']!,
|
||||
issuerId: process.env['APPLE_IAP_ISSUER_ID']!,
|
||||
bundleId: process.env['APPLE_IAP_BUNDLE_ID']!,
|
||||
privateKey: atob(process.env['APPLE_IAP_PRIVATE_KEY_BASE64']!),
|
||||
environment: 'sandbox',
|
||||
});
|
||||
});
|
||||
|
||||
describe('Transaction Verification', () => {
|
||||
it('should verify a real valid transaction', async () => {
|
||||
const { transactionId, originalTransactionId } = REAL_TEST_DATA.validSubscription;
|
||||
|
||||
const result = await verifier.verifyTransaction(originalTransactionId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.status).toBe('active');
|
||||
expect(result.transactionId).toBe(transactionId);
|
||||
expect(result.originalTransactionId).toBe(originalTransactionId);
|
||||
expect(result.bundleId).toBe(process.env['APPLE_IAP_BUNDLE_ID']);
|
||||
expect(result.productId).toBeTruthy();
|
||||
expect(result.purchaseDate).toBeInstanceOf(Date);
|
||||
}, 10000);
|
||||
|
||||
it('should handle expired subscription correctly', async () => {
|
||||
const { originalTransactionId } = REAL_TEST_DATA.expiredSubscription;
|
||||
|
||||
const result = await verifier.verifyTransaction(originalTransactionId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.status).toBe('expired');
|
||||
}, 10000);
|
||||
|
||||
it('should reject invalid transaction IDs', async () => {
|
||||
const result = await verifier.verifyTransaction('invalid_transaction_id');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeTruthy();
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { detectLanguage } from '@/utils/lang';
|
||||
|
||||
describe('detectLanguage - Result Tests', () => {
|
||||
describe('English text detection', () => {
|
||||
it('should detect English text', () => {
|
||||
const englishTexts = [
|
||||
'This is a sample English text for language detection testing.',
|
||||
'The quick brown fox jumps over the lazy dog.',
|
||||
'Hello world! This is an English sentence with punctuation.',
|
||||
'Chapter 1: Introduction to Language Detection',
|
||||
'In the beginning was the Word, and the Word was with God.',
|
||||
'To be or not to be, that is the question.',
|
||||
];
|
||||
|
||||
englishTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('en');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chinese text detection', () => {
|
||||
it('should detect Chinese text', () => {
|
||||
const chineseTexts = [
|
||||
'这是一个中文文本的示例,用于语言检测测试。',
|
||||
'天下大势,分久必合,合久必分。',
|
||||
'学而时习之,不亦说乎?有朋自远方来,不亦乐乎?',
|
||||
'春眠不觉晓,处处闻啼鸟。夜来风雨声,花落知多少。',
|
||||
'那时候,天空很蓝,云彩很白,我们都还很年轻。',
|
||||
'人工智能技术的发展正在改变我们的生活方式和工作方式。',
|
||||
];
|
||||
|
||||
chineseTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('zh');
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect Traditional Chinese text', () => {
|
||||
const traditionalChineseTexts = [
|
||||
'這是一個正體中文文本的示例,用於語言檢測測試。',
|
||||
'天下大勢,分久必合,合久必分。',
|
||||
'學而時習之,不亦說乎?有朋自遠方來,不亦樂乎?',
|
||||
'春眠不覺曉,處處聞啼鳥。夜來風雨聲,花落知多少。',
|
||||
'那時候,天空很藍,雲彩很白,我們都還很年輕。',
|
||||
'人工智慧技術的發展正在改變我們的生活方式和工作方式。',
|
||||
];
|
||||
|
||||
traditionalChineseTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('zh');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Japanese text detection', () => {
|
||||
it('should detect Japanese text', () => {
|
||||
const japaneseTexts = [
|
||||
'これは日本語のテキストサンプルです。',
|
||||
'第一章:言語検出について',
|
||||
'こんにちは、世界!これは日本語の文章です。',
|
||||
'桜の花が咲く季節になりました。とても美しいです。',
|
||||
'ひらがな、カタカナ、漢字を使って日本語を書きます。',
|
||||
'東京は日本の首都であり、多くの人々が住んでいます。',
|
||||
];
|
||||
|
||||
japaneseTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('ja');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('French text detection', () => {
|
||||
it('should detect French text', () => {
|
||||
const frenchTexts = [
|
||||
'Ceci est un exemple de texte français pour tester la détection de langue.',
|
||||
'Chapitre 1: Introduction à la détection de langue.',
|
||||
'Bonjour le monde! Ceci est une phrase en français.',
|
||||
'Les Champs-Élysées sont une avenue célèbre à Paris, souvent appelée la plus belle avenue du monde.',
|
||||
'Victor Hugo était un écrivain français très célèbre du XIXe siècle,',
|
||||
"La littérature française possède une richesse extraordinaire qui s'étend sur plusieurs siècles.",
|
||||
];
|
||||
|
||||
frenchTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('fr');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spanish text detection', () => {
|
||||
it('should detect Spanish text', () => {
|
||||
const spanishTexts = [
|
||||
'Este es un ejemplo de texto en español para probar la detección de idioma.',
|
||||
'Capítulo 1: Introducción a la detección de idiomas',
|
||||
'¡Hola mundo! Esta es una oración en español.',
|
||||
'España es un país ubicado en la península ibérica.',
|
||||
'El flamenco es una forma de arte español muy famosa.',
|
||||
'Don Quijote de La Mancha es una obra clásica de la literatura española.',
|
||||
];
|
||||
|
||||
spanishTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('es');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('German text detection', () => {
|
||||
it('should detect German text', () => {
|
||||
const germanTexts = [
|
||||
'Dies ist ein Beispieltext auf Deutsch für die Spracherkennung.',
|
||||
'Kapitel 1: Einführung in die Spracherkennung',
|
||||
'Hallo Welt! Dies ist ein deutscher Satz.',
|
||||
'Deutschland ist ein Land in Mitteleuropa.',
|
||||
'Das Oktoberfest ist ein berühmtes deutsches Festival.',
|
||||
'Die deutsche Sprache hat viele zusammengesetzte Wörter.',
|
||||
];
|
||||
|
||||
germanTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('de');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Arabic text detection', () => {
|
||||
it('should detect Arabic text', () => {
|
||||
const arabicTexts = [
|
||||
'هذا نص عربي للاختبار. يجب أن يتم اكتشاف هذا النص كعربي.',
|
||||
'الفصل الأول: مقدمة في اكتشاف اللغة',
|
||||
'مرحبا بالعالم! هذه جملة باللغة العربية.',
|
||||
'العربية لغة جميلة يتحدث بها ملايين الناس.',
|
||||
'القرآن الكريم نزل باللغة العربية.',
|
||||
'دمشق هي عاصمة سوريا وواحدة من أقدم المدن في العالم.',
|
||||
];
|
||||
|
||||
arabicTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('ar');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Korean text detection', () => {
|
||||
it('should detect Korean text', () => {
|
||||
const koreanTexts = [
|
||||
'이것은 언어 감지 테스트를 위한 한국어 텍스트 샘플입니다.',
|
||||
'제1장: 언어 감지 소개',
|
||||
'안녕하세요 세계! 이것은 한국어 문장입니다.',
|
||||
'서울은 대한민국의 수도이며 많은 사람들이 살고 있습니다.',
|
||||
'한글은 세종대왕이 만든 우리나라의 고유한 문자입니다.',
|
||||
'김치는 한국의 전통 음식 중 하나입니다.',
|
||||
];
|
||||
|
||||
koreanTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('ko');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback behavior', () => {
|
||||
it('should return "en" for very short text', () => {
|
||||
const shortTexts = ['', ' ', 'Hi', 'OK', '123', '!@#'];
|
||||
|
||||
shortTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('en');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return "en" for unrecognizable text', () => {
|
||||
const unrecognizableTexts = [
|
||||
'123456789012345678901234567890',
|
||||
'!@#$%^&*()_+{}|:"<>?[]\\;\',./',
|
||||
'1111111111222222222233333333334444444444',
|
||||
];
|
||||
|
||||
unrecognizableTexts.forEach((text) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe('en');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed content', () => {
|
||||
it('should detect predominant language in mixed content', () => {
|
||||
const testCases = [
|
||||
{
|
||||
text: 'This is mostly English text with some 中文 words mixed in but English dominates.',
|
||||
expected: 'en',
|
||||
},
|
||||
{
|
||||
text: '这主要是中文文本,虽然有一些 English words 混合在其中,但中文占主导地位。',
|
||||
expected: 'zh',
|
||||
},
|
||||
{
|
||||
text: 'Ceci est principalement du français avec quelques English words mélangés.',
|
||||
expected: 'fr',
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ text, expected }) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('long text handling', () => {
|
||||
it('should handle long text (over 1000 characters)', () => {
|
||||
const longEnglishText = 'This is a very long English text. '.repeat(100); // ~3400 chars
|
||||
const longChineseText = '这是一个很长的中文文本。'.repeat(100); // ~1200 chars
|
||||
const longFrenchText = 'Ceci est un très long texte français. '.repeat(100); // ~3800 chars
|
||||
|
||||
expect(detectLanguage(longEnglishText)).toBe('en');
|
||||
expect(detectLanguage(longChineseText)).toBe('zh');
|
||||
expect(detectLanguage(longFrenchText)).toBe('fr');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases with whitespace and formatting', () => {
|
||||
it('should handle text with lots of whitespace', () => {
|
||||
const textWithWhitespace = ' This is English text with lots of spaces ';
|
||||
const result = detectLanguage(textWithWhitespace);
|
||||
expect(result).toBe('en');
|
||||
});
|
||||
|
||||
it('should handle text with newlines and tabs', () => {
|
||||
const textWithFormatting = 'This is English text\nwith newlines\tand tabs\nfor formatting.';
|
||||
const result = detectLanguage(textWithFormatting);
|
||||
expect(result).toBe('en');
|
||||
});
|
||||
|
||||
it('should handle text with punctuation', () => {
|
||||
const textWithPunctuation = 'Hello, world! How are you? I am fine. Thank you very much!!!';
|
||||
const result = detectLanguage(textWithPunctuation);
|
||||
expect(result).toBe('en');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectLanguage - Real World Samples', () => {
|
||||
it('should detect language in book excerpts', () => {
|
||||
const bookExcerpts = [
|
||||
{
|
||||
text: 'It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness.',
|
||||
expected: 'en',
|
||||
},
|
||||
{
|
||||
text: 'Dans une trou dans la terre vivait un hobbit. Pas un trou déplaisant, sale et humide.',
|
||||
expected: 'fr',
|
||||
},
|
||||
{
|
||||
text: '昔々、あるところにおじいさんとおばあさんが住んでいました。',
|
||||
expected: 'ja',
|
||||
},
|
||||
{
|
||||
text: '很久很久以前,有一个美丽的公主住在一座城堡里。',
|
||||
expected: 'zh',
|
||||
},
|
||||
];
|
||||
|
||||
bookExcerpts.forEach(({ text, expected }) => {
|
||||
const result = detectLanguage(text);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { z } from 'zod';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAppleIAPVerifier } from '@/libs/iap/apple/verifier';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
import { validateUserAndToken } from '@/utils/access';
|
||||
import { IAPError } from '@/types/error';
|
||||
|
||||
const iapVerificationSchema = z.object({
|
||||
transactionId: z.string().min(1, 'Transaction ID is required'),
|
||||
originalTransactionId: z.string().min(1, 'Original Transaction ID is required'),
|
||||
});
|
||||
|
||||
const PRODUCT_MAP: Record<string, string> = {
|
||||
'com.bilingify.readest.monthly.plus': 'Plus',
|
||||
'com.bilingify.readest.yearly.plus': 'Plus',
|
||||
'com.bilingify.readest.monthly.pro': 'Pro',
|
||||
'com.bilingify.readest.yearly.pro': 'Pro',
|
||||
};
|
||||
|
||||
const getProductName = (productId: string) => {
|
||||
return PRODUCT_MAP[productId] || productId;
|
||||
};
|
||||
|
||||
const getProductPlan = (productId: string) => {
|
||||
if (productId.includes('plus')) {
|
||||
return 'plus';
|
||||
} else if (productId.includes('pro')) {
|
||||
return 'pro';
|
||||
}
|
||||
return 'free';
|
||||
};
|
||||
|
||||
interface Purchase {
|
||||
status: string;
|
||||
customerEmail: string;
|
||||
subscriptionId: string;
|
||||
planName: string;
|
||||
productId: string;
|
||||
platform: string;
|
||||
transactionId: string;
|
||||
originalTransactionId: string;
|
||||
purchaseDate?: string;
|
||||
expiresDate?: string | null;
|
||||
quantity: number;
|
||||
environment: string;
|
||||
bundleId: string;
|
||||
webOrderLineItemId?: string;
|
||||
subscriptionGroupIdentifier?: string;
|
||||
type?: string;
|
||||
revocationDate?: string | null;
|
||||
revocationReason?: number | null;
|
||||
}
|
||||
|
||||
async function updateUserSubscription(userId: string, purchase: Purchase) {
|
||||
try {
|
||||
const supabase = createSupabaseAdminClient();
|
||||
const { data, error } = await supabase.from('apple_iap_subscriptions').upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
platform: purchase.platform,
|
||||
product_id: purchase.productId,
|
||||
transaction_id: purchase.transactionId,
|
||||
original_transaction_id: purchase.originalTransactionId,
|
||||
status: purchase.status === 'active' ? 'active' : 'expired',
|
||||
purchase_date: purchase.purchaseDate,
|
||||
expires_date: purchase.expiresDate,
|
||||
environment: purchase.environment,
|
||||
bundle_id: purchase.bundleId,
|
||||
quantity: purchase.quantity || 1,
|
||||
auto_renew_status: true,
|
||||
web_order_line_item_id: purchase.webOrderLineItemId,
|
||||
subscription_group_identifier: purchase.subscriptionGroupIdentifier,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
{
|
||||
onConflict: 'user_id,original_transaction_id',
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Database update error:', error);
|
||||
throw new Error(`Database update failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const plan = await getProductPlan(purchase.productId);
|
||||
await supabase
|
||||
.from('plans')
|
||||
.update({
|
||||
plan: ['active', 'trialing'].includes(purchase.status) ? plan : 'free',
|
||||
status: purchase.status,
|
||||
})
|
||||
.eq('id', userId);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to update user subscription:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
let validatedInput;
|
||||
try {
|
||||
validatedInput = iapVerificationSchema.parse(body);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid input data',
|
||||
purchase: null,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
const { transactionId, originalTransactionId } = validatedInput!;
|
||||
|
||||
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
|
||||
if (!user || !token) {
|
||||
return NextResponse.json({ error: IAPError.NOT_AUTHENTICATED }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = createSupabaseAdminClient();
|
||||
const { data: existingSubscription } = await supabase
|
||||
.from('apple_iap_subscriptions')
|
||||
.select('*')
|
||||
.eq('original_transaction_id', originalTransactionId)
|
||||
.single();
|
||||
|
||||
console.log('Existing subscription:', existingSubscription);
|
||||
// Should not restore purchase for another account
|
||||
if (existingSubscription && existingSubscription.user_id !== user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: IAPError.TRANSACTION_BELONGS_TO_ANOTHER_USER },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
if (
|
||||
existingSubscription &&
|
||||
existingSubscription.transactionId === transactionId &&
|
||||
existingSubscription.status === 'active'
|
||||
) {
|
||||
console.log('Transaction already verified and active');
|
||||
|
||||
const purchase = {
|
||||
status: existingSubscription.status,
|
||||
customerEmail: user.email,
|
||||
subscriptionId:
|
||||
existingSubscription.web_order_line_item_id ||
|
||||
existingSubscription.original_transaction_id,
|
||||
planName: getProductName(existingSubscription.product_id),
|
||||
productId: existingSubscription.product_id,
|
||||
platform: existingSubscription.platform,
|
||||
transactionId: existingSubscription.transaction_id,
|
||||
originalTransactionId: existingSubscription.original_transaction_id,
|
||||
purchaseDate: existingSubscription.purchase_date,
|
||||
expiresDate: existingSubscription.expires_date,
|
||||
quantity: existingSubscription.quantity,
|
||||
environment: existingSubscription.environment,
|
||||
bundleId: existingSubscription.bundle_id,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
purchase,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
const defaultIAPVerifier = getAppleIAPVerifier();
|
||||
const verificationResult = await defaultIAPVerifier.verifyTransaction(originalTransactionId);
|
||||
if (!verificationResult.success) {
|
||||
console.error('Apple verification failed:', verificationResult.error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: verificationResult.error || IAPError.UNKNOWN_ERROR,
|
||||
purchase: null,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const transaction = verificationResult.transaction!;
|
||||
console.log('Apple verification successful:', {
|
||||
transactionId: transaction.transactionId,
|
||||
productId: transaction.productId,
|
||||
environment: transaction.environment,
|
||||
});
|
||||
if (transaction.environment === 'Sandbox' && process.env.NODE_ENV === 'production') {
|
||||
console.warn('Sandbox transaction in production environment');
|
||||
}
|
||||
|
||||
const purchase = {
|
||||
status: verificationResult.status!,
|
||||
customerEmail: user.email!,
|
||||
subscriptionId: transaction.webOrderLineItemId || transaction.originalTransactionId,
|
||||
planName: getProductName(transaction.productId),
|
||||
productId: transaction.productId,
|
||||
platform: 'ios',
|
||||
transactionId: transaction.transactionId,
|
||||
originalTransactionId: transaction.originalTransactionId,
|
||||
purchaseDate: verificationResult.purchaseDate?.toISOString(),
|
||||
expiresDate: verificationResult.expiresDate?.toISOString() || null,
|
||||
quantity: transaction.quantity,
|
||||
environment: transaction.environment.toLowerCase(),
|
||||
bundleId: transaction.bundleId,
|
||||
webOrderLineItemId: transaction.webOrderLineItemId,
|
||||
subscriptionGroupIdentifier: transaction.subscriptionGroupIdentifier,
|
||||
type: transaction.type,
|
||||
revocationDate: verificationResult.revocationDate?.toISOString() || null,
|
||||
revocationReason: verificationResult.revocationReason,
|
||||
};
|
||||
|
||||
try {
|
||||
await updateUserSubscription(user.id, purchase);
|
||||
} catch (dbError) {
|
||||
console.error('Database update failed:', dbError);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: IAPError.TRANSACTION_SERVICE_UNAVAILABLE,
|
||||
purchase: null,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
purchase,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('IAP verification error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : IAPError.UNKNOWN_ERROR },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { MetadataService } from '@/services/metadata/service';
|
||||
import { SearchRequest } from '@/services/metadata/types';
|
||||
import { validateUserAndToken } from '@/utils/access';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
responseTime?: number;
|
||||
}
|
||||
|
||||
function validateSearchRequest(body: SearchRequest): {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
data?: SearchRequest;
|
||||
} {
|
||||
const { title, isbn, author, language } = body;
|
||||
|
||||
if (
|
||||
(!title || typeof title !== 'string' || title.trim().length === 0) &&
|
||||
(!isbn || typeof isbn !== 'string' || isbn.trim().length === 0)
|
||||
) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Either title or isbn parameter is required and must be a non-empty string',
|
||||
};
|
||||
}
|
||||
|
||||
if (title && typeof title !== 'string') {
|
||||
return { isValid: false, error: 'Title must be a string if provided' };
|
||||
}
|
||||
|
||||
if (isbn && typeof isbn !== 'string') {
|
||||
return { isValid: false, error: 'ISBN must be a string if provided' };
|
||||
}
|
||||
|
||||
if (author && typeof author !== 'string') {
|
||||
return { isValid: false, error: 'Author must be a string if provided' };
|
||||
}
|
||||
|
||||
if (isbn) {
|
||||
const cleanIsbn = isbn.replace(/[-\s]/g, '');
|
||||
if (!/^\d{10}(\d{3})?$/.test(cleanIsbn)) {
|
||||
return { isValid: false, error: 'Invalid ISBN format. Must be 10 or 13 digits' };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
data: {
|
||||
title: title?.trim(),
|
||||
isbn: isbn?.trim(),
|
||||
author: author?.trim(),
|
||||
language: language?.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createResponse<T>(
|
||||
success: boolean,
|
||||
data: T | null = null,
|
||||
error: string | null = null,
|
||||
responseTime: number,
|
||||
): ApiResponse<T> {
|
||||
return {
|
||||
success,
|
||||
data: data || undefined,
|
||||
error: error || undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
responseTime,
|
||||
};
|
||||
}
|
||||
|
||||
let metadataService: MetadataService;
|
||||
|
||||
function getMetadataService(): MetadataService {
|
||||
if (!metadataService) {
|
||||
metadataService = new MetadataService({
|
||||
googleBooksApiKeys: process.env['GOOGLE_BOOKS_API_KEYS'],
|
||||
});
|
||||
}
|
||||
return metadataService;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
|
||||
if (!user || !token) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 403 });
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const validation = validateSearchRequest(body);
|
||||
if (!validation.isValid) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
return NextResponse.json(createResponse(false, null, validation.error!, responseTime), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const service = getMetadataService();
|
||||
const result = await service.search(validation.data!);
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json(createResponse(false, null, 'Book not found', responseTime), {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(createResponse(true, result, null, responseTime), {
|
||||
status: 200,
|
||||
});
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
console.error('Search API error:', error);
|
||||
|
||||
let errorMessage = 'Internal server error';
|
||||
let statusCode = 500;
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
|
||||
if (error.message.includes('rate limit')) {
|
||||
statusCode = 429;
|
||||
} else if (error.message.includes('forbidden') || error.message.includes('API key')) {
|
||||
statusCode = 403;
|
||||
} else if (error.message.includes('Invalid ISBN')) {
|
||||
statusCode = 400;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(createResponse(false, null, errorMessage, responseTime), {
|
||||
status: statusCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export async function GET() {
|
||||
currency: price.currency,
|
||||
interval: price.recurring?.interval,
|
||||
product: price.product,
|
||||
productName: product.name,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getBaseUrl, isTauriAppPlatform } from '@/services/environment';
|
||||
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
|
||||
import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oauth';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { handleAuthCallback } from '@/helpers/auth';
|
||||
import { getAppleIdAuth, Scope } from './utils/appleIdAuth';
|
||||
import { authWithCustomTab, authWithSafari } from './utils/nativeAuth';
|
||||
@@ -69,15 +70,22 @@ export default function AuthPage() {
|
||||
const { isTrafficLightVisible } = useTrafficLightStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [port, setPort] = useState<number | null>(null);
|
||||
const isOAuthServerRunning = useRef(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isOAuthServerRunning = useRef(false);
|
||||
const useCustomeOAuth = useRef(false);
|
||||
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useTheme({ systemUIVisible: false });
|
||||
|
||||
const getTauriRedirectTo = (isOAuth: boolean) => {
|
||||
if (process.env.NODE_ENV === 'production' || appService?.isMobileApp || USE_APPLE_SIGN_IN) {
|
||||
// For custom OAuth mode, use a local server to handle the OAuth callback
|
||||
// This is useful for development or some sandboxed environments like Flatpak
|
||||
// where custom URL schemes are not supported
|
||||
if (
|
||||
!useCustomeOAuth.current &&
|
||||
(process.env.NODE_ENV === 'production' || appService?.isMobileApp || USE_APPLE_SIGN_IN)
|
||||
) {
|
||||
if (appService?.isMobileApp) {
|
||||
return isOAuth ? DEEPLINK_CALLBACK : WEB_AUTH_CALLBACK;
|
||||
}
|
||||
@@ -171,7 +179,10 @@ export default function AuthPage() {
|
||||
|
||||
const startTauriOAuth = async () => {
|
||||
try {
|
||||
if (process.env.NODE_ENV === 'production' || appService?.isMobileApp || USE_APPLE_SIGN_IN) {
|
||||
if (
|
||||
!useCustomeOAuth.current &&
|
||||
(process.env.NODE_ENV === 'production' || appService?.isMobileApp || USE_APPLE_SIGN_IN)
|
||||
) {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const currentWindow = getCurrentWindow();
|
||||
currentWindow.listen('single-instance', ({ event, payload }) => {
|
||||
@@ -280,6 +291,12 @@ export default function AuthPage() {
|
||||
if (isOAuthServerRunning.current) return;
|
||||
isOAuthServerRunning.current = true;
|
||||
|
||||
invoke('get_environment_variable', { name: 'USE_CUSTOM_OAUTH' }).then((value) => {
|
||||
if (value === 'true') {
|
||||
useCustomeOAuth.current = true;
|
||||
}
|
||||
});
|
||||
|
||||
startTauriOAuth();
|
||||
return () => {
|
||||
isOAuthServerRunning.current = false;
|
||||
@@ -317,64 +334,82 @@ export default function AuthPage() {
|
||||
return isTauriAppPlatform() ? (
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed inset-0 z-0 flex select-none flex-col items-center overflow-y-auto',
|
||||
'bg-base-100 border-base-200 border',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
'bg-base-100 inset-0 flex select-none flex-col items-center overflow-hidden',
|
||||
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
|
||||
appService?.isLinuxApp && 'window-border',
|
||||
appService?.hasRoundedWindow && 'rounded-window',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={headerRef}
|
||||
className={clsx(
|
||||
'fixed z-10 flex w-full items-center justify-between py-2 pe-6 ps-4',
|
||||
appService?.hasTrafficLight && 'pt-11',
|
||||
'flex h-full w-full flex-col items-center overflow-y-auto',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
)}
|
||||
>
|
||||
<button onClick={handleGoBack} className={clsx('btn btn-ghost h-8 min-h-8 w-8 p-0')}>
|
||||
<IoArrowBack className='text-base-content' />
|
||||
</button>
|
||||
<div
|
||||
ref={headerRef}
|
||||
className={clsx(
|
||||
'fixed z-10 flex w-full items-center justify-between py-2 pe-6 ps-4',
|
||||
appService?.hasTrafficLight && 'pt-11',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
className={clsx('btn btn-ghost h-12 min-h-12 w-12 p-0 sm:h-8 sm:min-h-8 sm:w-8')}
|
||||
>
|
||||
<IoArrowBack className='text-base-content' />
|
||||
</button>
|
||||
|
||||
{appService?.hasWindowBar && (
|
||||
<WindowButtons
|
||||
headerRef={headerRef}
|
||||
showMinimize={!isTrafficLightVisible}
|
||||
showMaximize={!isTrafficLightVisible}
|
||||
showClose={!isTrafficLightVisible}
|
||||
onClose={handleGoBack}
|
||||
{appService?.hasWindowBar && (
|
||||
<WindowButtons
|
||||
headerRef={headerRef}
|
||||
showMinimize={!isTrafficLightVisible}
|
||||
showMaximize={!isTrafficLightVisible}
|
||||
showClose={!isTrafficLightVisible}
|
||||
onClose={handleGoBack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'z-20 flex flex-col items-center pb-8',
|
||||
appService?.hasTrafficLight ? 'mt-24' : 'mt-12',
|
||||
)}
|
||||
style={{ maxWidth: '420px' }}
|
||||
>
|
||||
<ProviderLogin
|
||||
provider='google'
|
||||
handleSignIn={tauriSignIn}
|
||||
Icon={FcGoogle}
|
||||
label={_('Sign in with Google')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={clsx('z-20 pb-8', appService?.hasTrafficLight ? 'mt-24' : 'mt-12')}
|
||||
style={{ maxWidth: '420px' }}
|
||||
>
|
||||
<ProviderLogin
|
||||
provider='google'
|
||||
handleSignIn={tauriSignIn}
|
||||
Icon={FcGoogle}
|
||||
label={_('Sign in with Google')}
|
||||
/>
|
||||
<ProviderLogin
|
||||
provider='apple'
|
||||
handleSignIn={appService?.isIOSApp || USE_APPLE_SIGN_IN ? tauriSignInApple : tauriSignIn}
|
||||
Icon={FaApple}
|
||||
label={_('Sign in with Apple')}
|
||||
/>
|
||||
<ProviderLogin
|
||||
provider='github'
|
||||
handleSignIn={tauriSignIn}
|
||||
Icon={FaGithub}
|
||||
label={_('Sign in with GitHub')}
|
||||
/>
|
||||
<hr className='border-base-300 my-3 mt-6 w-64 border-t' />
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
appearance={{ theme: ThemeSupa }}
|
||||
theme={isDarkMode ? 'dark' : 'light'}
|
||||
magicLink={true}
|
||||
providers={[]}
|
||||
redirectTo={getTauriRedirectTo(false)}
|
||||
localization={getAuthLocalization()}
|
||||
/>
|
||||
<ProviderLogin
|
||||
provider='apple'
|
||||
handleSignIn={
|
||||
appService?.isIOSApp || USE_APPLE_SIGN_IN ? tauriSignInApple : tauriSignIn
|
||||
}
|
||||
Icon={FaApple}
|
||||
label={_('Sign in with Apple')}
|
||||
/>
|
||||
<ProviderLogin
|
||||
provider='github'
|
||||
handleSignIn={tauriSignIn}
|
||||
Icon={FaGithub}
|
||||
label={_('Sign in with GitHub')}
|
||||
/>
|
||||
<hr className='border-base-300 my-3 mt-6 w-64 border-t' />
|
||||
<div className='w-full'>
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
appearance={{ theme: ThemeSupa }}
|
||||
theme={isDarkMode ? 'dark' : 'light'}
|
||||
magicLink={true}
|
||||
providers={[]}
|
||||
redirectTo={getTauriRedirectTo(false)}
|
||||
localization={getAuthLocalization()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { parseWebViewVersion } from '@/utils/ua';
|
||||
import { handleGlobalError } from '@/utils/error';
|
||||
|
||||
interface ErrorPageProps {
|
||||
error: Error & { digest?: string };
|
||||
@@ -19,6 +20,7 @@ export default function Error({ error, reset }: ErrorPageProps) {
|
||||
useEffect(() => {
|
||||
setBrowserInfo(parseWebViewVersion(appService));
|
||||
posthog.captureException(error);
|
||||
handleGlobalError(error);
|
||||
}, [appService, error]);
|
||||
|
||||
const handleGoHome = () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ interface BookItemProps {
|
||||
mode: LibraryViewModeType;
|
||||
coverFit: LibraryCoverFitType;
|
||||
isSelectMode: boolean;
|
||||
selectedBooks: string[];
|
||||
bookSelected: boolean;
|
||||
transferProgress: number | null;
|
||||
handleBookUpload: (book: Book) => void;
|
||||
handleBookDownload: (book: Book) => void;
|
||||
@@ -34,7 +34,7 @@ const BookItem: React.FC<BookItemProps> = ({
|
||||
mode,
|
||||
coverFit,
|
||||
isSelectMode,
|
||||
selectedBooks,
|
||||
bookSelected,
|
||||
transferProgress,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
@@ -61,18 +61,19 @@ const BookItem: React.FC<BookItemProps> = ({
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'relative flex aspect-[28/41] items-center justify-center',
|
||||
'relative flex aspect-[28/41] justify-center',
|
||||
coverFit === 'crop' && 'overflow-hidden shadow-md',
|
||||
mode === 'list' && 'min-w-20',
|
||||
mode === 'grid' && 'items-end',
|
||||
mode === 'list' && 'min-w-20 items-center',
|
||||
)}
|
||||
>
|
||||
<BookCover mode={mode} book={book} coverFit={coverFit} />
|
||||
{selectedBooks.includes(book.hash) && (
|
||||
<BookCover mode={mode} book={book} coverFit={coverFit} showSpine={false} />
|
||||
{bookSelected && (
|
||||
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
|
||||
)}
|
||||
{isSelectMode && (
|
||||
<div className='absolute bottom-1 right-1'>
|
||||
{selectedBooks.includes(book.hash) ? (
|
||||
{bookSelected ? (
|
||||
<MdCheckCircle className='fill-blue-500' />
|
||||
) : (
|
||||
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { MdDelete, MdOpenInNew, MdOutlineCancel, MdInfoOutline } from 'react-icons/md';
|
||||
import { LuFolderPlus } from 'react-icons/lu';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
@@ -11,7 +11,7 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
|
||||
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
|
||||
import { formatAuthors, formatTitle } from '@/utils/book';
|
||||
import { isMd5 } from '@/utils/md5';
|
||||
|
||||
@@ -54,7 +54,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const { appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
|
||||
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
const [showGroupingModal, setShowGroupingModal] = useState(false);
|
||||
@@ -70,6 +69,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const isImportingBook = useRef(false);
|
||||
|
||||
const { setCurrentBookshelf, setLibrary } = useLibraryStore();
|
||||
const { setSelectedBooks, getSelectedBooks, toggleSelectedBook } = useLibraryStore();
|
||||
const allBookshelfItems =
|
||||
viewMode === 'grid' ? generateGridItems(libraryBooks) : generateListItems(libraryBooks);
|
||||
|
||||
@@ -162,18 +162,24 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams, libraryBooks, showGroupingModal]);
|
||||
|
||||
const toggleSelection = (id: string) => {
|
||||
setSelectedBooks((prev) =>
|
||||
prev.includes(id) ? prev.filter((selectedId) => selectedId !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
toggleSelectedBook(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const openSelectedBooks = () => {
|
||||
setTimeout(() => setLoading(true), 200);
|
||||
navigateToReader(router, selectedBooks);
|
||||
handleSetSelectMode(false);
|
||||
if (appService?.hasWindow && settings.openBookInNewWindow) {
|
||||
showReaderWindow(appService, getSelectedBooks());
|
||||
} else {
|
||||
setTimeout(() => setLoading(true), 200);
|
||||
navigateToReader(router, getSelectedBooks());
|
||||
}
|
||||
};
|
||||
|
||||
const openBookDetails = () => {
|
||||
handleSetSelectMode(false);
|
||||
const selectedBooks = getSelectedBooks();
|
||||
const book = libraryBooks.find((book) => book.hash === selectedBooks[0]);
|
||||
if (book) {
|
||||
handleShowDetailsBook(book);
|
||||
@@ -182,6 +188,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
|
||||
const getBooksToDelete = () => {
|
||||
const booksToDelete: Book[] = [];
|
||||
const selectedBooks = getSelectedBooks();
|
||||
selectedBooks.forEach((id) => {
|
||||
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
|
||||
if (book && !book.deletedAt) {
|
||||
@@ -216,7 +223,13 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const searchTerm = new RegExp(queryTerm, 'i');
|
||||
const title = formatTitle(item.title);
|
||||
const authors = formatAuthors(item.author);
|
||||
return searchTerm.test(title) || searchTerm.test(authors);
|
||||
return (
|
||||
searchTerm.test(title) ||
|
||||
searchTerm.test(authors) ||
|
||||
searchTerm.test(item.format) ||
|
||||
(item.groupName && searchTerm.test(item.groupName)) ||
|
||||
(item.metadata?.description && searchTerm.test(item.metadata?.description))
|
||||
);
|
||||
};
|
||||
const bookSorter = (a: Book, b: Book) => {
|
||||
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
|
||||
@@ -281,6 +294,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSelectMode, isSelectAll, isSelectNone]);
|
||||
|
||||
const selectedBooks = getSelectedBooks();
|
||||
|
||||
return (
|
||||
<div className='bookshelf'>
|
||||
<div
|
||||
@@ -298,7 +313,9 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
mode={viewMode as LibraryViewModeType}
|
||||
coverFit={coverFit as LibraryCoverFitType}
|
||||
isSelectMode={isSelectMode}
|
||||
selectedBooks={selectedBooks}
|
||||
itemSelected={
|
||||
'hash' in item ? selectedBooks.includes(item.hash) : selectedBooks.includes(item.id)
|
||||
}
|
||||
setLoading={setLoading}
|
||||
toggleSelection={toggleSelection}
|
||||
handleBookUpload={handleBookUpload}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import { useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
|
||||
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -8,8 +9,9 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLongPress } from '@/hooks/useLongPress';
|
||||
import { Menu, MenuItem } from '@tauri-apps/api/menu';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { getLocalBookFilename } from '@/utils/book';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
|
||||
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
|
||||
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
|
||||
@@ -75,7 +77,7 @@ interface BookshelfItemProps {
|
||||
item: BookshelfItem;
|
||||
coverFit: LibraryCoverFitType;
|
||||
isSelectMode: boolean;
|
||||
selectedBooks: string[];
|
||||
itemSelected: boolean;
|
||||
transferProgress: number | null;
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
toggleSelection: (hash: string) => void;
|
||||
@@ -91,7 +93,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
item,
|
||||
coverFit,
|
||||
isSelectMode,
|
||||
selectedBooks,
|
||||
itemSelected,
|
||||
transferProgress,
|
||||
setLoading,
|
||||
toggleSelection,
|
||||
@@ -108,18 +110,20 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
const { settings } = useSettingsStore();
|
||||
const { updateBook } = useLibraryStore();
|
||||
|
||||
const showBookDetailsModal = async (book: Book) => {
|
||||
const showBookDetailsModal = useCallback(async (book: Book) => {
|
||||
if (await makeBookAvailable(book)) {
|
||||
handleShowDetailsBook(book);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const makeBookAvailable = async (book: Book) => {
|
||||
if (book.uploadedAt && !book.downloadedAt) {
|
||||
if (await appService?.isBookAvailable(book)) {
|
||||
if (!book.downloadedAt) {
|
||||
if (!book.downloadedAt || !book.coverDownloadedAt) {
|
||||
book.downloadedAt = Date.now();
|
||||
updateBook(envConfig, book);
|
||||
book.coverDownloadedAt = Date.now();
|
||||
await updateBook(envConfig, book);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -127,7 +131,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
const loadingTimeout = setTimeout(() => setLoading(true), 200);
|
||||
try {
|
||||
available = await handleBookDownload(book);
|
||||
updateBook(envConfig, book);
|
||||
await updateBook(envConfig, book);
|
||||
} finally {
|
||||
if (loadingTimeout) clearTimeout(loadingTimeout);
|
||||
setLoading(false);
|
||||
@@ -137,24 +141,41 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleBookClick = async (book: Book) => {
|
||||
if (isSelectMode) {
|
||||
toggleSelection(book.hash);
|
||||
} else {
|
||||
if (!(await makeBookAvailable(book))) return;
|
||||
navigateToReader(router, [book.hash]);
|
||||
}
|
||||
};
|
||||
const handleBookClick = useCallback(
|
||||
async (book: Book) => {
|
||||
if (isSelectMode) {
|
||||
toggleSelection(book.hash);
|
||||
} else {
|
||||
const available = await makeBookAvailable(book);
|
||||
if (!available) return;
|
||||
if (appService?.hasWindow && settings.openBookInNewWindow) {
|
||||
showReaderWindow(appService, [book.hash]);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
navigateToReader(router, [book.hash]);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[isSelectMode, settings.openBookInNewWindow, appService],
|
||||
);
|
||||
|
||||
const handleGroupClick = (group: BooksGroup) => {
|
||||
if (isSelectMode) {
|
||||
toggleSelection(group.id);
|
||||
} else {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
params.set('group', group.id);
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
}
|
||||
};
|
||||
const handleGroupClick = useCallback(
|
||||
(group: BooksGroup) => {
|
||||
if (isSelectMode) {
|
||||
toggleSelection(group.id);
|
||||
} else {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
params.set('group', group.id);
|
||||
setTimeout(() => {
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[isSelectMode, searchParams],
|
||||
);
|
||||
|
||||
const bookContextMenuHandler = async (book: Book) => {
|
||||
if (!appService?.hasContextMenu) return;
|
||||
@@ -162,7 +183,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
const fileRevealLabel =
|
||||
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
|
||||
const selectBookMenuItem = await MenuItem.new({
|
||||
text: selectedBooks.includes(book.hash) ? _('Deselect Book') : _('Select Book'),
|
||||
text: itemSelected ? _('Deselect Book') : _('Select Book'),
|
||||
action: async () => {
|
||||
if (!isSelectMode) handleSetSelectMode(true);
|
||||
toggleSelection(book.hash);
|
||||
@@ -216,7 +237,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
const groupContextMenuHandler = async (group: BooksGroup) => {
|
||||
if (!appService?.hasContextMenu) return;
|
||||
const selectGroupMenuItem = await MenuItem.new({
|
||||
text: selectedBooks.includes(group.id) ? _('Deselect Group') : _('Select Group'),
|
||||
text: itemSelected ? _('Deselect Group') : _('Select Group'),
|
||||
action: async () => {
|
||||
if (!isSelectMode) handleSetSelectMode(true);
|
||||
toggleSelection(group.id);
|
||||
@@ -236,8 +257,9 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
menu.popup();
|
||||
};
|
||||
|
||||
const { pressing, handlers } = useLongPress({
|
||||
onLongPress: async () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSelectItem = useCallback(
|
||||
throttle(() => {
|
||||
if (!isSelectMode) {
|
||||
handleSetSelectMode(true);
|
||||
}
|
||||
@@ -246,22 +268,56 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
} else {
|
||||
toggleSelection((item as BooksGroup).id);
|
||||
}
|
||||
},
|
||||
onTap: () => {
|
||||
}, 100),
|
||||
[isSelectMode],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleOpenItem = useCallback(
|
||||
throttle(() => {
|
||||
if (isSelectMode) {
|
||||
handleSelectItem();
|
||||
return;
|
||||
}
|
||||
if ('format' in item) {
|
||||
handleBookClick(item as Book);
|
||||
} else {
|
||||
handleGroupClick(item as BooksGroup);
|
||||
}
|
||||
},
|
||||
onContextMenu: () => {
|
||||
}, 100),
|
||||
[handleSelectItem, handleBookClick, handleGroupClick],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleContextMenu = useCallback(
|
||||
throttle(() => {
|
||||
if ('format' in item) {
|
||||
bookContextMenuHandler(item as Book);
|
||||
} else {
|
||||
groupContextMenuHandler(item as BooksGroup);
|
||||
}
|
||||
}, 100),
|
||||
[itemSelected],
|
||||
);
|
||||
|
||||
const { pressing, handlers } = useLongPress(
|
||||
{
|
||||
onLongPress: () => {
|
||||
handleSelectItem();
|
||||
},
|
||||
onTap: () => {
|
||||
handleOpenItem();
|
||||
},
|
||||
onContextMenu: () => {
|
||||
if (appService?.hasContextMenu) {
|
||||
handleContextMenu();
|
||||
} else if (appService?.isAndroidApp) {
|
||||
handleSelectItem();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
[isSelectMode, handleSelectItem, handleOpenItem, handleContextMenu],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx(mode === 'list' && 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
|
||||
@@ -270,6 +326,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
'group',
|
||||
mode === 'grid' && 'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-4 sm:px-4',
|
||||
mode === 'list' && 'border-base-300 flex flex-col border-b py-2',
|
||||
appService?.isMobileApp && 'no-context-menu',
|
||||
pressing ? (mode === 'grid' ? 'scale-95' : 'scale-98') : 'scale-100',
|
||||
)}
|
||||
style={{
|
||||
@@ -284,14 +341,14 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
book={item}
|
||||
coverFit={coverFit}
|
||||
isSelectMode={isSelectMode}
|
||||
selectedBooks={selectedBooks}
|
||||
bookSelected={itemSelected}
|
||||
transferProgress={transferProgress}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
showBookDetailsModal={showBookDetailsModal}
|
||||
/>
|
||||
) : (
|
||||
<GroupItem group={item} isSelectMode={isSelectMode} selectedBooks={selectedBooks} />
|
||||
<GroupItem group={item} isSelectMode={isSelectMode} groupSelected={itemSelected} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,10 @@ import BookCover from '@/components/BookCover';
|
||||
interface GroupItemProps {
|
||||
group: BooksGroup;
|
||||
isSelectMode: boolean;
|
||||
selectedBooks: string[];
|
||||
groupSelected: boolean;
|
||||
}
|
||||
|
||||
const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, selectedBooks }) => {
|
||||
const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, groupSelected }) => {
|
||||
const { appService } = useEnv();
|
||||
const iconSize15 = useResponsiveSize(15);
|
||||
|
||||
@@ -30,12 +30,12 @@ const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, selectedBook
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selectedBooks.includes(group.id) && (
|
||||
{groupSelected && (
|
||||
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
|
||||
)}
|
||||
{isSelectMode && (
|
||||
<div className='absolute bottom-1 right-1'>
|
||||
{selectedBooks.includes(group.id) ? (
|
||||
{groupSelected ? (
|
||||
<MdCheckCircle className='fill-blue-500' />
|
||||
) : (
|
||||
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
|
||||
|
||||
@@ -33,7 +33,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { user } = useAuth();
|
||||
const { userPlan, quotas } = useQuotaStats();
|
||||
const { userPlan, quotas } = useQuotaStats(true);
|
||||
const { themeMode, setThemeMode } = useThemeStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
|
||||
@@ -83,6 +83,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const toggleOpenInNewWindow = () => {
|
||||
settings.openBookInNewWindow = !settings.openBookInNewWindow;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const toggleAlwaysOnTop = () => {
|
||||
settings.alwaysOnTop = !settings.alwaysOnTop;
|
||||
setSettings(settings);
|
||||
@@ -185,7 +192,9 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
}
|
||||
>
|
||||
<ul>
|
||||
<Quota quotas={quotas} labelClassName='h-10 pl-3 pr-2' />
|
||||
<div onClick={handleUserProfile} className='cursor-pointer'>
|
||||
<Quota quotas={quotas} labelClassName='h-10 pl-3 pr-2' />
|
||||
</div>
|
||||
<MenuItem label={_('Account')} noIcon onClick={handleUserProfile} />
|
||||
</ul>
|
||||
</MenuItem>
|
||||
@@ -219,6 +228,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
/>
|
||||
)}
|
||||
<hr className='border-base-200 my-1' />
|
||||
{appService?.hasWindow && (
|
||||
<MenuItem
|
||||
label={_('Open Book in New Window')}
|
||||
Icon={settings.openBookInNewWindow ? MdCheck : undefined}
|
||||
onClick={toggleOpenInNewWindow}
|
||||
/>
|
||||
)}
|
||||
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
|
||||
{appService?.hasWindow && (
|
||||
<MenuItem
|
||||
|
||||
@@ -70,8 +70,9 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
|
||||
const processOldBook = async (oldBook: Book) => {
|
||||
const matchingBook = syncedBooks.find((newBook) => newBook.hash === oldBook.hash);
|
||||
if (matchingBook) {
|
||||
if (!matchingBook.deletedAt && matchingBook.uploadedAt && !oldBook.downloadedAt) {
|
||||
if (!matchingBook.deletedAt && matchingBook.uploadedAt && !oldBook.coverDownloadedAt) {
|
||||
await appService?.downloadBook(oldBook, true);
|
||||
oldBook.coverImageUrl = await appService?.generateCoverImageUrl(oldBook);
|
||||
}
|
||||
const mergedBook =
|
||||
matchingBook.updatedAt > oldBook.updatedAt
|
||||
|
||||
@@ -2,21 +2,27 @@
|
||||
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useState, useRef, useEffect, Suspense } from 'react';
|
||||
import { useState, useRef, useEffect, Suspense, useCallback } from 'react';
|
||||
import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from 'overlayscrollbars-react';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { AppService } from '@/types/system';
|
||||
import { navigateToLogin, navigateToReader } from '@/utils/nav';
|
||||
import { getFilename, listFormater } from '@/utils/book';
|
||||
import {
|
||||
formatAuthors,
|
||||
formatTitle,
|
||||
getFilename,
|
||||
getPrimaryLanguage,
|
||||
listFormater,
|
||||
} from '@/utils/book';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { ProgressPayload } from '@/utils/transfer';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { parseOpenWithFiles } from '@/helpers/openWith';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { checkForAppUpdates } from '@/helpers/updater';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
|
||||
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
|
||||
import { impactFeedback } from '@tauri-apps/plugin-haptics';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
@@ -43,11 +49,12 @@ import {
|
||||
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
import { UpdaterWindow } from '@/components/UpdaterWindow';
|
||||
import { BookMetadata } from '@/libs/document';
|
||||
import { BookDetailModal } from '@/components/metadata';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import LibraryHeader from './components/LibraryHeader';
|
||||
import Bookshelf from './components/Bookshelf';
|
||||
import BookDetailModal from '@/components/BookDetailModal';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
import DropIndicator from '@/components/DropIndicator';
|
||||
|
||||
@@ -85,7 +92,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const [pendingNavigationBookIds, setPendingNavigationBookIds] = useState<string[] | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const demoBooks = useDemoBooks();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
|
||||
const containerRef: React.MutableRefObject<HTMLDivElement | null> = useRef(null);
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useTheme({ systemUIVisible: true, appThemeColor: 'base-200' });
|
||||
@@ -118,6 +126,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const doCheckAppUpdates = async () => {
|
||||
if (appService?.hasUpdater && settings.autoCheckUpdates) {
|
||||
await checkForAppUpdates(_);
|
||||
} else if (appService?.hasUpdater === false) {
|
||||
checkAppReleaseNotes();
|
||||
}
|
||||
};
|
||||
if (settings.alwaysOnTop) {
|
||||
@@ -125,7 +135,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
}
|
||||
doCheckAppUpdates();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings]);
|
||||
}, [appService?.hasUpdater, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appService?.isMobileApp) {
|
||||
@@ -184,6 +194,28 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshLibrary = useCallback(async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const settings = await appService.loadSettings();
|
||||
const library = await appService.loadLibraryBooks();
|
||||
setSettings(settings);
|
||||
setLibrary(library);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [envConfig, appService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appService?.hasWindow) {
|
||||
const currentWebview = getCurrentWebview();
|
||||
const unlisten = currentWebview.listen('close-reader-window', async () => {
|
||||
handleRefreshLibrary();
|
||||
});
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [appService, handleRefreshLibrary]);
|
||||
|
||||
useEffect(() => {
|
||||
const libraryPage = document.querySelector('.library-page');
|
||||
if (!appService?.isMobile) {
|
||||
@@ -284,7 +316,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
navigateToReader(router, bookIds);
|
||||
}
|
||||
}
|
||||
}, [pendingNavigationBookIds, router]);
|
||||
}, [pendingNavigationBookIds, appService, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitiating.current) return;
|
||||
@@ -312,12 +344,15 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
|
||||
// Reuse the library from the store when we return from the reader
|
||||
const library = libraryBooks.length > 0 ? libraryBooks : await appService.loadLibraryBooks();
|
||||
setCheckOpenWithBooks(checkOpenWithBooks && (await handleOpenWithBooks(appService, library)));
|
||||
setCheckLastOpenBooks(
|
||||
checkLastOpenBooks &&
|
||||
settings.openLastBooks &&
|
||||
(await handleOpenLastBooks(appService, settings.lastOpenBooks, library)),
|
||||
);
|
||||
let opened = false;
|
||||
if (checkOpenWithBooks) {
|
||||
opened = await handleOpenWithBooks(appService, library);
|
||||
}
|
||||
setCheckOpenWithBooks(opened);
|
||||
if (!opened && checkLastOpenBooks && settings.openLastBooks) {
|
||||
opened = await handleOpenLastBooks(appService, settings.lastOpenBooks, library);
|
||||
}
|
||||
setCheckLastOpenBooks(opened);
|
||||
|
||||
setLibrary(library);
|
||||
setLibraryLoaded(true);
|
||||
@@ -401,7 +436,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
};
|
||||
|
||||
const selectFilesTauri = async () => {
|
||||
const exts = appService?.isMobileApp ? [] : SUPPORTED_FILE_EXTS;
|
||||
const exts = appService?.isIOSApp ? [] : SUPPORTED_FILE_EXTS;
|
||||
const files = (await appService?.selectFiles(_('Select Books'), exts)) || [];
|
||||
if (appService?.isIOSApp) {
|
||||
return files.filter((file) => {
|
||||
@@ -409,7 +444,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
return SUPPORTED_FILE_EXTS.includes(fileExt);
|
||||
});
|
||||
}
|
||||
// Cannot filter out files on Android since some content providers may not return the file name
|
||||
return files;
|
||||
};
|
||||
|
||||
@@ -436,70 +470,78 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
}));
|
||||
}, 500);
|
||||
|
||||
const handleBookUpload = async (book: Book) => {
|
||||
try {
|
||||
await appService?.uploadBook(book, (progress) => {
|
||||
updateBookTransferProgress(book.hash, progress);
|
||||
});
|
||||
await updateBook(envConfig, book);
|
||||
pushLibrary();
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
timeout: 2000,
|
||||
message: _('Book uploaded: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes('Not authenticated') && settings.keepLogin) {
|
||||
settings.keepLogin = false;
|
||||
setSettings(settings);
|
||||
navigateToLogin(router);
|
||||
return false;
|
||||
} else if (err.message.includes('Insufficient storage quota')) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Insufficient storage quota'),
|
||||
});
|
||||
return false;
|
||||
const handleBookUpload = useCallback(
|
||||
async (book: Book) => {
|
||||
try {
|
||||
await appService?.uploadBook(book, (progress) => {
|
||||
updateBookTransferProgress(book.hash, progress);
|
||||
});
|
||||
await updateBook(envConfig, book);
|
||||
pushLibrary();
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
timeout: 2000,
|
||||
message: _('Book uploaded: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes('Not authenticated') && settings.keepLogin) {
|
||||
settings.keepLogin = false;
|
||||
setSettings(settings);
|
||||
navigateToLogin(router);
|
||||
return false;
|
||||
} else if (err.message.includes('Insufficient storage quota')) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Insufficient storage quota'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Failed to upload book: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Failed to upload book: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[appService],
|
||||
);
|
||||
|
||||
const handleBookDownload = async (book: Book) => {
|
||||
try {
|
||||
await appService?.downloadBook(book, false, (progress) => {
|
||||
updateBookTransferProgress(book.hash, progress);
|
||||
});
|
||||
await updateBook(envConfig, book);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
timeout: 2000,
|
||||
message: _('Book downloaded: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Failed to download book: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const handleBookDownload = useCallback(
|
||||
async (book: Book, redownload = false) => {
|
||||
try {
|
||||
await appService?.downloadBook(book, false, redownload, (progress) => {
|
||||
updateBookTransferProgress(book.hash, progress);
|
||||
});
|
||||
await updateBook(envConfig, book);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
timeout: 2000,
|
||||
message: _('Book downloaded: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Failed to download book: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[appService],
|
||||
);
|
||||
|
||||
const handleBookDelete = async (book: Book) => {
|
||||
try {
|
||||
@@ -550,15 +592,46 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateMetadata = async (book: Book, metadata: BookMetadata) => {
|
||||
book.metadata = metadata;
|
||||
book.title = formatTitle(metadata.title);
|
||||
book.author = formatAuthors(metadata.author);
|
||||
book.primaryLanguage = getPrimaryLanguage(metadata.language);
|
||||
book.updatedAt = Date.now();
|
||||
if (metadata.coverImageBlobUrl || metadata.coverImageUrl || metadata.coverImageFile) {
|
||||
book.coverImageUrl = metadata.coverImageBlobUrl || metadata.coverImageUrl;
|
||||
try {
|
||||
await appService?.updateCoverImage(
|
||||
book,
|
||||
metadata.coverImageBlobUrl || metadata.coverImageUrl,
|
||||
metadata.coverImageFile,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Failed to update cover image:', error);
|
||||
}
|
||||
}
|
||||
if (isWebAppPlatform()) {
|
||||
// Clear HTTP cover image URL if cover is updated with a local file
|
||||
if (metadata.coverImageBlobUrl) {
|
||||
metadata.coverImageUrl = undefined;
|
||||
}
|
||||
} else {
|
||||
metadata.coverImageUrl = undefined;
|
||||
}
|
||||
metadata.coverImageBlobUrl = undefined;
|
||||
metadata.coverImageFile = undefined;
|
||||
await updateBook(envConfig, book);
|
||||
};
|
||||
|
||||
const handleImportBooks = async () => {
|
||||
setIsSelectMode(false);
|
||||
console.log('Importing books...');
|
||||
let files;
|
||||
|
||||
if (isTauriAppPlatform()) {
|
||||
files = (await selectFilesTauri()) as [string];
|
||||
files = (await selectFilesTauri()) as string[];
|
||||
} else {
|
||||
files = (await selectFilesWeb()) as [File];
|
||||
files = (await selectFilesWeb()) as File[];
|
||||
}
|
||||
importBooks(files);
|
||||
};
|
||||
@@ -606,6 +679,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
className={clsx(
|
||||
'library-page bg-base-200 text-base-content flex select-none flex-col overflow-hidden',
|
||||
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
|
||||
appService?.isLinuxApp && 'window-border',
|
||||
appService?.hasRoundedWindow && 'rounded-window',
|
||||
)}
|
||||
>
|
||||
@@ -626,9 +700,20 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
)}
|
||||
{libraryLoaded &&
|
||||
(libraryBooks.some((book) => !book.deletedAt) ? (
|
||||
<OverlayScrollbarsComponent options={{ scrollbars: { autoHide: 'scroll' } }} defer>
|
||||
<OverlayScrollbarsComponent
|
||||
defer
|
||||
ref={osRef}
|
||||
options={{ scrollbars: { autoHide: 'scroll' } }}
|
||||
events={{
|
||||
initialized: (instance) => {
|
||||
const { content } = instance.elements();
|
||||
if (content) {
|
||||
containerRef.current = content as HTMLDivElement;
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx('scroll-container drop-zone flex-grow', isDragging && 'drag-over')}
|
||||
style={{
|
||||
paddingTop: '0px',
|
||||
@@ -680,10 +765,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
handleBookDownload={handleBookDownload}
|
||||
handleBookDelete={handleBookDelete}
|
||||
handleBookDeleteCloudBackup={handleBookDeleteCloudBackup}
|
||||
handleBookMetadataUpdate={handleUpdateMetadata}
|
||||
/>
|
||||
)}
|
||||
<AboutWindow />
|
||||
{appService?.isAndroidApp && <UpdaterWindow />}
|
||||
<UpdaterWindow />
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
const { getConfig, getBookData } = useBookDataStore();
|
||||
const { getProgress, getViewState, getViewSettings } = useReaderStore();
|
||||
const { setGridInsets, hoveredBookKey } = useReaderStore();
|
||||
const { isSideBarVisible, sideBarBookKey } = useSidebarStore();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
|
||||
const screenInsets = useSafeAreaInsets();
|
||||
@@ -70,7 +70,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('relative grid h-full flex-grow')}
|
||||
className={clsx('books-grid bg-base-100 relative grid h-full flex-grow')}
|
||||
style={{
|
||||
gridTemplateColumns: gridTemplate.columns,
|
||||
gridTemplateRows: gridTemplate.rows,
|
||||
@@ -106,7 +106,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
key={bookKey}
|
||||
className={clsx(
|
||||
'relative h-full w-full overflow-hidden',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window',
|
||||
appService?.hasRoundedWindow && 'rounded-window',
|
||||
)}
|
||||
>
|
||||
{isBookmarked && !hoveredBookKey && <Ribbon width={`${horizontalGapPercent}%`} />}
|
||||
@@ -153,11 +153,12 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
showFooter={showFooter}
|
||||
borderColor={viewSettings.borderColor}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
contentInsets={contentInsets}
|
||||
insets={viewInsets}
|
||||
/>
|
||||
)}
|
||||
{showHeader && (
|
||||
<SectionInfo
|
||||
bookKey={bookKey}
|
||||
section={sectionLabel}
|
||||
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
|
||||
isScrolled={viewSettings.scrolled}
|
||||
|
||||
@@ -5,7 +5,7 @@ interface DoubleBorderProps {
|
||||
horizontalGap: number;
|
||||
showHeader: boolean;
|
||||
showFooter: boolean;
|
||||
contentInsets: Insets;
|
||||
insets: Insets;
|
||||
}
|
||||
|
||||
const paddingPx = 10;
|
||||
@@ -14,7 +14,7 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
|
||||
borderColor,
|
||||
showHeader,
|
||||
showFooter,
|
||||
contentInsets,
|
||||
insets,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
@@ -23,10 +23,10 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
|
||||
className={'borderframe pointer-events-none absolute'}
|
||||
style={{
|
||||
border: `4px solid ${borderColor}`,
|
||||
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px + ${paddingPx * 2}px)`,
|
||||
top: `calc(${contentInsets.top}px - ${paddingPx}px)`,
|
||||
left: `calc(${contentInsets.left}px - ${paddingPx}px)`,
|
||||
right: `calc(${contentInsets.right}px - ${paddingPx}px)`,
|
||||
height: `calc(100% - ${insets.top + insets.bottom}px + ${paddingPx * 2}px)`,
|
||||
top: `calc(${insets.top}px - ${paddingPx}px)`,
|
||||
left: `calc(${insets.left}px - ${paddingPx}px)`,
|
||||
right: `calc(${insets.right}px - ${paddingPx}px)`,
|
||||
}}
|
||||
></div>
|
||||
{/* inner frame */}
|
||||
@@ -34,10 +34,10 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
|
||||
className={'borderframe pointer-events-none absolute'}
|
||||
style={{
|
||||
border: `1px solid ${borderColor}`,
|
||||
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px)`,
|
||||
top: `${contentInsets.top}px`,
|
||||
left: `calc(${contentInsets.left + (showFooter ? 32 : 0)}px`,
|
||||
right: `calc(${contentInsets.right + (showHeader ? 32 : 0)}px`,
|
||||
height: `calc(100% - ${insets.top + insets.bottom}px)`,
|
||||
top: `${insets.top}px`,
|
||||
left: `calc(${insets.left + (showFooter ? 32 : 0)}px`,
|
||||
right: `calc(${insets.right + (showHeader ? 32 : 0)}px`,
|
||||
}}
|
||||
/>
|
||||
{/* footer */}
|
||||
@@ -49,9 +49,9 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
borderLeft: `1px solid ${borderColor}`,
|
||||
width: '32px',
|
||||
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px)`,
|
||||
top: `${contentInsets.top}px`,
|
||||
left: `calc(${contentInsets.left}px)`,
|
||||
height: `calc(100% - ${insets.top + insets.bottom}px)`,
|
||||
top: `${insets.top}px`,
|
||||
left: `calc(${insets.left}px)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -64,9 +64,9 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
borderRight: `1px solid ${borderColor}`,
|
||||
width: '32px',
|
||||
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px)`,
|
||||
top: `${contentInsets.top}px`,
|
||||
left: `calc(100% - ${contentInsets.right}px - 32px)`,
|
||||
height: `calc(100% - ${insets.top + insets.bottom}px)`,
|
||||
top: `${insets.top}px`,
|
||||
left: `calc(100% - ${insets.right}px - 32px)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,13 @@ import { usePagination } from '../hooks/usePagination';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { useProgressSync } from '../hooks/useProgressSync';
|
||||
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
|
||||
import { applyTranslationStyles, getStyles, transformStylesheet } from '@/utils/style';
|
||||
import {
|
||||
applyFixedlayoutStyles,
|
||||
applyImageStyle,
|
||||
applyTranslationStyle,
|
||||
getStyles,
|
||||
transformStylesheet,
|
||||
} from '@/utils/style';
|
||||
import { mountAdditionalFonts } from '@/utils/font';
|
||||
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
|
||||
import { useUICSS } from '@/hooks/useUICSS';
|
||||
@@ -60,6 +66,7 @@ const FoliateViewer: React.FC<{
|
||||
const viewRef = useRef<FoliateView | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isViewCreated = useRef(false);
|
||||
const doubleClickDisabled = useRef(!!viewSettings?.disableDoubleClick);
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -129,6 +136,12 @@ const FoliateViewer: React.FC<{
|
||||
|
||||
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
|
||||
|
||||
if (bookDoc.rendition?.layout === 'pre-paginated') {
|
||||
applyFixedlayoutStyles(detail.doc, viewSettings);
|
||||
}
|
||||
|
||||
applyImageStyle(detail.doc);
|
||||
|
||||
// Inline scripts in tauri platforms are not executed by default
|
||||
if (viewSettings.allowScript && isTauriAppPlatform()) {
|
||||
evalInlineScripts(detail.doc);
|
||||
@@ -147,7 +160,7 @@ const FoliateViewer: React.FC<{
|
||||
detail.doc.addEventListener('keydown', handleKeydown.bind(null, bookKey));
|
||||
detail.doc.addEventListener('mousedown', handleMousedown.bind(null, bookKey));
|
||||
detail.doc.addEventListener('mouseup', handleMouseup.bind(null, bookKey));
|
||||
detail.doc.addEventListener('click', handleClick.bind(null, bookKey));
|
||||
detail.doc.addEventListener('click', handleClick.bind(null, bookKey, doubleClickDisabled));
|
||||
detail.doc.addEventListener('wheel', handleWheel.bind(null, bookKey));
|
||||
detail.doc.addEventListener('touchstart', handleTouchStart.bind(null, bookKey));
|
||||
detail.doc.addEventListener('touchmove', handleTouchMove.bind(null, bookKey));
|
||||
@@ -242,8 +255,9 @@ const FoliateViewer: React.FC<{
|
||||
const height = viewHeight - insets.top - insets.bottom;
|
||||
book.transformTarget?.addEventListener('data', getDocTransformHandler({ width, height }));
|
||||
view.renderer.setStyles?.(getStyles(viewSettings));
|
||||
applyTranslationStyles(viewSettings);
|
||||
applyTranslationStyle(viewSettings);
|
||||
|
||||
doubleClickDisabled.current = viewSettings.disableDoubleClick!;
|
||||
const animated = viewSettings.animated!;
|
||||
const maxColumnCount = viewSettings.maxColumnCount!;
|
||||
const maxInlineSize = getMaxInlineSize(viewSettings);
|
||||
@@ -305,9 +319,19 @@ const FoliateViewer: React.FC<{
|
||||
if (viewRef.current && viewRef.current.renderer) {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
|
||||
if (bookDoc.rendition?.layout === 'pre-paginated') {
|
||||
const docs = viewRef.current.renderer.getContents();
|
||||
docs.forEach(({ doc }) => applyFixedlayoutStyles(doc, viewSettings));
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [themeCode, isDarkMode]);
|
||||
}, [themeCode, isDarkMode, viewSettings?.overrideColor, viewSettings?.invertImgColorInDark]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewRef.current && viewRef.current.renderer) {
|
||||
doubleClickDisabled.current = !!viewSettings?.disableDoubleClick;
|
||||
}
|
||||
}, [viewSettings?.disableDoubleClick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewRef.current && viewRef.current.renderer && viewSettings) {
|
||||
|
||||
@@ -183,10 +183,11 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'footer-bar shadow-xs absolute bottom-0 z-50 flex w-full flex-col',
|
||||
'footer-bar shadow-xs bottom-0 z-50 flex w-full flex-col',
|
||||
'sm:h-[52px] sm:justify-center',
|
||||
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
|
||||
'transition-[opacity,transform] duration-300',
|
||||
appService?.isMobile ? 'fixed' : 'absolute',
|
||||
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
|
||||
isHoveredAnim && 'hover-bar-anim',
|
||||
@@ -203,7 +204,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
{/* Mobile footer bar */}
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-200 absolute bottom-16 flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
|
||||
'footerbar-progress-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
|
||||
actionTab === 'progress'
|
||||
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
|
||||
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
|
||||
@@ -257,7 +258,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
|
||||
'footerbar-font-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
|
||||
actionTab === 'font'
|
||||
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
|
||||
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
|
||||
|
||||
@@ -40,7 +40,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
const { appService } = useEnv();
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
isTrafficLightVisible,
|
||||
trafficLightInFullscreen,
|
||||
setTrafficLightVisibility,
|
||||
initializeTrafficLightStore,
|
||||
initializeTrafficLightListeners,
|
||||
@@ -62,21 +62,27 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
|
||||
initializeTrafficLightStore(appService);
|
||||
initializeTrafficLightListeners();
|
||||
setTrafficLightVisibility(true);
|
||||
return () => {
|
||||
cleanupTrafficLightListeners();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [appService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
if (isSideBarVisible) return;
|
||||
|
||||
setTrafficLightVisibility(isSideBarVisible);
|
||||
if (hoveredBookKey === bookKey && isTopLeft) {
|
||||
setTrafficLightVisibility(true, { x: 10, y: 20 });
|
||||
} else if (!hoveredBookKey) {
|
||||
setTrafficLightVisibility(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSideBarVisible]);
|
||||
}, [appService, isSideBarVisible, hoveredBookKey]);
|
||||
|
||||
const isVisible = hoveredBookKey === bookKey || isDropdownOpen;
|
||||
const isHeaderVisible = hoveredBookKey === bookKey || isDropdownOpen;
|
||||
const trafficLightInHeader =
|
||||
appService?.hasTrafficLight && !trafficLightInFullscreen && !isSideBarVisible && isTopLeft;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -86,14 +92,15 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx('absolute top-0 z-10 hidden h-11 w-full sm:flex')}
|
||||
className={clsx('absolute top-0 z-10 h-11 w-full')}
|
||||
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
|
||||
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-100 absolute left-0 right-0 top-0 z-10',
|
||||
isVisible ? 'visible' : 'hidden',
|
||||
appService?.hasRoundedWindow && 'rounded-window-top-right',
|
||||
isHeaderVisible ? 'visible' : 'hidden',
|
||||
)}
|
||||
style={{
|
||||
height: systemUIVisible ? `${Math.max(gridInsets.top, statusBarHeight)}px` : '0px',
|
||||
@@ -104,11 +111,11 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
className={clsx(
|
||||
`header-bar bg-base-100 absolute top-0 z-10 flex h-11 w-full items-center pr-4`,
|
||||
`shadow-xs transition-[opacity,margin-top] duration-300`,
|
||||
isTrafficLightVisible && isTopLeft && !isSideBarVisible ? 'pl-16' : 'pl-4',
|
||||
trafficLightInHeader ? 'pl-20' : 'pl-4',
|
||||
appService?.hasRoundedWindow && 'rounded-window-top-right',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-top-left',
|
||||
isHoveredAnim && 'hover-bar-anim',
|
||||
isVisible ? 'pointer-events-auto visible' : 'pointer-events-none opacity-0',
|
||||
isHeaderVisible ? 'pointer-events-auto visible' : 'pointer-events-none opacity-0',
|
||||
isDropdownOpen && 'header-bar-pinned',
|
||||
)}
|
||||
style={{
|
||||
@@ -148,10 +155,14 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
className='window-buttons flex h-full items-center'
|
||||
headerRef={headerRef}
|
||||
showMinimize={
|
||||
bookKeys.length == 1 && !isTrafficLightVisible && appService?.appPlatform !== 'web'
|
||||
bookKeys.length == 1 &&
|
||||
!appService?.hasTrafficLight &&
|
||||
appService?.appPlatform !== 'web'
|
||||
}
|
||||
showMaximize={
|
||||
bookKeys.length == 1 && !isTrafficLightVisible && appService?.appPlatform !== 'web'
|
||||
bookKeys.length == 1 &&
|
||||
!appService?.hasTrafficLight &&
|
||||
appService?.appPlatform !== 'web'
|
||||
}
|
||||
onClose={() => {
|
||||
setHoveredBookKey(null);
|
||||
|
||||
@@ -111,14 +111,15 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
settings.globalReadSettings && (
|
||||
<div
|
||||
className={clsx(
|
||||
`reader-page bg-base-100 text-base-content select-none`,
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window',
|
||||
`reader-page text-base-content select-none overflow-hidden`,
|
||||
appService?.isLinuxApp && 'window-border',
|
||||
appService?.hasRoundedWindow && 'rounded-window',
|
||||
)}
|
||||
>
|
||||
<Suspense>
|
||||
<ReaderContent ids={ids} settings={settings} />
|
||||
<AboutWindow />
|
||||
{appService?.isAndroidApp && <UpdaterWindow />}
|
||||
<UpdaterWindow />
|
||||
<Toast />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -5,14 +5,16 @@ import * as React from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { Book } from '@/types/book';
|
||||
import { SystemSettings } from '@/types/settings';
|
||||
import { parseOpenWithFiles } from '@/helpers/openWith';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { tauriHandleClose, tauriHandleOnCloseWindow } from '@/utils/window';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { uniqueId } from '@/utils/misc';
|
||||
@@ -20,10 +22,10 @@ import { throttle } from '@/utils/throttle';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
|
||||
import { BookDetailModal } from '@/components/metadata';
|
||||
|
||||
import useBooksManager from '../hooks/useBooksManager';
|
||||
import useBookShortcuts from '../hooks/useBookShortcuts';
|
||||
import BookDetailModal from '@/components/BookDetailModal';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import SideBar from './sidebar/SideBar';
|
||||
import Notebook from './notebook/Notebook';
|
||||
@@ -83,7 +85,10 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
saveSettings(envConfig, settings);
|
||||
}
|
||||
|
||||
if (isTauriAppPlatform()) tauriHandleOnCloseWindow(handleCloseBooks);
|
||||
let unlistenOnCloseWindow: Promise<UnlistenFn>;
|
||||
if (isTauriAppPlatform()) {
|
||||
unlistenOnCloseWindow = tauriHandleOnCloseWindow(handleCloseBooks);
|
||||
}
|
||||
window.addEventListener('beforeunload', handleCloseBooks);
|
||||
eventDispatcher.on('beforereload', handleCloseBooks);
|
||||
eventDispatcher.on('quit-app', handleCloseBooks);
|
||||
@@ -91,6 +96,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
window.removeEventListener('beforeunload', handleCloseBooks);
|
||||
eventDispatcher.off('beforereload', handleCloseBooks);
|
||||
eventDispatcher.off('quit-app', handleCloseBooks);
|
||||
unlistenOnCloseWindow?.then((fn) => fn());
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookKeys]);
|
||||
@@ -128,7 +134,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
await Promise.all(bookKeys.map((key) => saveConfigAndCloseBook(key)));
|
||||
await saveSettings(envConfig, settings);
|
||||
}, 500);
|
||||
}, 200);
|
||||
|
||||
const handleCloseBooksToLibrary = () => {
|
||||
handleCloseBooks();
|
||||
@@ -143,11 +149,16 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
dismissBook(bookKey);
|
||||
if (bookKeys.filter((key) => key !== bookKey).length == 0) {
|
||||
const openWithFiles = (await parseOpenWithFiles()) || [];
|
||||
if (openWithFiles.length > 0 && !appService?.isMobile) {
|
||||
tauriHandleClose();
|
||||
} else {
|
||||
saveSettingsAndGoToLibrary();
|
||||
if (appService?.hasWindow) {
|
||||
if (openWithFiles.length > 0) {
|
||||
return await tauriHandleClose();
|
||||
}
|
||||
const currentWindow = getCurrentWindow();
|
||||
if (currentWindow.label.startsWith('reader')) {
|
||||
return await currentWindow.close();
|
||||
}
|
||||
}
|
||||
saveSettingsAndGoToLibrary();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -166,7 +177,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('flex', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
|
||||
<div className={clsx('reader-content flex', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
|
||||
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
|
||||
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
|
||||
<Notebook />
|
||||
|
||||
@@ -9,7 +9,7 @@ const Ribbon: React.FC<RibbonProps> = ({}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed inset-0 z-10 flex w-8 justify-center sm:w-6',
|
||||
'ribbon absolute inset-0 z-10 flex w-8 justify-center sm:w-6',
|
||||
'h-[calc(env(safe-area-inset-top)+44px)]',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -3,8 +3,10 @@ import React from 'react';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
|
||||
interface SectionInfoProps {
|
||||
bookKey: string;
|
||||
section?: string;
|
||||
showDoubleBorder: boolean;
|
||||
isScrolled: boolean;
|
||||
@@ -15,6 +17,7 @@ interface SectionInfoProps {
|
||||
}
|
||||
|
||||
const SectionInfo: React.FC<SectionInfoProps> = ({
|
||||
bookKey,
|
||||
section,
|
||||
showDoubleBorder,
|
||||
isScrolled,
|
||||
@@ -24,6 +27,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
|
||||
gridInsets,
|
||||
}) => {
|
||||
const { appService } = useEnv();
|
||||
const { hoveredBookKey } = useReaderStore();
|
||||
const { systemUIVisible, statusBarHeight } = useThemeStore();
|
||||
const topInset = Math.max(
|
||||
gridInsets.top,
|
||||
@@ -68,6 +72,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
|
||||
className={clsx(
|
||||
'text-neutral-content text-center font-sans text-xs font-light',
|
||||
isVertical ? '' : 'line-clamp-1',
|
||||
!isVertical && hoveredBookKey == bookKey && 'hidden',
|
||||
)}
|
||||
>
|
||||
{section || ''}
|
||||
|
||||
@@ -216,7 +216,7 @@ const Notebook: React.FC = ({}) => {
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
className='drag-bar absolute left-0 top-0 -m-3 h-full w-0.5 cursor-col-resize p-3'
|
||||
className='drag-bar absolute left-0 top-0 -m-2 h-full w-0.5 cursor-col-resize bg-transparent p-2'
|
||||
onMouseDown={handleDragStart}
|
||||
onTouchStart={handleDragStart}
|
||||
/>
|
||||
|
||||
@@ -27,6 +27,9 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
|
||||
const [volumeKeysToFlip, setVolumeKeysToFlip] = useState(viewSettings.volumeKeysToFlip!);
|
||||
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
|
||||
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea!);
|
||||
const [isDisableDoubleClick, setIsDisableDoubleClick] = useState(
|
||||
viewSettings.disableDoubleClick!,
|
||||
);
|
||||
const [animated, setAnimated] = useState(viewSettings.animated!);
|
||||
const [allowScript, setAllowScript] = useState(viewSettings.allowScript!);
|
||||
|
||||
@@ -90,6 +93,11 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDisableClick]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'disableDoubleClick', isDisableDoubleClick, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDisableDoubleClick]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'swapClickArea', swapClickArea, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -172,6 +180,15 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
|
||||
onChange={() => setSwapClickArea(!swapClickArea)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Disable Double Click')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={isDisableDoubleClick}
|
||||
onChange={() => setIsDisableDoubleClick(!isDisableDoubleClick)}
|
||||
/>
|
||||
</div>
|
||||
{appService?.isMobileApp && (
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Volume Keys for Page Flip')}</span>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { formatAuthors, formatTitle } from '@/utils/book';
|
||||
|
||||
const BookCard = ({ book }: { book: Book }) => {
|
||||
const { coverImageUrl, title, author } = book;
|
||||
const { coverImageUrl, title, author, metadata } = book;
|
||||
const _ = useTranslation();
|
||||
const { isDarkMode } = useThemeStore();
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
@@ -22,7 +22,7 @@ const BookCard = ({ book }: { book: Book }) => {
|
||||
return (
|
||||
<div className='flex h-20 w-full items-center'>
|
||||
<Image
|
||||
src={coverImageUrl!}
|
||||
src={metadata?.coverImageUrl || coverImageUrl!}
|
||||
alt={_('Book Cover')}
|
||||
width={56}
|
||||
height={80}
|
||||
|
||||
@@ -27,7 +27,7 @@ const SidebarHeader: React.FC<{
|
||||
<div
|
||||
className={clsx(
|
||||
'sidebar-header flex h-11 items-center justify-between pe-2',
|
||||
isTrafficLightVisible ? 'pl-20' : 'ps-1.5',
|
||||
isTrafficLightVisible ? 'ps-1.5 sm:ps-20' : 'ps-1.5',
|
||||
)}
|
||||
dir='ltr'
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { FaSearch, FaChevronDown } from 'react-icons/fa';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
@@ -9,7 +9,9 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { BookSearchConfig, BookSearchResult } from '@/types/book';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { isCJKStr } from '@/utils/lang';
|
||||
import { createRejecttFilter } from '@/utils/node';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import SearchOptions from './SearchOptions';
|
||||
|
||||
@@ -38,6 +40,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
const { getConfig, saveConfig } = useBookDataStore();
|
||||
const { getView, getProgress } = useReaderStore();
|
||||
const [searchTerm, setSearchTerm] = useState(term);
|
||||
const queuedSearchTerm = useRef('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputFocusedRef = useRef(false);
|
||||
|
||||
@@ -48,10 +51,6 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
const primaryLang = bookData.book?.primaryLanguage || 'en';
|
||||
const searchConfig = config.searchConfig! as BookSearchConfig;
|
||||
|
||||
const queuedSearchTerm = useRef('');
|
||||
const isSearchPending = useRef(false);
|
||||
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const iconSize12 = useResponsiveSize(12);
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
|
||||
@@ -95,27 +94,14 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
if (searchTimeout.current) {
|
||||
clearTimeout(searchTimeout.current);
|
||||
}
|
||||
};
|
||||
}, [onHideSearchBar]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setSearchTerm(value);
|
||||
|
||||
if (searchTimeout.current) {
|
||||
clearTimeout(searchTimeout.current);
|
||||
}
|
||||
|
||||
searchTimeout.current = setTimeout(() => {
|
||||
if (!isSearchPending.current) {
|
||||
handleSearchTermChange(value);
|
||||
} else {
|
||||
queuedSearchTerm.current = value;
|
||||
}
|
||||
}, 500);
|
||||
handleSearchTermChange(value);
|
||||
queuedSearchTerm.current = value;
|
||||
};
|
||||
|
||||
const handleSearchConfigChange = (searchConfig: BookSearchConfig) => {
|
||||
@@ -132,67 +118,74 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
return searchTerm.length >= minLength;
|
||||
};
|
||||
|
||||
const handleSearchTermChange = (term: string) => {
|
||||
if (exceedMinSearchTermLength(term)) {
|
||||
handleSearch(term);
|
||||
} else {
|
||||
resetSearch();
|
||||
}
|
||||
};
|
||||
const handleSearch = useCallback(
|
||||
async (term: string) => {
|
||||
console.log('searching for:', term);
|
||||
const { section } = progress;
|
||||
const index = searchConfig.scope === 'section' ? section.current : undefined;
|
||||
const generator = await view.search({
|
||||
...searchConfig,
|
||||
index,
|
||||
query: term,
|
||||
acceptNode: createRejecttFilter({
|
||||
tags: primaryLang.startsWith('ja') ? ['rt'] : [],
|
||||
}),
|
||||
});
|
||||
const results: BookSearchResult[] = [];
|
||||
let lastProgressLogTime = 0;
|
||||
|
||||
const createAcceptNode = ({ withRT = true } = {}) => {
|
||||
return (node: Node): number => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const name = (node as Element).tagName.toLowerCase();
|
||||
if (name === 'script' || name === 'style' || (!withRT && name === 'rt')) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
return NodeFilter.FILTER_SKIP;
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
};
|
||||
};
|
||||
|
||||
const handleSearch = async (term: string) => {
|
||||
console.log('searching for:', term);
|
||||
isSearchPending.current = true;
|
||||
const { section } = progress;
|
||||
const index = searchConfig.scope === 'section' ? section.current : undefined;
|
||||
const generator = await view.search({
|
||||
...searchConfig,
|
||||
index,
|
||||
query: term,
|
||||
acceptNode: createAcceptNode({ withRT: !primaryLang.startsWith('ja') }),
|
||||
});
|
||||
const results: BookSearchResult[] = [];
|
||||
for await (const result of generator) {
|
||||
if (typeof result === 'string') {
|
||||
if (result === 'done') {
|
||||
onSearchResultChange([...results]);
|
||||
isSearchPending.current = false;
|
||||
console.log('search done');
|
||||
if (
|
||||
queuedSearchTerm.current !== term &&
|
||||
exceedMinSearchTermLength(queuedSearchTerm.current)
|
||||
) {
|
||||
handleSearch(queuedSearchTerm.current);
|
||||
const processResults = async () => {
|
||||
for await (const result of generator) {
|
||||
if (typeof result === 'string') {
|
||||
if (result === 'done') {
|
||||
onSearchResultChange([...results]);
|
||||
console.log('search done');
|
||||
}
|
||||
} else {
|
||||
if (result.progress) {
|
||||
const now = Date.now();
|
||||
if (now - lastProgressLogTime >= 1000) {
|
||||
console.log('search progress:', result.progress);
|
||||
lastProgressLogTime = now;
|
||||
}
|
||||
if (queuedSearchTerm.current !== term) {
|
||||
console.log('search term changed, resetting search');
|
||||
resetSearch();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
results.push(result);
|
||||
onSearchResultChange([...results]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (result.progress) {
|
||||
//console.log('search progress:', result.progress);
|
||||
} else {
|
||||
results.push(result);
|
||||
onSearchResultChange([...results]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetSearch = () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
};
|
||||
|
||||
processResults();
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[progress, searchConfig],
|
||||
);
|
||||
|
||||
const resetSearch = useCallback(() => {
|
||||
onSearchResultChange([]);
|
||||
view?.clearSearch();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSearchTermChange = useCallback(
|
||||
debounce((term: string) => {
|
||||
if (exceedMinSearchTermLength(term)) {
|
||||
handleSearch(term);
|
||||
} else {
|
||||
resetSearch();
|
||||
}
|
||||
}, 500),
|
||||
[handleSearch, resetSearch],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='relative p-2'>
|
||||
|
||||
@@ -259,7 +259,7 @@ const SideBar: React.FC<{
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'drag-bar absolute -right-1 top-0 -m-1 h-full w-0.5 cursor-col-resize p-1',
|
||||
'drag-bar absolute right-0 top-0 -m-2 h-full w-0.5 cursor-col-resize bg-transparent p-2',
|
||||
isMobile && 'hidden',
|
||||
)}
|
||||
onMouseDown={handleHorizontalDragStart}
|
||||
|
||||
@@ -81,7 +81,12 @@ const TOCView: React.FC<{
|
||||
}
|
||||
}, [initialize]);
|
||||
|
||||
useTextTranslation(bookKey, containerRef.current, false, 'translation-target-toc');
|
||||
useTextTranslation(
|
||||
bookKey,
|
||||
containerRef.current || staticListRef.current,
|
||||
false,
|
||||
'translation-target-toc',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const updateHeight = () => {
|
||||
@@ -112,7 +117,7 @@ const TOCView: React.FC<{
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [expandedItems]);
|
||||
|
||||
const activeHref = useMemo(() => progress?.sectionHref || null, [progress?.sectionHref]);
|
||||
const flatItems = useFlattenedTOC(toc, expandedItems);
|
||||
@@ -208,7 +213,11 @@ const TOCView: React.FC<{
|
||||
}, [flatItems, scrollToActiveItem]);
|
||||
|
||||
return flatItems.length > 256 ? (
|
||||
<div className='rounded' data-overlayscrollbars-initialize='' ref={containerRef}>
|
||||
<div
|
||||
className='virtual-list rounded pt-2'
|
||||
data-overlayscrollbars-initialize=''
|
||||
ref={containerRef}
|
||||
>
|
||||
<VirtualList
|
||||
ref={vitualListRef}
|
||||
outerRef={listOuterRef}
|
||||
@@ -228,7 +237,7 @@ const TOCView: React.FC<{
|
||||
</VirtualList>
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded pt-2' ref={staticListRef}>
|
||||
<div className='static-list rounded pt-2' ref={staticListRef}>
|
||||
{flatItems.map((flatItem, index) => (
|
||||
<StaticListRow
|
||||
key={`static-row-${index}`}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MdBookmarkBorder as BookmarkIcon } from 'react-icons/md';
|
||||
import { IoIosList as TOCIcon } from 'react-icons/io';
|
||||
import { PiNotePencil as NoteIcon } from 'react-icons/pi';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
const TabNavigation: React.FC<{
|
||||
@@ -11,12 +12,16 @@ const TabNavigation: React.FC<{
|
||||
onTabChange: (tab: string) => void;
|
||||
}> = ({ activeTab, onTabChange }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
|
||||
const tabs = ['toc', 'annotations', 'bookmarks'];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('bottom-tab border-base-300/50 bg-base-200 relative flex w-full border-t')}
|
||||
className={clsx(
|
||||
'bottom-tab border-base-300/50 bg-base-200 relative flex w-full border-t',
|
||||
appService?.hasRoundedWindow && 'rounded-window-bottom-left',
|
||||
)}
|
||||
dir='ltr'
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -212,11 +212,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
|
||||
await ttsController.initViewTTS();
|
||||
const ssml = view.tts?.from(ttsFromRange);
|
||||
if (ssml) {
|
||||
let lang = parseSSMLLang(ssml) || 'en';
|
||||
// We will not trust 'en' language from ssml, as it may be a fallback or hardcoded value
|
||||
if (lang === 'en' && primaryLang && primaryLang !== 'en') {
|
||||
lang = primaryLang.split('-')[0]!;
|
||||
}
|
||||
const lang = parseSSMLLang(ssml, primaryLang) || 'en';
|
||||
setIsPlaying(true);
|
||||
setTtsLang(lang);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { TranslationFunc, useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { getLanguageName } from '@/utils/lang';
|
||||
|
||||
type TTSPanelProps = {
|
||||
bookKey: string;
|
||||
@@ -178,7 +179,19 @@ const TTSPanel = ({
|
||||
useEffect(() => {
|
||||
const fetchVoices = async () => {
|
||||
const voiceGroups = await onGetVoices(ttsLang);
|
||||
setVoiceGroups(voiceGroups);
|
||||
const voicesCount = voiceGroups.reduce((acc, group) => acc + group.voices.length, 0);
|
||||
if (!voiceGroups || voicesCount === 0) {
|
||||
console.warn('No voices found for TTSPanel');
|
||||
setVoiceGroups([
|
||||
{
|
||||
id: 'no-voices',
|
||||
name: _('Voices for {{lang}}', { lang: getLanguageName(ttsLang) }),
|
||||
voices: [],
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setVoiceGroups(voiceGroups);
|
||||
}
|
||||
};
|
||||
fetchVoices();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -289,48 +302,44 @@ const TTSPanel = ({
|
||||
>
|
||||
{voiceGroups.map((voiceGroup, index) => {
|
||||
return (
|
||||
voiceGroup.voices.length > 0 && (
|
||||
<div key={voiceGroup.id} className=''>
|
||||
<div className='flex items-center gap-2 px-2 py-1'>
|
||||
<span
|
||||
style={{ width: `${defaultIconSize}px`, height: `${defaultIconSize}px` }}
|
||||
></span>
|
||||
<span className='text-sm text-gray-400 sm:text-xs'>
|
||||
{_('{{engine}}: {{count}} voices', {
|
||||
engine: _(voiceGroup.name),
|
||||
count: voiceGroup.voices.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{voiceGroup.voices.map((voice, voiceIndex) => (
|
||||
<li
|
||||
key={`${index}-${voiceGroup.id}-${voiceIndex}`}
|
||||
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
|
||||
>
|
||||
<div className='flex items-center px-2'>
|
||||
<span
|
||||
style={{
|
||||
width: `${defaultIconSize}px`,
|
||||
height: `${defaultIconSize}px`,
|
||||
}}
|
||||
>
|
||||
{selectedVoice === voice.id && (
|
||||
<MdCheck className='text-base-content' />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-base sm:text-sm',
|
||||
voice.disabled && 'text-gray-400',
|
||||
)}
|
||||
>
|
||||
{_(voice.name)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<div key={voiceGroup.id} className=''>
|
||||
<div className='flex items-center gap-2 px-2 py-1'>
|
||||
<span
|
||||
style={{ width: `${defaultIconSize}px`, height: `${defaultIconSize}px` }}
|
||||
></span>
|
||||
<span className='text-sm text-gray-400 sm:text-xs'>
|
||||
{_('{{engine}}: {{count}} voices', {
|
||||
engine: _(voiceGroup.name),
|
||||
count: voiceGroup.voices.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
{voiceGroup.voices.map((voice, voiceIndex) => (
|
||||
<li
|
||||
key={`${index}-${voiceGroup.id}-${voiceIndex}`}
|
||||
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
|
||||
>
|
||||
<div className='flex items-center px-2'>
|
||||
<span
|
||||
style={{
|
||||
width: `${defaultIconSize}px`,
|
||||
height: `${defaultIconSize}px`,
|
||||
}}
|
||||
>
|
||||
{selectedVoice === voice.id && <MdCheck className='text-base-content' />}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'max-w-[180px] overflow-hidden text-ellipsis text-base sm:text-sm',
|
||||
voice.disabled && 'text-gray-400',
|
||||
)}
|
||||
>
|
||||
{_(voice.name)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function useTextTranslation(
|
||||
if (!view || !enabled.current) return;
|
||||
const observer = createTranslationObserver();
|
||||
observerRef.current = observer;
|
||||
const nodes = walkTextNodes(view);
|
||||
const nodes = walkTextNodes(view, ['pre', 'code', 'math']);
|
||||
console.log('Observing text nodes for translation:', nodes.length);
|
||||
allTextNodes.current = nodes;
|
||||
nodes.forEach((el) => observer.observe(el));
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { checkForAppUpdates } from '@/helpers/updater';
|
||||
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
|
||||
import Reader from './components/Reader';
|
||||
|
||||
// This is only used for the Tauri app in the app router
|
||||
@@ -20,11 +20,13 @@ export default function Page() {
|
||||
const doCheckAppUpdates = async () => {
|
||||
if (appService?.hasUpdater && settings.autoCheckUpdates) {
|
||||
await checkForAppUpdates(_);
|
||||
} else if (appService?.hasUpdater === false) {
|
||||
checkAppReleaseNotes();
|
||||
}
|
||||
};
|
||||
doCheckAppUpdates();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings]);
|
||||
}, [appService?.hasUpdater, settings.autoCheckUpdates]);
|
||||
|
||||
return <Reader />;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import {
|
||||
DISABLE_DOUBLE_CLICK_ON_MOBILE,
|
||||
DOUBLE_CLICK_INTERVAL_THRESHOLD_MS,
|
||||
LONG_HOLD_THRESHOLD,
|
||||
} from '@/services/constants';
|
||||
import { DOUBLE_CLICK_INTERVAL_THRESHOLD_MS, LONG_HOLD_THRESHOLD } from '@/services/constants';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
|
||||
const doubleClickEnabled =
|
||||
!DISABLE_DOUBLE_CLICK_ON_MOBILE || !['android', 'ios'].includes(getOSPlatform());
|
||||
|
||||
let lastClickTime = 0;
|
||||
let longHoldTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -93,10 +85,14 @@ export const handleWheel = (bookKey: string, event: WheelEvent) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const handleClick = (bookKey: string, event: MouseEvent) => {
|
||||
export const handleClick = (
|
||||
bookKey: string,
|
||||
doubleClickDisabled: React.MutableRefObject<boolean>,
|
||||
event: MouseEvent,
|
||||
) => {
|
||||
const now = Date.now();
|
||||
|
||||
if (doubleClickEnabled && now - lastClickTime < DOUBLE_CLICK_INTERVAL_THRESHOLD_MS) {
|
||||
if (!doubleClickDisabled.current && now - lastClickTime < DOUBLE_CLICK_INTERVAL_THRESHOLD_MS) {
|
||||
lastClickTime = now;
|
||||
window.postMessage(
|
||||
{
|
||||
@@ -156,7 +152,7 @@ export const handleClick = (bookKey: string, event: MouseEvent) => {
|
||||
'*',
|
||||
);
|
||||
};
|
||||
if (doubleClickEnabled) {
|
||||
if (!doubleClickDisabled.current) {
|
||||
setTimeout(() => {
|
||||
if (Date.now() - lastClickTime >= DOUBLE_CLICK_INTERVAL_THRESHOLD_MS) {
|
||||
postSingleClick();
|
||||
|
||||
@@ -16,7 +16,7 @@ const UpdaterPage = () => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='px-12 py-4'>
|
||||
<div className='bg-base-100 px-12 py-4'>
|
||||
<UpdaterContent />
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { UserPlan } from '@/types/user';
|
||||
|
||||
@@ -48,16 +49,19 @@ interface AccountActionsProps {
|
||||
userPlan: UserPlan;
|
||||
onLogout: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onManageSubscription: () => void;
|
||||
onRestorePurchase?: () => void;
|
||||
onManageSubscription?: () => void;
|
||||
}
|
||||
|
||||
const AccountActions: React.FC<AccountActionsProps> = ({
|
||||
userPlan,
|
||||
onLogout,
|
||||
onConfirmDelete,
|
||||
onRestorePurchase,
|
||||
onManageSubscription,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
|
||||
|
||||
const handleDeleteRequest = () => {
|
||||
@@ -79,13 +83,22 @@ const AccountActions: React.FC<AccountActionsProps> = ({
|
||||
}}
|
||||
/>
|
||||
<div className='flex flex-col gap-4 md:flex-row'>
|
||||
{userPlan !== 'free' && (
|
||||
{appService?.isIOSApp ? (
|
||||
<button
|
||||
onClick={onManageSubscription}
|
||||
onClick={onRestorePurchase}
|
||||
className='w-full rounded-lg bg-blue-100 px-6 py-3 font-medium text-blue-600 transition-colors hover:bg-blue-200 md:w-auto'
|
||||
>
|
||||
{_('Manage Subscription')}
|
||||
{_('Restore Purchase')}
|
||||
</button>
|
||||
) : (
|
||||
userPlan !== 'free' && (
|
||||
<button
|
||||
onClick={onManageSubscription}
|
||||
className='w-full rounded-lg bg-blue-100 px-6 py-3 font-medium text-blue-600 transition-colors hover:bg-blue-200 md:w-auto'
|
||||
>
|
||||
{_('Manage Subscription')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={onLogout}
|
||||
|
||||
@@ -22,7 +22,10 @@ const ProfileHeader: React.FC<ProfileHeaderProps> = ({ onGoBack }) => {
|
||||
appService?.hasTrafficLight && 'pt-11',
|
||||
)}
|
||||
>
|
||||
<button onClick={onGoBack} className={clsx('btn btn-ghost h-8 min-h-8 w-8 p-0')}>
|
||||
<button
|
||||
onClick={onGoBack}
|
||||
className={clsx('btn btn-ghost h-12 min-h-12 w-12 p-0 sm:h-8 sm:min-h-8 sm:w-8')}
|
||||
>
|
||||
<IoArrowBack className='text-base-content' />
|
||||
</button>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IoCheckmark } from 'react-icons/io5';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
import { PlanDetails } from '../utils/plan';
|
||||
import PlanActionButton from './PlanActionButton';
|
||||
|
||||
@@ -25,6 +26,10 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
onSelectPlan,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { price, currency } = plan;
|
||||
const formattedPrice = new Intl.NumberFormat(getLocale(), { style: 'currency', currency }).format(
|
||||
price / 100,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -38,7 +43,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
<div className='mb-6 text-center'>
|
||||
<h4 className='mb-2 text-2xl font-bold'>{_(plan.name)}</h4>
|
||||
<div className='text-3xl font-bold'>
|
||||
{`$${(plan.price / 100).toFixed(2)}`}
|
||||
{formattedPrice}
|
||||
<span className='text-lg font-normal'>/{_(plan.interval)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user