Compare commits

..

57 Commits

Author SHA1 Message Date
Huang Xin a5f18ab024 release: version 0.9.67 (#1664) 2025-07-22 16:11:00 +02:00
Huang Xin d1f5934947 library: various fixes on metadata editor and bookshelf (#1663)
* i18n: update translations

* library: various fixes on metadata editor and bookshelf

* chore: enable parallel web builds checking
2025-07-22 16:04:44 +02:00
Huang Xin f03d6f6861 feat: search in book format, group names and descriptions (#1662) 2025-07-22 11:40:14 +02:00
Huang Xin 20523b7eaf PDF: Support custom background theming for PDF files, closes #1649 (#1661) 2025-07-22 11:34:38 +02:00
Huang Xin f31c9b2a98 layout: fix hardcoded image layout in fixed layout documents (#1660) 2025-07-22 10:40:21 +02:00
Huang Xin 93071e727f linux: fixed multiple instances created in OAuth, closes #1654 (#1659) 2025-07-22 09:00:19 +02:00
Huang Xin 2d12210606 css: multiply img color in light mode when overriding book color (#1656)
This fixed image color issue for EPUBs in #1649.
2025-07-21 18:17:16 +02:00
Huang Xin 88ccc528d4 layout: hover header to show traffic light window control on macOS, closes #1645 (#1653) 2025-07-21 17:16:58 +02:00
Yurii Kostyukov 9d3fc07865 feat: Added Yandex Translator (#1652) 2025-07-21 17:13:13 +02:00
Huang Xin 6d465a40fb fix: don't use comma as separator when parsing filenames, closes #1622 (#1650) 2025-07-21 15:32:10 +02:00
Huang Xin 01c7e4c2e2 chore: fixed failed AppImage builds for Linux 2025-07-21 20:39:57 +08:00
Huang Xin 4005858242 release: version 0.9.66 (#1643) 2025-07-20 23:12:40 +02:00
Huang Xin c24f951375 fix: set xdg-mime with mime type other than scheme, closes #1621 (#1641) 2025-07-21 05:02:22 +08:00
Huang Xin dbe1efd1e9 tts: convert iso6392 language code to iso6391 to filter tts voices, closes #1627 (#1639) 2025-07-20 22:10:14 +02:00
Huang Xin 0299cce343 library: fix book not redownloaded for the redownload button in detail modal, closes #1628 (#1638) 2025-07-20 21:00:29 +02:00
Huang Xin ca9f504329 layout: fix layout for auth and user page (#1637) 2025-07-20 20:05:55 +02:00
Huang Xin df3527ceca fix: chaining file open with OS opening the highest precedence, closes #1622 (#1636) 2025-07-20 17:09:19 +02:00
Huang Xin 73d0838291 doc: run a preview build to identify compatibility issues between OpenNext, Next.js, and Supabase (#1635) 2025-07-20 11:32:00 +02:00
Huang Xin 8e3574509e chore: downgrade next.js to 15.3 (#1634)
Until https://github.com/opennextjs/opennextjs-cloudflare/issues/667 is fixed.
2025-07-20 11:12:06 +02:00
Huang Xin 9bdf375304 api: fix cors for api with new nextjs version (#1633) 2025-07-20 10:24:28 +02:00
Huang Xin effa922597 chore: bump tauri, next and zustand to latest versions (#1631) 2025-07-20 09:42:32 +02:00
Huang Xin fe484257a0 doc: add deepwiki badge and link (#1630) 2025-07-20 07:02:14 +02:00
Huang Xin cc0cd9a114 layout: fix nested toc items not expanded in very long toc list, closes #1625 (#1629) 2025-07-20 06:42:31 +02:00
Huang Xin 916d63806e chore: fix loading chunk error of optional chaining for Android WebView below 80 (#1626) 2025-07-19 20:24:30 +02:00
Huang Xin 59d0118067 chore: handle ChunkLoadError by refreshing page (#1619) 2025-07-19 06:37:13 +02:00
Huang Xin 957c6b9f32 fix: update book cover image from metadata in sidebar (#1615) 2025-07-18 19:11:02 +02:00
Huang Xin 9c5a1147fa release: version 0.9.65 (#1614) 2025-07-18 18:31:38 +02:00
Huang Xin 60ad6706ff ios: skip context menu when long-press on book cover, closes #1612 (#1613) 2025-07-18 18:22:59 +02:00
Huang Xin d9199d1182 sync: also sync book metadata (#1611) 2025-07-18 17:59:05 +02:00
Huang Xin 219edb9faf translation: fix translation not working for table of contents (#1610) 2025-07-18 11:54:26 +02:00
Huang Xin 30a0227ef3 layout: unset text indent inside of list elements (#1609) 2025-07-18 11:26:28 +02:00
Huang Xin 29b5a7cb88 tts: skip text to speech for rubys and footnote anchors, closes #1334 (#1608) 2025-07-18 11:10:12 +02:00
Huang Xin abb727870e tts: handle invalid language code and show no voices hints, closes #1579 (#1607) 2025-07-18 09:27:51 +02:00
Dave Vasilevsky af84a8b110 chore: match dev-web command to docs (#1606) 2025-07-18 06:50:41 +02:00
Huang Xin 0dea5d3094 layout: maintain the layout of anchor elements while increasing their tap target size, closes #1603 (#1605) 2025-07-18 06:26:54 +02:00
Huang Xin 527b95521e fix: replace fallback book cover with new cover image (#1604) 2025-07-18 06:03:22 +02:00
Huang Xin 396b23ddb4 css: named container classes for easier css customization, closes #1598 (#1600) 2025-07-17 20:17:51 +02:00
Huang Xin ee0d1549bd layout: add window borders on Linux, closes #1570 (#1599) 2025-07-18 01:40:12 +08:00
Huang Xin 6da6b85530 feat: support multiple reader windows on desktop, closes #259 (#1596) 2025-07-17 10:21:37 +02:00
StepanSad cde055dcb7 Update translation.json (#1595) 2025-07-17 05:03:39 +02:00
Huang Xin 08957ce3d7 release: version 0.9.64 (#1589) 2025-07-15 19:40:03 +02:00
Huang Xin 33515f1f09 fix: save custom cover images in apps (#1588) 2025-07-15 19:33:13 +02:00
Huang Xin e3c411f043 feat: support to edit book metadata, closes #753 (#1583) 2025-07-14 19:46:57 +02:00
StepanSad eda71725da Update translation.json (#1581) 2025-07-14 20:09:46 +08:00
StepanSad f19f45c00e Fixed bad Ukrainian translation. (#1576) 2025-07-12 18:01:41 +02:00
Huang Xin 6fb7897092 chore: flatpak with custom oauth (#1574) 2025-07-11 11:28:43 +08:00
Huang Xin 2de941d7fb doc: add appdata metainfo for Flathub (#1569) 2025-07-10 14:10:40 +02:00
Huang Xin 3e4b7ebb13 css: remove unintended indent for images, closes #1567 (#1568)
* chore: target Android SDK to version 36

* css: remove unintended indent for images, closes #1567
2025-07-10 12:05:07 +02:00
Huang Xin 2b52f82b57 fix: bookmark ribbon is now placed correctly when sidebar is pinned (#1565) 2025-07-09 18:23:55 +02:00
Huang Xin 9fb7e80757 fix: pull down to refresh is now more smoother and responsive (#1564) 2025-07-09 18:01:33 +02:00
Huang Xin 38869ce438 search: more responsive full-text search, closes #1558 (#1562) 2025-07-09 12:36:22 +02:00
Huang Xin 1dbf93c1cf css: lighten the highlight color in dark mode, closes #1489 (#1561) 2025-07-09 10:41:40 +02:00
Huang Xin aafcab8232 layout: fix dimension of inline images, closes #1555 (#1560) 2025-07-09 09:52:51 +02:00
Huang Xin bcfe1b8830 fix: invalid href handling in EPUB (#1557) 2025-07-09 04:57:21 +02:00
Huang Xin 5db1847f7e layout: add window border on Windows 10, closes #1551 (#1556) 2025-07-08 17:24:43 +02:00
Huang Xin 165ca4a6e9 chore: add fastlane action to release to Google Play (#1554) 2025-07-08 16:18:53 +02:00
Huang Xin 9556e1305f updater: show update notes for new releases (#1552) 2025-07-08 12:13:47 +02:00
137 changed files with 6067 additions and 1806 deletions
+13 -1
View File
@@ -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:
@@ -54,6 +59,13 @@ jobs:
pnpm install && pnpm setup-pdfjs
- name: build the web App
if: matrix.config.platform == 'web'
working-directory: apps/readest-app
run: |
pnpm build-web
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
+2
View File
@@ -38,3 +38,5 @@ next-env.d.ts
# Rust build
target
fastlane/report.xml
+3
View File
@@ -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
Generated
+366 -270
View File
File diff suppressed because it is too large Load Diff
+19 -1
View File
@@ -2,9 +2,24 @@
members = [
"apps/readest-app/src-tauri",
"packages/tauri/crates/tauri",
"packages/tauri/crates/tauri-utils",
"packages/tauri/crates/tauri-build",
"packages/tauri-plugins/plugins/deep-link",
]
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"
serde_json = "1"
thiserror = "2"
url = "2"
[workspace.package]
authors = ["Bilingify LLC"]
homepage = "https://readest.com"
@@ -15,4 +30,7 @@ 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-deep-link = { path = "packages/tauri-plugins/plugins/deep-link" }
+6
View File
@@ -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
+20 -2
View File
@@ -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));
+21 -16
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.63",
"version": "0.9.67",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -35,7 +35,10 @@
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare preview",
"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",
@@ -49,18 +52,18 @@
"@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",
"aws4fetch": "^1.0.20",
"clsx": "^2.1.1",
@@ -72,6 +75,7 @@
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.1",
"iso-639-2": "^3.0.2",
"js-md5": "^0.8.3",
"jwt-decode": "^4.0.0",
"marked": "^15.0.12",
@@ -89,11 +93,12 @@
"semver": "^7.7.1",
"stripe": "^18.2.1",
"tinycolor2": "^1.6.0",
"zustand": "5.0.1"
"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",
"@types/cors": "^2.8.17",
"@types/cssbeautify": "^0.3.5",
"@types/node": "^22.10.1",
@@ -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": "القسم السابق",
@@ -433,5 +431,43 @@
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "احفظ واوصل لمجموعة قراءتك كاملة بـ 10 جيجابايت تخزين سحابي آمن.",
"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"
}
@@ -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",
@@ -417,5 +415,43 @@
"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.",
"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"
}
@@ -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": "Προηγούμενη ενότητα",
@@ -417,5 +415,43 @@
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Αποθηκεύστε και αποκτήστε πρόσβαση στη συλλογή ανάγνωσης με έως 10 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"
}
@@ -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",
@@ -421,5 +419,43 @@
"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.",
"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"
}
@@ -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",
@@ -421,5 +419,43 @@
"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"
}
@@ -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": "पिछला अनुभाग",
@@ -417,5 +415,43 @@
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "10 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"
}
@@ -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",
@@ -413,5 +411,43 @@
"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.",
"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"
}
@@ -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",
@@ -421,5 +419,43 @@
"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.",
"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"
}
@@ -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": "前のセクション",
@@ -413,5 +411,43 @@
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "最大10GBの安全なクラウドストレージで読書コレクション全体を保存・アクセス。",
"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"
}
@@ -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": "이전 섹션",
@@ -413,5 +411,43 @@
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "최대 10GB의 안전한 클라우드 저장소로 전체 독서 컬렉션을 저장하고 액세스하세요.",
"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"
}
@@ -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",
@@ -417,5 +415,43 @@
"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.",
"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"
}
@@ -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",
@@ -425,5 +423,43 @@
"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.",
"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"
}
@@ -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",
@@ -421,5 +419,43 @@
"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.",
"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"
}
@@ -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": "Предыдущий раздел",
@@ -425,5 +423,43 @@
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Безопасно храните и получайте доступ ко всей коллекции чтения с до 10 ГБ облачного хранилища.",
"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"
}
@@ -365,15 +365,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 +382,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 +411,43 @@
"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"
}
@@ -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",
@@ -417,5 +415,43 @@
"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.",
"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"
}
@@ -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}} сторінка у розділі",
@@ -385,45 +383,83 @@
"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 ГБ Хмарного Сховища",
"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 ГБ Хмарного Сховища",
"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 ГБ хмарного сховища.",
"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"
}
@@ -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",
@@ -413,5 +411,43 @@
"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.",
"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"
}
@@ -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": "重试",
@@ -413,5 +411,43 @@
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "使用多达 10GB 的安全云存储保存和访问您的整个阅读收藏。",
"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"
}
@@ -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": "上一章",
@@ -413,5 +411,43 @@
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "使用多達 10GB 的安全雲端儲存保存和存取您的整個閱讀收藏。",
"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"
}
+47 -1
View File
@@ -1,5 +1,51 @@
{
"releases": {
"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 +54,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",
@@ -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
+16 -17
View File
@@ -15,7 +15,7 @@ name = "readestlib"
crate-type = ["staticlib", "cdylib", "lib"]
[build-dependencies]
tauri-build = { version = "2.1.1", features = [] }
tauri-build = { version = "2", features = [] }
[dependencies]
serde_json = "1.0"
@@ -30,21 +30,20 @@ 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 +60,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",
@@ -68,6 +68,9 @@
{
"url": "https://edge.microsoft.com"
},
{
"url": "https://translate.toil.cc"
},
{
"url": "https://*.microsofttranslator.com"
},
@@ -91,6 +94,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",
@@ -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]
@@ -5,7 +5,7 @@ plugins {
android {
namespace = "com.readest.native_bridge"
compileSdk = 35
compileSdk = 36
defaultConfig {
minSdk = 21
@@ -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
+18 -33
View File
@@ -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)
}))
}
}
+1 -1
View File
@@ -15,7 +15,7 @@
"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",
@@ -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,
});
}
}
+88 -53
View File
@@ -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>
) : (
+2
View File
@@ -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 = () => {
@@ -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';
@@ -169,11 +169,17 @@ const Bookshelf: React.FC<BookshelfProps> = ({
};
const openSelectedBooks = () => {
setTimeout(() => setLoading(true), 200);
navigateToReader(router, selectedBooks);
handleSetSelectMode(false);
if (appService?.hasWindow && settings.openBookInNewWindow) {
showReaderWindow(appService, selectedBooks);
} else {
setTimeout(() => setLoading(true), 200);
navigateToReader(router, selectedBooks);
}
};
const openBookDetails = () => {
handleSetSelectMode(false);
const book = libraryBooks.find((book) => book.hash === selectedBooks[0]);
if (book) {
handleShowDetailsBook(book);
@@ -216,7 +222,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') || '';
@@ -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';
@@ -117,8 +119,9 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
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();
book.coverDownloadedAt = Date.now();
updateBook(envConfig, book);
}
return true;
@@ -137,24 +140,36 @@ 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 {
if (!(await makeBookAvailable(book))) return;
if (appService?.hasWindow && settings.openBookInNewWindow) {
showReaderWindow(appService, [book.hash]);
} else {
navigateToReader(router, [book.hash]);
}
}
},
// 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);
navigateToLibrary(router, `${params.toString()}`);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isSelectMode, searchParams],
);
const bookContextMenuHandler = async (book: Book) => {
if (!appService?.hasContextMenu) return;
@@ -236,8 +251,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 +262,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),
[],
);
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 +320,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={{
@@ -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);
@@ -219,6 +226,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,7 +70,7 @@ 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);
}
const mergedBook =
+101 -22
View File
@@ -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);
@@ -476,9 +511,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}
};
const handleBookDownload = async (book: Book) => {
const handleBookDownload = async (book: Book, redownload = false) => {
try {
await appService?.downloadBook(book, false, (progress) => {
await appService?.downloadBook(book, false, redownload, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
@@ -550,15 +585,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 +672,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 +693,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 +758,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}%`} />}
@@ -158,6 +158,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
)}
{showHeader && (
<SectionInfo
bookKey={bookKey}
section={sectionLabel}
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
isScrolled={viewSettings.scrolled}
@@ -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';
@@ -129,6 +135,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);
@@ -242,7 +254,7 @@ 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);
const animated = viewSettings.animated!;
const maxColumnCount = viewSettings.maxColumnCount!;
@@ -305,9 +317,13 @@ 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 && viewSettings) {
@@ -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}
/>
@@ -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>
+4 -2
View File
@@ -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 />;
}
@@ -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>
@@ -44,13 +44,16 @@ const PlansComparison: React.FC<PlansComparisonProps> = ({
};
const handleTouchStart = (e: React.TouchEvent) => {
const touchStart = e.touches[0]!.clientX;
const touchStartX = e.touches[0]!.clientX;
const touchStartY = e.touches[0]!.clientY;
const handleTouchMove = (moveEvent: TouchEvent) => {
const touchEnd = moveEvent.touches[0]!.clientX;
const diff = touchStart - touchEnd;
const touchEndX = moveEvent.touches[0]!.clientX;
const touchEndY = moveEvent.touches[0]!.clientY;
const diffX = touchStartX - touchEndX;
const diffY = touchStartY - touchEndY;
if (Math.abs(diff) > 50) {
if (diff > 0) {
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
if (diffX > 0) {
handlePlanSwipe('left');
} else {
handlePlanSwipe('right');
+57 -49
View File
@@ -69,7 +69,7 @@ const ProfilePage = () => {
useTheme({ systemUIVisible: false });
const { quotas, userPlan } = useQuotaStats();
const { quotas, userPlan = 'free' } = useQuotaStats();
const handleGoBack = () => {
if (showEmbeddedCheckout) {
@@ -191,7 +191,7 @@ const ProfilePage = () => {
useEffect(() => {
fetch(WEB_STRIPE_PLANS_URL)
.then((res) => res.json())
.then((data) => setAvailablePlans(data));
.then((data) => setAvailablePlans(data || []));
}, []);
if (!mounted) {
@@ -219,62 +219,70 @@ const ProfilePage = () => {
return (
<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 select-none overflow-hidden',
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
appService?.isLinuxApp && 'window-border',
appService?.hasRoundedWindow && 'rounded-window',
)}
>
<ProfileHeader onGoBack={handleGoBack} />
<div className='w-full min-w-60 max-w-4xl py-10'>
{loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
<div
className={clsx(
'flex h-full w-full flex-col items-center overflow-y-auto',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
)}
{showEmbeddedCheckout ? (
<div className='bg-base-100 rounded-lg p-4'>
<Checkout
clientSecret={checkoutState.clientSecret}
sessionId={checkoutState.sessionId}
planName={checkoutState.planName}
onSuccess={handleCheckoutSuccess}
/>
</div>
) : (
<div className='sm:bg-base-200 overflow-hidden rounded-lg sm:p-6 sm:shadow-md'>
<div className='flex flex-col gap-y-8'>
<div className='flex flex-col gap-y-8 px-6'>
<UserInfo
avatarUrl={avatarUrl}
userFullName={userFullName}
userEmail={userEmail}
planDetails={planDetails}
/>
>
<ProfileHeader onGoBack={handleGoBack} />
<div className='w-full min-w-60 max-w-4xl py-10'>
{loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
)}
{showEmbeddedCheckout ? (
<div className='bg-base-100 rounded-lg p-4'>
<Checkout
clientSecret={checkoutState.clientSecret}
sessionId={checkoutState.sessionId}
planName={checkoutState.planName}
onSuccess={handleCheckoutSuccess}
/>
</div>
) : (
<div className='sm:bg-base-200 overflow-hidden rounded-lg sm:p-6 sm:shadow-md'>
<div className='flex flex-col gap-y-8'>
<div className='flex flex-col gap-y-8 px-6'>
<UserInfo
avatarUrl={avatarUrl}
userFullName={userFullName}
userEmail={userEmail}
planDetails={planDetails}
/>
<UsageStats quotas={quotas} />
</div>
<UsageStats quotas={quotas} />
</div>
<div className='flex flex-col gap-y-8 sm:px-6'>
<PlansComparison
availablePlans={availablePlans}
userPlan={userPlan}
onSubscribe={handleSubscribe}
/>
</div>
<div className='flex flex-col gap-y-8 sm:px-6'>
<PlansComparison
availablePlans={availablePlans}
userPlan={userPlan}
onSubscribe={handleSubscribe}
/>
</div>
<div className='flex flex-col gap-y-8 px-6'>
<AccountActions
userPlan={userPlan}
onLogout={handleLogout}
onConfirmDelete={handleConfirmDelete}
onManageSubscription={handleManageSubscription}
/>
<div className='flex flex-col gap-y-8 px-6'>
<AccountActions
userPlan={userPlan}
onLogout={handleLogout}
onConfirmDelete={handleConfirmDelete}
onManageSubscription={handleManageSubscription}
/>
</div>
</div>
</div>
</div>
)}
)}
</div>
<Toast />
</div>
<Toast />
</div>
);
};
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { checkForAppUpdates } from '@/helpers/updater';
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
import { parseWebViewVersion } from '@/utils/ua';
import { getAppVersion } from '@/utils/version';
import Dialog from './Dialog';
@@ -50,9 +50,9 @@ export const AboutWindow = () => {
const handleCheckUpdate = async () => {
setUpdateStatus('checking');
try {
const update = await checkForAppUpdates(_, false);
if (update) {
setIsOpen(false);
const hasUpdate = await checkForAppUpdates(_, false);
if (hasUpdate) {
handleClose();
} else {
setUpdateStatus('updated');
}
@@ -62,6 +62,15 @@ export const AboutWindow = () => {
}
};
const handleShowRecentUpdates = async () => {
const hasNotes = await checkAppReleaseNotes(false);
if (hasNotes) {
handleClose();
} else {
setUpdateStatus('error');
}
};
const handleClose = () => {
setIsOpen(false);
setUpdateStatus(null);
@@ -87,8 +96,11 @@ export const AboutWindow = () => {
</p>
</div>
<div className='h-5'>
{appService?.hasUpdater && !updateStatus && (
<span className='badge badge-primary cursor-pointer p-1' onClick={handleCheckUpdate}>
{!updateStatus && (
<span
className='badge badge-primary cursor-pointer p-2'
onClick={appService?.hasUpdater ? handleCheckUpdate : handleShowRecentUpdates}
>
{_('Check Update')}
</span>
)}
+28 -13
View File
@@ -1,5 +1,6 @@
import clsx from 'clsx';
import Image from 'next/image';
import { useEffect, useRef } from 'react';
import { Book } from '@/types/book';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { formatAuthors, formatTitle } from '@/utils/book';
@@ -21,26 +22,40 @@ const BookCover: React.FC<BookCoverProps> = ({
imageClassName,
isPreview,
}) => {
const handleImageError = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.target as HTMLImageElement;
img.style.display = 'none';
const coverRef = useRef<HTMLDivElement>(null);
const mainContainer = img.closest('.book-cover-container');
const fallbackDiv = mainContainer?.querySelector('.fallback-cover');
if (fallbackDiv) {
fallbackDiv.classList.remove('invisible');
const toggleImageVisibility = (showImage: boolean) => {
if (coverRef.current) {
const coverImage = coverRef.current.querySelector('.cover-image');
const fallbackCover = coverRef.current.querySelector('.fallback-cover');
if (coverImage) {
coverImage.classList.toggle('invisible', !showImage);
}
if (fallbackCover) {
fallbackCover.classList.toggle('invisible', showImage);
}
}
};
const handleImageError = () => {
toggleImageVisibility(false);
};
useEffect(() => {
toggleImageVisibility(true);
}, [book.metadata?.coverImageUrl, book.coverImageUrl]);
return (
<div className={clsx('book-cover-container relative flex h-full w-full', className)}>
<div
ref={coverRef}
className={clsx('book-cover-container relative flex h-full w-full', className)}
>
{coverFit === 'crop' ? (
<Image
src={book.coverImageUrl!}
src={book.metadata?.coverImageUrl || book.coverImageUrl!}
alt={book.title}
fill={true}
className={clsx('crop-cover-img object-cover', imageClassName)}
className={clsx('cover-image crop-cover-img object-cover', imageClassName)}
onError={handleImageError}
/>
) : (
@@ -51,13 +66,13 @@ const BookCover: React.FC<BookCoverProps> = ({
)}
>
<Image
src={book.coverImageUrl!}
src={book.metadata?.coverImageUrl || book.coverImageUrl!}
alt={book.title}
width={0}
height={0}
sizes='100vw'
className={clsx(
'fit-cover-img h-auto max-h-full w-auto max-w-full shadow-md',
'cover-image fit-cover-img h-auto max-h-full w-auto max-w-full shadow-md',
imageClassName,
)}
onError={handleImageError}
@@ -1,273 +0,0 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import {
MdOutlineDelete,
MdOutlineCloudDownload,
MdOutlineCloudUpload,
MdOutlineCloudOff,
} from 'react-icons/md';
import { Book } from '@/types/book';
import { BookDoc } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import {
formatAuthors,
formatDate,
formatFileSize,
formatLanguage,
formatPublisher,
formatSubject,
formatTitle,
} from '@/utils/book';
import Alert from '@/components/Alert';
import Dialog from './Dialog';
import Spinner from './Spinner';
import BookCover from './BookCover';
interface BookDetailModalProps {
book: Book;
isOpen: boolean;
onClose: () => void;
handleBookDownload?: (book: Book) => void;
handleBookUpload?: (book: Book) => void;
handleBookDelete?: (book: Book) => void;
handleBookDeleteCloudBackup?: (book: Book) => void;
}
const BookDetailModal = ({
book,
isOpen,
onClose,
handleBookDownload,
handleBookUpload,
handleBookDelete,
handleBookDeleteCloudBackup,
}: BookDetailModalProps) => {
const _ = useTranslation();
const [loading, setLoading] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [showDeleteCloudBackupAlert, setShowDeleteCloudBackupAlert] = useState(false);
const [bookMeta, setBookMeta] = useState<BookDoc['metadata'] | null>(null);
const [fileSize, setFileSize] = useState<number | null>(null);
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
useEffect(() => {
const loadingTimeout = setTimeout(() => setLoading(true), 300);
const fetchBookDetails = async () => {
const appService = await envConfig.getAppService();
try {
const details = await appService.fetchBookDetails(book, settings);
const size = await appService.getBookFileSize(book);
setBookMeta(details);
setFileSize(size);
} finally {
if (loadingTimeout) clearTimeout(loadingTimeout);
setLoading(false);
}
};
fetchBookDetails();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [book]);
const handleClose = () => {
setBookMeta(null);
onClose();
};
const handleDelete = () => {
setShowDeleteAlert(true);
};
const handleDeleteCloudBackup = () => {
setShowDeleteCloudBackupAlert(true);
};
const confirmDelete = async () => {
handleClose();
setShowDeleteAlert(false);
if (handleBookDelete) {
handleBookDelete(book);
}
};
const confirmDeleteCloudBackup = async () => {
handleClose();
setShowDeleteCloudBackupAlert(false);
if (handleBookDeleteCloudBackup) {
handleBookDeleteCloudBackup(book);
}
};
const handleRedownload = async () => {
handleClose();
if (handleBookDownload) {
handleBookDownload(book);
}
};
const handleReupload = async () => {
handleClose();
if (handleBookUpload) {
handleBookUpload(book);
}
};
if (!bookMeta)
return (
loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
)
);
return (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Dialog
title={_('Book Details')}
isOpen={isOpen}
onClose={handleClose}
bgClassName='sm:bg-black/50'
boxClassName='sm:min-w-[480px] sm:max-w-[480px] sm:h-auto sm:max-h-[90%]'
contentClassName='!px-6 !py-2'
>
<div className='flex w-full select-text items-center justify-center'>
<div className='relative w-full rounded-lg'>
<div className='mb-6 me-4 flex h-32 items-start'>
<div className='me-10 aspect-[28/41] h-32 shadow-lg'>
<BookCover mode='list' book={book} />
</div>
<div className='title-author flex h-32 flex-col justify-between'>
<div>
<p className='text-base-content mb-2 line-clamp-2 break-all text-lg font-bold'>
{formatTitle(book.title) || _('Untitled')}
</p>
<p className='text-neutral-content line-clamp-1'>
{formatAuthors(book.author, book.primaryLanguage) || _('Unknown')}
</p>
</div>
<div className='flex flex-wrap items-center gap-x-4'>
{handleBookDelete && (
<button onClick={handleDelete}>
<MdOutlineDelete className='fill-red-500' />
</button>
)}
{book.uploadedAt && handleBookDeleteCloudBackup && (
<button onClick={handleDeleteCloudBackup}>
<MdOutlineCloudOff className='fill-red-500' />
</button>
)}
{book.uploadedAt && handleBookDownload && (
<button onClick={handleRedownload}>
<MdOutlineCloudDownload className='fill-base-content' />
</button>
)}
{book.downloadedAt && handleBookUpload && (
<button onClick={handleReupload}>
<MdOutlineCloudUpload className='fill-base-content' />
</button>
)}
</div>
</div>
</div>
<div className='text-base-content my-4'>
<div className='mb-4 grid grid-cols-2 gap-4 sm:grid-cols-3'>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Publisher:')}</span>
<p className='text-neutral-content text-sm'>
{formatPublisher(bookMeta.publisher || '') || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Published:')}</span>
<p className='text-neutral-content text-sm'>
{formatDate(bookMeta.published) || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Updated:')}</span>
<p className='text-neutral-content text-sm'>{formatDate(book.updatedAt) || ''}</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Added:')}</span>
<p className='text-neutral-content text-sm'>{formatDate(book.createdAt) || ''}</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Language:')}</span>
<p className='text-neutral-content text-sm'>
{formatLanguage(bookMeta.language) || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Subjects:')}</span>
<p className='text-neutral-content line-clamp-3 text-sm'>
{formatSubject(bookMeta.subject) || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Format:')}</span>
<p className='text-neutral-content text-sm'>{book.format || _('Unknown')}</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('File Size:')}</span>
<p className='text-neutral-content text-sm'>
{formatFileSize(fileSize) || _('Unknown')}
</p>
</div>
</div>
<div>
<span className='font-bold'>{_('Description:')}</span>
<p
className='text-neutral-content prose prose-sm text-sm'
dangerouslySetInnerHTML={{
__html: bookMeta.description || _('No description available'),
}}
></p>
</div>
</div>
</div>
</div>
</Dialog>
{showDeleteAlert && (
<div
className={clsx(
'fixed bottom-0 left-0 right-0 z-50 flex justify-center',
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
>
<Alert
title={_('Confirm Deletion')}
message={_('Are you sure to delete the selected book?')}
onCancel={() => {
setShowDeleteAlert(false);
}}
onConfirm={confirmDelete}
/>
</div>
)}
{showDeleteCloudBackupAlert && (
<div
className={clsx(
'fixed bottom-0 left-0 right-0 z-50 flex justify-center',
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
>
<Alert
title={_('Confirm Deletion')}
message={_('Are you sure to delete the cloud backup of the selected book?')}
onCancel={() => {
setShowDeleteAlert(false);
}}
onConfirm={confirmDeleteCloudBackup}
/>
</div>
)}
</div>
);
};
export default BookDetailModal;
+5 -1
View File
@@ -160,7 +160,11 @@ const Dialog: React.FC<DialogProps> = ({
dir={isRtl ? 'rtl' : undefined}
>
<div
className={clsx('overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20', bgClassName)}
className={clsx(
'overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20',
appService?.hasRoundedWindow && 'rounded-window',
bgClassName,
)}
onClick={onClose}
/>
<div
+176 -83
View File
@@ -6,7 +6,8 @@ import { useEffect, useState } from 'react';
import { type as osType, arch as osArch } from '@tauri-apps/plugin-os';
import { check, Update } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import { fetch } from '@tauri-apps/plugin-http';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { isTauriAppPlatform } from '@/services/environment';
import { useTranslator } from '@/hooks/useTranslator';
import { useTranslation } from '@/hooks/useTranslation';
import { useSearchParams } from 'next/navigation';
@@ -14,8 +15,10 @@ import { getAppVersion } from '@/utils/version';
import { tauriDownload } from '@/utils/transfer';
import { installPackage } from '@/utils/bridge';
import { getLocale } from '@/utils/misc';
import { setLastShownReleaseNotesVersion } from '@/helpers/updater';
import { READEST_UPDATER_FILE, READEST_CHANGELOG_FILE } from '@/services/constants';
import Dialog from '@/components/Dialog';
import Link from './Link';
interface ReleaseNotes {
releases: Record<
@@ -58,7 +61,15 @@ interface GenericUpdate {
downloadAndInstall?(onEvent?: (progress: DownloadEvent) => void): Promise<void>;
}
export const UpdaterContent = ({ version }: { version?: string }) => {
export const UpdaterContent = ({
latestVersion,
lastVersion,
checkUpdate = true,
}: {
latestVersion?: string;
lastVersion?: string;
checkUpdate?: boolean;
}) => {
const _ = useTranslation();
const [targetLang, setTargetLang] = useState('EN');
const { translate } = useTranslator({
@@ -69,15 +80,19 @@ export const UpdaterContent = ({ version }: { version?: string }) => {
const { appService } = useEnv();
const searchParams = useSearchParams();
const currentVersion = getAppVersion();
const resolvedVersion = version ?? searchParams?.get('version') ?? '';
const [newVersion, setNewVersion] = useState(
latestVersion ?? searchParams?.get('latestVersion') ?? '',
);
const [oldVersion, setOldVersion] = useState(
lastVersion ?? searchParams?.get('lastVersion') ?? '',
);
const [update, setUpdate] = useState<GenericUpdate | Update | null>(null);
const [isMounted, setIsMounted] = useState(false);
const [newVersion, setNewVersion] = useState(resolvedVersion);
const [changelogs, setChangelogs] = useState<Changelog[]>([]);
const [progress, setProgress] = useState<number | null>(null);
const [contentLength, setContentLength] = useState<number | null>(null);
const [isDownloading, setIsDownloading] = useState(false);
const [downloaded, setDownloaded] = useState<number | null>(null);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setTargetLang(getLocale());
@@ -91,6 +106,7 @@ export const UpdaterContent = ({ version }: { version?: string }) => {
}
};
const checkAndroidUpdate = async () => {
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
const response = await fetch(READEST_UPDATER_FILE);
const data = await response.json();
if (semver.gt(data.version, currentVersion)) {
@@ -156,30 +172,47 @@ export const UpdaterContent = ({ version }: { version?: string }) => {
checkAndroidUpdate();
}
};
checkForUpdates();
if (appService?.hasUpdater && checkUpdate) {
checkForUpdates();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [appService?.hasUpdater]);
useEffect(() => {
const fetchChangelogs = async (
fromVersion: string,
toVersion: string,
): Promise<Changelog[]> => {
if (latestVersion) {
setNewVersion(latestVersion);
}
if (lastVersion) {
setOldVersion(lastVersion);
}
if (!checkUpdate) {
setUpdate({
currentVersion,
version: latestVersion,
date: '',
body: '',
} as GenericUpdate);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [latestVersion, lastVersion, checkUpdate]);
useEffect(() => {
const fetchChangelogs = async (fromVersion: string): Promise<Changelog[]> => {
try {
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
const res = await fetch(READEST_CHANGELOG_FILE);
const data: ReleaseNotes = await res.json();
const releases = data.releases;
const entries = Object.entries(releases)
.filter(([ver]) => semver.gt(ver, fromVersion) && semver.lte(ver, toVersion))
.sort(([a], [b]) => semver.rcompare(a, b))
.map(([version, info]) => ({
version,
date: new Date(info.date).toLocaleDateString(),
notes: info.notes,
}));
return entries;
let entries = Object.entries(releases)
.filter(([ver]) => semver.gt(ver, fromVersion))
.sort(([a], [b]) => semver.rcompare(a, b));
entries = entries.length ? entries : Object.entries(releases).slice(0, 3);
return entries.map(([version, info]) => ({
version,
date: new Date(info.date).toLocaleDateString(),
notes: info.notes,
}));
} catch (error) {
console.error('Failed to fetch changelog:', error);
return [];
@@ -193,25 +226,28 @@ export const UpdaterContent = ({ version }: { version?: string }) => {
};
const updateChangelogs = async (update: GenericUpdate) => {
setNewVersion(update.version);
let changelogs = await fetchChangelogs(currentVersion, update.version);
if (changelogs.length === 0) {
let changelogs = await fetchChangelogs(oldVersion || currentVersion);
if (changelogs.length === 0 && update.date && update.body) {
changelogs = [
{
version: update.version,
date: new Date(update.date!).toLocaleDateString(),
date: new Date(update.date).toLocaleDateString(),
notes: parseNumberedList(update.body ?? ''),
},
];
}
for (const entry of changelogs) {
try {
entry.notes = await translate(entry.notes, { useCache: true });
} catch (error) {
console.log('Failed to translate changelog:', error);
if (!targetLang.toLowerCase().startsWith('en')) {
for (const entry of changelogs) {
try {
entry.notes = await translate(entry.notes, { useCache: true });
} catch (error) {
console.log('Failed to translate changelog:', error);
}
}
}
setChangelogs(changelogs);
setLastShownReleaseNotesVersion(newVersion);
};
if (update) {
updateChangelogs(update);
@@ -272,57 +308,97 @@ export const UpdaterContent = ({ version }: { version?: string }) => {
<Image src='/icon.png' alt='Logo' className='h-20 w-20' width={64} height={64} />
</div>
<div className='text-base-content flex-grow text-sm'>
<h2 className='mb-4 text-center font-bold sm:text-start'>
{_('A new version of Readest is available!')}
</h2>
<p className='mb-2'>
{_('Readest {{newVersion}} is available (installed version {{currentVersion}}).', {
newVersion,
currentVersion,
})}
</p>
<p className='mb-2'>{_('Download and install now?')}</p>
{checkUpdate ? (
<div className='text-base-content flex-grow text-sm'>
<h2 className='mb-4 text-center font-bold sm:text-start'>
{_('A new version of Readest is available!')}
</h2>
<p className='mb-2'>
{_('Readest {{newVersion}} is available (installed version {{currentVersion}}).', {
newVersion,
currentVersion,
})}
</p>
<p className='mb-2'>{_('Download and install now?')}</p>
<div className='flex w-full flex-row items-center justify-end gap-4'>
{progress !== null && (
<div className='flex flex-grow flex-col'>
<progress
className='progress my-1 h-4 w-full'
value={progress}
max='100'
></progress>
<p className='text-base-content/75 flex items-center justify-center text-sm'>
{progress < 100
? _('Downloading {{downloaded}} of {{contentLength}}', {
downloaded: downloaded
? `${Math.floor(downloaded / 1024 / 1024)} MB`
: '0 MB',
contentLength: contentLength
? `${Math.floor(contentLength / 1024 / 1024)} MB`
: '0 MB',
})
: _('Download finished')}
</p>
<div className='flex w-full flex-row items-center justify-end gap-4'>
{progress !== null && (
<div className='flex flex-grow flex-col'>
<progress
className='progress my-1 h-4 w-full'
value={progress}
max='100'
></progress>
<p className='text-base-content/75 flex items-center justify-center text-sm'>
{progress < 100
? _('Downloading {{downloaded}} of {{contentLength}}', {
downloaded: downloaded
? `${Math.floor(downloaded / 1024 / 1024)} MB`
: '0 MB',
contentLength: contentLength
? `${Math.floor(contentLength / 1024 / 1024)} MB`
: '0 MB',
})
: _('Download finished')}
</p>
</div>
)}
<div className='card-actions'>
<button
className={clsx(
'btn btn-warning text-base-100 px-6 font-bold',
(!update || isDownloading) && 'btn-disabled',
)}
onClick={handleDownloadInstall}
>
{_('DOWNLOAD & INSTALL')}
</button>
</div>
)}
<div className='card-actions'>
<button
className={clsx(
'btn btn-warning text-base-100 px-6 font-bold',
(!update || isDownloading) && 'btn-disabled',
)}
onClick={handleDownloadInstall}
>
{_('DOWNLOAD & INSTALL')}
</button>
</div>
</div>
</div>
) : (
<div className='text-base-content flex h-full flex-grow flex-col text-sm sm:flex-row'>
<div className='flex flex-col items-center justify-center gap-4 p-1 sm:items-start sm:gap-2'>
<h2 className='text-center font-bold sm:text-start'>
{_('Version {{version}}', { version: currentVersion })}
</h2>
{changelogs.length > 0 && semver.gt(changelogs[0]!.version, currentVersion) ? (
<div className='flex gap-2'>
{(appService?.isIOSApp || appService?.isMacOSApp) && (
<Link
href='https://apps.apple.com/app/id6738622779'
target='_blank'
rel='noopener noreferrer'
className='btn btn-primary btn-sm'
>
{_('Check Update')}
</Link>
)}
{appService?.isAndroidApp && (
<Link
href='https://play.google.com/store/apps/details?id=com.bilingify.readest'
target='_blank'
rel='noopener noreferrer'
className='btn btn-primary btn-sm'
>
{_('Check Update')}
</Link>
)}
</div>
) : (
<div className='flex'>
<p className='text-sm font-bold'>{_('Already the latest version')}</p>
</div>
)}
</div>
</div>
)}
</div>
<div className='text-base-content text-sm'>
<h3 className='mb-2 font-bold'>{_('Changelog:')}</h3>
<h3 className='mb-2 font-bold'>{_('Changelog')}</h3>
<div className='bg-base-300 mb-4 rounded-lg px-4 pb-2 pt-4'>
{changelogs.length > 0 ? (
changelogs.map((entry: Changelog) => (
@@ -351,11 +427,16 @@ export const UpdaterContent = ({ version }: { version?: string }) => {
);
};
export const setUpdaterWindowVisible = (visible: boolean, newVersion?: string) => {
export const setUpdaterWindowVisible = (
visible: boolean,
latestVersion: string,
lastVersion?: string,
checkUpdate = true,
) => {
const dialog = document.getElementById('updater_window');
if (dialog) {
const event = new CustomEvent('setDialogVisibility', {
detail: { visible, newVersion },
detail: { visible, latestVersion, lastVersion, checkUpdate },
});
dialog.dispatchEvent(event);
}
@@ -363,15 +444,21 @@ export const setUpdaterWindowVisible = (visible: boolean, newVersion?: string) =
export const UpdaterWindow = () => {
const _ = useTranslation();
const [newVersion, setNewVersion] = useState('');
const [latestVersion, setLatestVersion] = useState('');
const [lastVersion, setLastVersion] = useState('');
const [checkUpdate, setCheckUpdate] = useState(true);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleCustomEvent = (event: CustomEvent) => {
const { visible, newVersion } = event.detail;
const { visible, latestVersion, lastVersion, checkUpdate } = event.detail;
setIsOpen(visible);
if (newVersion) {
setNewVersion(newVersion);
setCheckUpdate(checkUpdate);
if (latestVersion) {
setLatestVersion(latestVersion);
}
if (lastVersion) {
setLastVersion(lastVersion);
}
};
@@ -391,11 +478,17 @@ export const UpdaterWindow = () => {
<Dialog
id='updater_window'
isOpen={isOpen}
title={_('Software Update')}
title={checkUpdate ? _('Software Update') : _("What's New in Readest")}
onClose={() => setIsOpen(false)}
boxClassName='sm:!w-[80%] sm:h-auto'
boxClassName='sm:!w-[75%] sm:h-auto sm:!max-h-[85vh] sm:!max-w-2xl'
>
<UpdaterContent version={newVersion ?? undefined} />
{isOpen && (
<UpdaterContent
latestVersion={latestVersion ?? undefined}
lastVersion={lastVersion ?? undefined}
checkUpdate={checkUpdate}
/>
)}
</Dialog>
);
};
@@ -0,0 +1,389 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { MdEdit, MdLock, MdLockOpen, MdOutlineSearch } from 'react-icons/md';
import { Book } from '@/types/book';
import { BookMetadata } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { formatAuthors, formatTitle } from '@/utils/book';
import { FormField } from './FormField';
import { IMAGE_ACCEPT_FORMATS, SUPPORTED_IMAGE_EXTS } from '@/services/constants';
import { isTauriAppPlatform } from '@/services/environment';
import BookCover from '@/components/BookCover';
interface BookDetailEditProps {
book: Book;
metadata: BookMetadata;
fieldSources: Record<string, string>;
lockedFields: Record<string, boolean>;
fieldErrors: Record<string, string>;
searchLoading: boolean;
onFieldChange: (field: string, value: string) => void;
onToggleFieldLock: (field: string) => void;
onAutoRetrieve: () => void;
onLockAll: () => void;
onUnlockAll: () => void;
onCancel: () => void;
onReset: () => void;
onSave: () => void;
}
const BookDetailEdit: React.FC<BookDetailEditProps> = ({
book,
metadata,
fieldSources,
lockedFields,
fieldErrors,
searchLoading,
onFieldChange,
onToggleFieldLock,
onAutoRetrieve,
onLockAll,
onUnlockAll,
onCancel,
onReset,
onSave,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const hasLockedFields = Object.values(lockedFields).some((locked) => locked);
const allFieldsLocked = Object.values(lockedFields).every((locked) => locked);
const isCoverLocked = lockedFields['coverImageUrl'] || false;
const [newCoverImageUrl, setNewCoverImageUrl] = useState<string | null>(null);
useEffect(() => {
if (metadata.coverImageUrl) {
setNewCoverImageUrl(metadata.coverImageUrl);
}
}, [metadata.coverImageUrl]);
const titleAuthorFields = [
{
field: 'title',
label: _('Title'),
required: true,
value: formatTitle(metadata.title),
placeholder: _('Enter book title'),
},
{
field: 'subtitle',
label: _('Subtitle'),
required: false,
value: formatTitle(metadata.subtitle || ''),
placeholder: _('Enter book subtitle'),
},
{
field: 'author',
label: _('Author'),
required: true,
value: formatAuthors(metadata.author),
placeholder: _('Enter author name'),
},
];
const metadataGridFields = [
{
field: 'series',
label: _('Series'),
value: metadata.series || '',
placeholder: _('Enter series name'),
},
{
field: 'seriesIndex',
label: _('Series Index'),
isNumber: true,
value: String(metadata.seriesIndex || ''),
placeholder: _('Enter series index'),
},
{
field: 'seriesTotal',
label: _('Total in Series'),
isNumber: true,
value: String(metadata.seriesTotal || ''),
placeholder: _('Enter total books in series'),
},
{
field: 'publisher',
label: _('Publisher'),
value: metadata.publisher || '',
placeholder: _('Enter publisher'),
},
{
field: 'published',
label: _('Publication Date'),
value: metadata.published || '',
placeholder: _('YYYY or YYYY-MM-DD'),
},
{
field: 'language',
label: _('Language'),
value: Array.isArray(metadata.language)
? metadata.language.join(', ')
: metadata.language || '',
placeholder: 'en, zh, fr',
},
{
field: 'identifier',
label: _('Identifier'),
value: metadata.identifier || '',
placeholder: '978-0123456789',
},
];
const metadataFullwidthFields = [
{
field: 'subject',
label: _('Subjects'),
value: Array.isArray(metadata.subject) ? metadata.subject.join(',') : metadata.subject || '',
placeholder: _('Fiction, Science, History'),
},
{
field: 'description',
label: _('Description'),
type: 'textarea',
rows: 4,
value: metadata.description || '',
placeholder: _('Enter book description'),
},
];
const selectImageFileWeb = () => {
return new Promise((resolve) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = IMAGE_ACCEPT_FORMATS;
fileInput.multiple = false;
fileInput.click();
fileInput.onchange = () => {
resolve(fileInput.files);
};
});
};
const selectImageFileTauri = async () => {
const exts = appService?.isMobileApp ? [] : SUPPORTED_IMAGE_EXTS;
const files = (await appService?.selectFiles(_('Select Cover Image'), exts)) || [];
if (appService?.isIOSApp) {
return files.filter((file) => {
const fileExt = file.split('.').pop()?.toLowerCase() || 'unknown';
return SUPPORTED_IMAGE_EXTS.includes(fileExt);
});
}
return files;
};
const handleSelectLocalImage = async () => {
let files;
if (isTauriAppPlatform()) {
files = (await selectImageFileTauri()) as string[];
if (appService && files.length > 0) {
metadata.coverImageFile = files[0]!;
const tempName = 'cover-temp.png';
const cachePrefix = appService.fs.getPrefix('Cache');
await appService.fs.copyFile(files[0]!, tempName, 'Cache');
metadata.coverImageUrl = await appService.fs.getURL(`${cachePrefix}/${tempName}`);
setNewCoverImageUrl(metadata.coverImageUrl!);
}
} else {
files = (await selectImageFileWeb()) as File[];
if (files.length > 0) {
metadata.coverImageBlobUrl = URL.createObjectURL(files[0]!);
setNewCoverImageUrl(metadata.coverImageBlobUrl!);
}
}
};
return (
<div className='bg-base-100 relative w-full rounded-lg'>
<div className='mb-6 flex items-start gap-4'>
<div className='flex w-[30%] max-w-32 flex-col gap-2'>
<div
className='aspect-[28/41] h-full shadow-md'
onClick={!isCoverLocked ? handleSelectLocalImage : undefined}
>
<BookCover
mode='list'
book={{
...book,
metadata: {
...metadata,
coverImageUrl: newCoverImageUrl || metadata.coverImageUrl,
},
...(newCoverImageUrl ? { coverImageUrl: newCoverImageUrl } : {}),
}}
/>
</div>
<div className='flex w-full gap-1'>
<button
onClick={handleSelectLocalImage}
disabled={isCoverLocked}
className={clsx(
'flex flex-1 items-center justify-center gap-1 rounded px-2 py-1',
'border border-gray-300 bg-white hover:bg-gray-50',
'disabled:cursor-not-allowed disabled:opacity-50',
'text-sm sm:text-xs',
isCoverLocked && '!text-base-content !bg-base-200',
)}
title={_('Change cover image')}
>
<MdEdit
className={clsx(
'h-5 w-5 sm:h-4 sm:w-4',
isCoverLocked ? 'fill-base-content' : 'fill-gray-600',
)}
/>
<span className='hidden sm:inline'>{_('Replace')}</span>
</button>
<button
onClick={() => onToggleFieldLock('coverImageUrl')}
className={clsx(
'flex items-center justify-center rounded px-2 py-1 text-xs',
'border border-gray-300 hover:bg-gray-50',
isCoverLocked
? 'bg-green-100 text-green-500 hover:bg-green-200'
: 'bg-white text-gray-500',
)}
title={isCoverLocked ? _('Unlock cover') : _('Lock cover')}
>
{isCoverLocked ? <MdLock className='h-3 w-3' /> : <MdLockOpen className='h-3 w-3' />}
</button>
</div>
</div>
<div className='flex-1 space-y-4'>
{titleAuthorFields.map(({ field, label, required, value, placeholder }) => (
<FormField
key={field}
field={field}
label={label}
required={required}
value={value}
onFieldChange={onFieldChange}
fieldSources={fieldSources}
lockedFields={lockedFields}
fieldErrors={fieldErrors}
onToggleFieldLock={onToggleFieldLock}
placeholder={placeholder}
/>
))}
</div>
</div>
{/* Metadata Fields Grid */}
<div className='mb-6 space-y-4'>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
{metadataGridFields.map(({ field, label, value, isNumber, placeholder }) => (
<FormField
key={field}
field={field}
label={label}
value={value}
isNumber={isNumber}
onFieldChange={onFieldChange}
fieldSources={fieldSources}
lockedFields={lockedFields}
fieldErrors={fieldErrors}
onToggleFieldLock={onToggleFieldLock}
placeholder={placeholder}
/>
))}
</div>
{metadataFullwidthFields.map(
({ field, label, type = 'input', rows, value, placeholder }) => (
<FormField
key={field}
field={field}
label={label}
type={type as 'input' | 'textarea'}
rows={rows}
value={value}
onFieldChange={onFieldChange}
fieldSources={fieldSources}
lockedFields={lockedFields}
fieldErrors={fieldErrors}
onToggleFieldLock={onToggleFieldLock}
placeholder={placeholder}
/>
),
)}
</div>
{/* Action Buttons */}
<div className='flex flex-col items-center justify-between gap-4'>
<div className='flex w-full items-center gap-2'>
<button
onClick={onAutoRetrieve}
disabled={searchLoading}
className='flex items-center gap-2 rounded-md bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50'
title={_('Auto-Retrieve Metadata')}
>
{searchLoading ? (
<span className='loading loading-spinner h-4 w-4'></span>
) : (
<MdOutlineSearch className='h-4 w-4' />
)}
<span className='sm:hidden'>{_('Auto')}</span>
<span className='hidden sm:inline'>{_('Auto-Retrieve')}</span>
</button>
{/* Lock/Unlock All Buttons */}
<div className='flex items-center gap-1 border-l border-gray-300 pl-2'>
<button
onClick={onUnlockAll}
disabled={!hasLockedFields}
className={clsx(
'hover:bg-base-200 flex items-center gap-1 rounded px-2 py-1 text-sm',
'disabled:cursor-not-allowed disabled:opacity-50',
'text-yellow-500 hover:text-yellow-600',
)}
title={_('Unlock all fields')}
>
<MdLockOpen className='h-3 w-3' />
{_('Unlock All')}
</button>
<button
onClick={onLockAll}
disabled={allFieldsLocked}
className={clsx(
'hover:bg-base-200 flex items-center gap-1 rounded px-2 py-1 text-sm',
'disabled:cursor-not-allowed disabled:opacity-50',
'text-green-500 hover:text-green-600',
)}
title={_('Lock all fields')}
>
<MdLock className='h-3 w-3' />
{_('Lock All')}
</button>
</div>
</div>
<div className='flex w-full justify-end gap-4'>
<button
onClick={onCancel}
className='hover:bg-base-200 rounded-md border-neutral-300 px-4 py-2'
>
{_('Cancel')}
</button>
<button
onClick={onReset}
className='hover:bg-base-200 rounded-md border-neutral-300 px-4 py-2'
>
{_('Reset')}
</button>
<button
onClick={onSave}
disabled={fieldErrors && Object.keys(fieldErrors).length > 0}
className='rounded-md bg-green-500 px-4 py-2 text-white hover:bg-green-600 disabled:opacity-50'
>
{_('Save')}
</button>
</div>
</div>
</div>
);
};
export default BookDetailEdit;
@@ -0,0 +1,248 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { Book } from '@/types/book';
import { BookMetadata } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useMetadataEdit } from './useMetadataEdit';
import Alert from '@/components/Alert';
import Dialog from '@/components/Dialog';
import Spinner from '@/components/Spinner';
import BookDetailView from './BookDetailView';
import BookDetailEdit from './BookDetailEdit';
import SourceSelector from './SourceSelector';
interface BookDetailModalProps {
book: Book;
isOpen: boolean;
onClose: () => void;
handleBookDownload?: (book: Book, redownload?: boolean) => void;
handleBookUpload?: (book: Book) => void;
handleBookDelete?: (book: Book) => void;
handleBookDeleteCloudBackup?: (book: Book) => void;
handleBookMetadataUpdate?: (book: Book, updatedMetadata: BookMetadata) => void;
}
const BookDetailModal: React.FC<BookDetailModalProps> = ({
book,
isOpen,
onClose,
handleBookDownload,
handleBookUpload,
handleBookDelete,
handleBookDeleteCloudBackup,
handleBookMetadataUpdate,
}) => {
const _ = useTranslation();
const [loading, setLoading] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [showDeleteCloudBackupAlert, setShowDeleteCloudBackupAlert] = useState(false);
const [editMode, setEditMode] = useState(false);
const [bookMeta, setBookMeta] = useState<BookMetadata | null>(null);
const [fileSize, setFileSize] = useState<number | null>(null);
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
// Initialize metadata edit hook
const {
editedMeta,
fieldSources,
lockedFields,
fieldErrors,
searchLoading,
showSourceSelection,
availableSources,
handleFieldChange,
handleToggleFieldLock,
handleLockAll,
handleUnlockAll,
handleAutoRetrieve,
handleSourceSelection,
handleCloseSourceSelection,
resetToOriginal,
} = useMetadataEdit(bookMeta);
useEffect(() => {
const loadingTimeout = setTimeout(() => setLoading(true), 300);
const fetchBookDetails = async () => {
const appService = await envConfig.getAppService();
try {
const details = book.metadata || (await appService.fetchBookDetails(book, settings));
setBookMeta(details);
const size = await appService.getBookFileSize(book);
setFileSize(size);
} finally {
if (loadingTimeout) clearTimeout(loadingTimeout);
setLoading(false);
}
};
fetchBookDetails();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [book]);
const handleClose = () => {
setBookMeta(null);
setEditMode(false);
onClose();
};
const handleEditMetadata = () => {
setEditMode(true);
};
const handleCancelEdit = () => {
resetToOriginal();
setEditMode(false);
};
const handleSaveMetadata = () => {
if (editedMeta && handleBookMetadataUpdate) {
setBookMeta({ ...editedMeta });
handleBookMetadataUpdate(book, editedMeta);
setEditMode(false);
}
};
const handleDelete = () => {
setShowDeleteAlert(true);
};
const handleDeleteCloudBackup = () => {
setShowDeleteCloudBackupAlert(true);
};
const confirmDelete = async () => {
handleClose();
setShowDeleteAlert(false);
if (handleBookDelete) {
handleBookDelete(book);
}
};
const confirmDeleteCloudBackup = async () => {
handleClose();
setShowDeleteCloudBackupAlert(false);
if (handleBookDeleteCloudBackup) {
handleBookDeleteCloudBackup(book);
}
};
const handleRedownload = async () => {
handleClose();
if (handleBookDownload) {
handleBookDownload(book, true);
}
};
const handleReupload = async () => {
handleClose();
if (handleBookUpload) {
handleBookUpload(book);
}
};
if (!bookMeta)
return (
loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
)
);
return (
<>
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Dialog
title={editMode ? _('Edit Metadata') : _('Book Details')}
isOpen={isOpen}
onClose={handleClose}
bgClassName='sm:bg-black/50'
boxClassName={clsx(
editMode ? 'sm:min-w-[600px] sm:max-w-[600px]' : 'sm:min-w-[480px] sm:max-w-[480px]',
'sm:h-auto sm:max-h-[90%]',
)}
contentClassName='!px-6 !py-4'
>
<div className='flex w-full select-text items-start justify-center'>
{editMode ? (
<BookDetailEdit
book={book}
metadata={editedMeta}
fieldSources={fieldSources}
lockedFields={lockedFields}
fieldErrors={fieldErrors}
searchLoading={searchLoading}
onFieldChange={handleFieldChange}
onToggleFieldLock={handleToggleFieldLock}
onAutoRetrieve={handleAutoRetrieve}
onLockAll={handleLockAll}
onUnlockAll={handleUnlockAll}
onCancel={handleCancelEdit}
onReset={resetToOriginal}
onSave={handleSaveMetadata}
/>
) : (
<BookDetailView
book={book}
metadata={bookMeta}
fileSize={fileSize}
onEdit={handleBookMetadataUpdate ? handleEditMetadata : undefined}
onDelete={handleBookDelete ? handleDelete : undefined}
onDeleteCloudBackup={
handleBookDeleteCloudBackup ? handleDeleteCloudBackup : undefined
}
onDownload={handleBookDownload ? handleRedownload : undefined}
onUpload={handleBookUpload ? handleReupload : undefined}
/>
)}
</div>
</Dialog>
{/* Source Selection Modal */}
<SourceSelector
sources={availableSources}
isOpen={showSourceSelection}
onSelect={handleSourceSelection}
onClose={handleCloseSourceSelection}
/>
{/* Delete Alerts */}
{showDeleteAlert && (
<div
className={clsx(
'fixed bottom-0 left-0 right-0 z-50 flex justify-center',
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
>
<Alert
title={_('Confirm Deletion')}
message={_('Are you sure to delete the selected book?')}
onCancel={() => setShowDeleteAlert(false)}
onConfirm={confirmDelete}
/>
</div>
)}
{showDeleteCloudBackupAlert && (
<div
className={clsx(
'fixed bottom-0 left-0 right-0 z-50 flex justify-center',
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
>
<Alert
title={_('Confirm Deletion')}
message={_('Are you sure to delete the cloud backup of the selected book?')}
onCancel={() => setShowDeleteCloudBackupAlert(false)}
onConfirm={confirmDeleteCloudBackup}
/>
</div>
)}
</div>
</>
);
};
export default BookDetailModal;
@@ -0,0 +1,151 @@
import React from 'react';
import {
MdOutlineDelete,
MdOutlineCloudDownload,
MdOutlineCloudUpload,
MdOutlineCloudOff,
MdOutlineEdit,
} from 'react-icons/md';
import { Book } from '@/types/book';
import { BookMetadata } from '@/libs/document';
import { useTranslation } from '@/hooks/useTranslation';
import {
formatAuthors,
formatDate,
formatFileSize,
formatLanguage,
formatPublisher,
formatSubject,
formatTitle,
} from '@/utils/book';
import BookCover from '@/components/BookCover';
interface BookDetailViewProps {
book: Book;
metadata: BookMetadata;
fileSize: number | null;
onEdit?: () => void;
onDelete?: () => void;
onDeleteCloudBackup?: () => void;
onDownload?: () => void;
onUpload?: () => void;
}
const BookDetailView: React.FC<BookDetailViewProps> = ({
book,
metadata,
fileSize,
onEdit,
onDelete,
onDeleteCloudBackup,
onDownload,
onUpload,
}) => {
const _ = useTranslation();
return (
<div className='relative w-full rounded-lg'>
<div className='mb-6 me-4 flex h-32 items-start'>
<div className='me-10 aspect-[28/41] h-32 shadow-lg'>
<BookCover mode='list' book={book} />
</div>
<div className='title-author flex h-32 flex-col justify-between'>
<div>
<p className='text-base-content mb-2 line-clamp-2 break-all text-lg font-bold'>
{formatTitle(book.title) || _('Untitled')}
</p>
<p className='text-neutral-content line-clamp-1'>
{formatAuthors(book.author, book.primaryLanguage) || _('Unknown')}
</p>
</div>
<div className='flex flex-wrap items-center gap-x-4'>
{onEdit && (
<button onClick={onEdit} title={_('Edit Metadata')}>
<MdOutlineEdit className='fill-base-content hover:fill-blue-500' />
</button>
)}
{onDelete && (
<button onClick={onDelete}>
<MdOutlineDelete className='fill-red-500' />
</button>
)}
{book.uploadedAt && onDeleteCloudBackup && (
<button onClick={onDeleteCloudBackup}>
<MdOutlineCloudOff className='fill-red-500' />
</button>
)}
{book.uploadedAt && onDownload && (
<button onClick={onDownload}>
<MdOutlineCloudDownload className='fill-base-content' />
</button>
)}
{book.downloadedAt && onUpload && (
<button onClick={onUpload}>
<MdOutlineCloudUpload className='fill-base-content' />
</button>
)}
</div>
</div>
</div>
<div className='text-base-content my-4'>
<div className='mb-4 grid grid-cols-2 gap-4 sm:grid-cols-3'>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Publisher')}</span>
<p className='text-neutral-content text-sm'>
{formatPublisher(metadata.publisher || '') || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Published')}</span>
<p className='text-neutral-content text-sm'>
{formatDate(metadata.published, true) || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Updated')}</span>
<p className='text-neutral-content text-sm'>{formatDate(book.updatedAt) || ''}</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Added')}</span>
<p className='text-neutral-content text-sm'>{formatDate(book.createdAt) || ''}</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Language')}</span>
<p className='text-neutral-content text-sm'>
{formatLanguage(metadata.language) || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Subjects')}</span>
<p className='text-neutral-content line-clamp-3 text-sm'>
{formatSubject(metadata.subject) || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Format')}</span>
<p className='text-neutral-content text-sm'>{book.format || _('Unknown')}</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('File Size')}</span>
<p className='text-neutral-content text-sm'>
{formatFileSize(fileSize) || _('Unknown')}
</p>
</div>
</div>
<div>
<span className='font-bold'>{_('Description')}</span>
<p
className='text-neutral-content prose prose-sm text-sm'
dangerouslySetInnerHTML={{
__html: metadata.description || _('No description available'),
}}
></p>
</div>
</div>
</div>
);
};
export default BookDetailView;
@@ -0,0 +1,164 @@
import React from 'react';
import clsx from 'clsx';
import { MdOutlineInfo, MdLock, MdLockOpen, MdError } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
const inputBaseStyles =
'w-full rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-blue-500';
const inputBackgroundStyles = 'bg-base-200/50';
const labelStyles = 'text-base-content block text-sm font-medium';
interface SourceIndicatorProps {
source?: string;
}
const SourceIndicator: React.FC<SourceIndicatorProps> = ({ source }) => {
if (!source) return null;
const [sourceName, confidence] = source.split('-');
const confidenceNum = parseInt(confidence!);
let color = 'text-green-500';
if (confidenceNum < 90) color = 'text-yellow-500';
if (confidenceNum < 70) color = 'text-red-500';
return (
<div className='flex items-center justify-end gap-1 text-xs'>
<MdOutlineInfo className={clsx('h-3 w-3', color)} />
<span className={clsx('capitalize', color)}>
{sourceName} ({confidence}%)
</span>
</div>
);
};
interface LockButtonProps {
isLocked: boolean;
onToggle: () => void;
disabled?: boolean;
}
const LockButton: React.FC<LockButtonProps> = ({ isLocked, onToggle, disabled = false }) => {
return (
<button
type='button'
onClick={onToggle}
disabled={disabled}
className={clsx(
'focus:outline-non absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 transition-colors',
disabled && 'cursor-not-allowed opacity-50',
isLocked ? 'bg-green-100 text-green-500 hover:bg-green-200' : 'text-base-content',
)}
title={isLocked ? 'Unlock field' : 'Lock field'}
>
{isLocked ? <MdLock className='h-4 w-4' /> : <MdLockOpen className='h-4 w-4' />}
</button>
);
};
interface FormFieldProps {
type?: 'input' | 'textarea';
field: string;
label: string;
isNumber?: boolean;
required?: boolean;
disabled?: boolean;
lockable?: boolean;
fieldSources: Record<string, string>;
lockedFields: Record<string, boolean>;
fieldErrors: Record<string, string>;
onToggleFieldLock: (field: string) => void;
value: string;
onFieldChange: (field: string, value: string) => void;
placeholder?: string;
rows?: number;
className?: string;
}
const FormField: React.FC<FormFieldProps> = ({
type = 'input',
field,
label,
fieldSources,
lockedFields,
fieldErrors,
isNumber = false,
required = false,
lockable = true,
disabled = false,
onToggleFieldLock,
value,
onFieldChange,
placeholder,
rows,
className,
}) => {
const _ = useTranslation();
const isLocked = lockedFields[field]!;
const source = fieldSources[field]!;
const error = fieldErrors[field]!;
return (
<div className='flex flex-col gap-1'>
<div className={clsx(labelStyles, 'flex items-center justify-between')}>
<span>
{label} {required && '*'}
</span>
{isLocked && (
<span className='flex items-center gap-1 text-xs text-green-500'>
<MdLock className='h-3 w-3' />
{_('Locked')}
</span>
)}
</div>
<div className='relative'>
{type === 'input' ? (
<input
type={isNumber ? 'number' : 'text'}
min={1}
value={value}
onChange={(e) => onFieldChange(field, e.target.value)}
placeholder={isLocked ? '' : placeholder}
disabled={disabled || isLocked}
className={clsx(
inputBaseStyles,
inputBackgroundStyles,
lockable && 'pe-10',
isLocked ? 'cursor-not-allowed' : 'bg-gray-100 text-gray-500',
error && 'border-red-500 focus:ring-red-500',
className,
)}
/>
) : (
<textarea
value={value}
onChange={(e) => onFieldChange(field, e.target.value)}
placeholder={isLocked ? '' : placeholder}
rows={rows || 4}
disabled={disabled || isLocked}
className={clsx(
inputBaseStyles,
inputBackgroundStyles,
lockable && 'pe-10',
isLocked ? 'cursor-not-allowed' : 'bg-gray-100 text-gray-500',
error && 'border-red-500 focus:ring-red-500',
className,
)}
/>
)}
{lockable && <LockButton isLocked={isLocked} onToggle={() => onToggleFieldLock(field)} />}
</div>
{error && (
<div className='flex items-center gap-1 text-xs text-red-600'>
<MdError className='h-3 w-3' />
<span>{error}</span>
</div>
)}
{!error && <SourceIndicator source={source} />}
</div>
);
};
export { FormField, SourceIndicator };
@@ -0,0 +1,113 @@
import React from 'react';
import { MdOutlineCheck, MdOutlineEdit } from 'react-icons/md';
import { BookMetadata } from '@/libs/document';
import { useTranslation } from '@/hooks/useTranslation';
import { formatAuthors, formatTitle, getPrimaryLanguage } from '@/utils/book';
import BookCover from '../BookCover';
import { Metadata } from '@/services/metadata/types';
import { Book } from '@/types/book';
export interface MetadataSource {
sourceName: string;
sourceLabel: string;
confidence: number;
data: BookMetadata;
}
interface SourceSelectorProps {
sources: MetadataSource[];
isOpen: boolean;
onSelect: (source: MetadataSource) => void;
onClose: () => void;
}
const SourceSelector: React.FC<SourceSelectorProps> = ({ sources, isOpen, onSelect, onClose }) => {
const _ = useTranslation();
const getConfidenceIcon = (confidence: number) => {
if (confidence >= 90) return <MdOutlineCheck className='text-green-500' />;
if (confidence >= 70) return <span className='text-yellow-500'></span>;
return <span className='text-red-500'></span>;
};
if (!isOpen) return null;
return (
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/50'>
<div className='bg-base-100 mx-4 max-h-[80vh] w-full max-w-md overflow-y-auto rounded-lg p-6'>
<h3 className='mb-4 text-lg font-semibold'>{_('Select Metadata Source')}</h3>
<div className='space-y-3'>
{sources.map((source, index) => (
<div
key={index}
onClick={() => onSelect(source)}
className='hover:bg-base-200 cursor-pointer rounded-md border p-3 transition-colors'
>
<div className='flex items-start gap-4'>
<div className='aspect-[28/41] h-full w-[40%] max-w-32 shadow-md'>
<BookCover
mode='list'
book={
{
title: formatTitle(source.data.title),
author: formatAuthors(source.data.author),
coverImageUrl: (source.data as Metadata)['coverImageUrl'] ?? undefined,
} as Book
}
/>
</div>
<div className='flex-1'>
<div className='mb-2 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='font-medium capitalize'>{source.sourceLabel}</span>
{getConfidenceIcon(source.confidence)}
</div>
<span className='text-sm text-gray-500'>{source.confidence}%</span>
</div>
<div className='space-y-1 text-sm'>
<div className='font-medium'>{formatTitle(source.data.title)}</div>
<div className='text-gray-600'>{formatAuthors(source.data.author)}</div>
<div className='text-gray-500'>
{source.data.language ? `${getPrimaryLanguage(source.data.language)}` : ''}
{source.data.publisher ? `${source.data.publisher}` : ''}
{source.data.published}
</div>
{source.data.identifier && (
<div className='text-gray-500'>Identifier: {source.data.identifier}</div>
)}
{source.data.description && (
<div className='line-clamp-3 text-xs text-gray-500'>
{source.data.description}
</div>
)}
</div>
</div>
</div>
</div>
))}
<div
onClick={onClose}
className='hover:bg-base-200 cursor-pointer rounded-md border p-3 transition-colors'
>
<div className='flex items-center gap-2'>
<MdOutlineEdit className='h-4 w-4' />
<span className='font-medium'>{_('Keep manual input')}</span>
</div>
</div>
</div>
<div className='mt-6 flex justify-end gap-2'>
<button onClick={onClose} className='hover:bg-base-200 rounded-md px-4 py-2'>
{_('Cancel')}
</button>
</div>
</div>
</div>
);
};
export default SourceSelector;
@@ -0,0 +1,8 @@
export { default as BookDetailModal } from './BookDetailModal';
export { default as BookDetailView } from './BookDetailView';
export { default as BookDetailEdit } from './BookDetailEdit';
export { default as SourceSelector } from './SourceSelector';
export { useMetadataEdit } from './useMetadataEdit';
export type { MetadataSource } from './SourceSelector';
@@ -0,0 +1,237 @@
import { useEffect, useState } from 'react';
import { BookMetadata } from '@/libs/document';
import {
validateAndNormalizeDate,
validateAndNormalizeLanguage,
validateAndNormalizeSubjects,
validateISBN,
ValidationResult,
} from '@/utils/validation';
import { MetadataSource } from './SourceSelector';
import { searchMetadata } from '@/libs/metadata';
import { formatAuthors, formatTitle, getPrimaryLanguage } from '@/utils/book';
export const useMetadataEdit = (metadata: BookMetadata | null) => {
const [editedMeta, setEditedMeta] = useState<BookMetadata>({} as BookMetadata);
const [fieldSources, setFieldSources] = useState<Record<string, string>>({});
const [lockedFields, setLockedFields] = useState<Record<string, boolean>>({});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [searchLoading, setSearchLoading] = useState(false);
const [showSourceSelection, setShowSourceSelection] = useState(false);
const [availableSources, setAvailableSources] = useState<MetadataSource[]>([]);
const lockableFields = [
'title',
'author',
'publisher',
'published',
'language',
'identifier',
'subject',
'description',
'subtitle',
'series',
'seriesIndex',
'seriesTotal',
'coverImageUrl',
];
useEffect(() => {
if (metadata) {
setEditedMeta({ ...metadata });
}
}, [metadata]);
useEffect(() => {
const initialLockedFields: Record<string, boolean> = {};
lockableFields.forEach((field) => {
initialLockedFields[field] = false;
});
setLockedFields(initialLockedFields);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleFieldChange = (field: string, value: string) => {
if (lockedFields[field]) {
return;
}
const newMeta = { ...editedMeta } as { [key: string]: unknown };
switch (field) {
case 'subject':
newMeta['subject'] = value ? value.split(/,|;||、/).map((s) => s.trim()) : [];
break;
default:
newMeta[field] = value;
}
setEditedMeta(newMeta as BookMetadata);
handleFieldValidation(field, value);
if (fieldSources[field]) {
const newSources = { ...fieldSources };
delete newSources[field];
setFieldSources(newSources);
}
};
const handleFieldValidation = (field: string, value: string) => {
if (lockedFields[field]) {
return true;
}
let validationResult: ValidationResult<unknown>;
switch (field) {
case 'title':
case 'author':
if (!value.trim()) {
console.warn(`Field ${field} cannot be empty`);
setFieldErrors((prev) => ({ ...prev, [field]: 'This field is required' }));
return false;
}
break;
case 'published':
if (value.trim()) {
validationResult = validateAndNormalizeDate(value);
if (!validationResult.isValid) {
console.warn(`Invalid date for field ${field}:`, validationResult.error);
setFieldErrors((prev) => ({ ...prev, [field]: validationResult.error || '' }));
return false;
}
}
break;
case 'language':
if (value.trim()) {
validationResult = validateAndNormalizeLanguage(value);
if (!validationResult.isValid) {
console.warn(`Invalid language for field ${field}:`, validationResult.error);
setFieldErrors((prev) => ({ ...prev, [field]: validationResult.error || '' }));
return false;
}
}
break;
case 'subject':
if (value.trim()) {
validationResult = validateAndNormalizeSubjects(value);
if (!validationResult.isValid) {
console.warn(`Invalid subjects for field ${field}:`, validationResult.error);
setFieldErrors((prev) => ({ ...prev, [field]: validationResult.error || '' }));
return false;
}
}
break;
}
setFieldErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
return true;
};
const handleToggleFieldLock = (field: string) => {
setLockedFields((prev) => ({
...prev,
[field]: !prev[field],
}));
};
const handleLockAll = () => {
const allLocked: Record<string, boolean> = {};
lockableFields.forEach((field) => {
allLocked[field] = true;
});
setLockedFields(allLocked);
};
const handleUnlockAll = () => {
const allUnlocked: Record<string, boolean> = {};
lockableFields.forEach((field) => {
allUnlocked[field] = false;
});
setLockedFields(allUnlocked);
};
const handleAutoRetrieve = async () => {
setSearchLoading(true);
try {
const isbnValidation = validateISBN(editedMeta.identifier || '');
const results = await searchMetadata({
title: formatTitle(editedMeta.title),
author: formatAuthors(editedMeta.author),
isbn: isbnValidation.isValid ? editedMeta.identifier : undefined,
language: getPrimaryLanguage(editedMeta.language),
});
const metadataSources = results.map((result) => ({
sourceName: result.providerName,
sourceLabel: result.providerLabel,
confidence: result.confidence,
data: result.metadata as BookMetadata,
}));
setAvailableSources(metadataSources);
setShowSourceSelection(true);
} catch (error) {
console.error('Failed to retrieve metadata:', error);
} finally {
setSearchLoading(false);
}
};
const handleSourceSelection = (selectedSource: MetadataSource) => {
const newMeta = { ...editedMeta } as { [key: string]: unknown };
const newSources = { ...fieldSources };
Object.entries(selectedSource.data).forEach(([key, value]) => {
if (lockedFields[key] || !value) {
return;
}
switch (key) {
default:
newMeta[key] = value;
}
newSources[key] = `${selectedSource.sourceName}-${selectedSource.confidence}`;
});
setEditedMeta(newMeta as BookMetadata);
setFieldSources(newSources);
setShowSourceSelection(false);
};
const handleCloseSourceSelection = () => {
setShowSourceSelection(false);
};
const resetToOriginal = () => {
if (metadata) {
setEditedMeta({ ...metadata });
}
setFieldSources({});
setShowSourceSelection(false);
handleUnlockAll();
};
return {
editedMeta,
fieldSources,
lockedFields,
fieldErrors,
searchLoading,
showSourceSelection,
availableSources,
handleFieldChange,
handleFieldValidation,
handleToggleFieldLock,
handleLockAll,
handleUnlockAll,
handleAutoRetrieve,
handleSourceSelection,
handleCloseSourceSelection,
resetToOriginal,
};
};
+3 -1
View File
@@ -13,7 +13,9 @@ interface CliArgument {
}
const parseWindowOpenWithFiles = () => {
return window.OPEN_WITH_FILES;
const params = new URLSearchParams(window.location.search);
const files = params.getAll('file');
return files.length > 0 ? files : window.OPEN_WITH_FILES;
};
const parseCLIOpenWithFiles = async () => {
+41 -6
View File
@@ -5,14 +5,19 @@ import { fetch } from '@tauri-apps/plugin-http';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { TranslationFunc } from '@/hooks/useTranslation';
import { setUpdaterWindowVisible } from '@/components/UpdaterWindow';
import { isTauriAppPlatform } from '@/services/environment';
import { getAppVersion } from '@/utils/version';
import { CHECK_UPDATE_INTERVAL_SEC, READEST_UPDATER_FILE } from '@/services/constants';
import {
CHECK_UPDATE_INTERVAL_SEC,
READEST_CHANGELOG_FILE,
READEST_UPDATER_FILE,
} from '@/services/constants';
const LAST_CHECK_KEY = 'lastAppUpdateCheck';
const showUpdateWindow = (newVersion: string) => {
const showUpdateWindow = (latestVersion: string) => {
const win = new WebviewWindow('updater', {
url: `/updater?version=${newVersion}`,
url: `/updater?latestVersion=${latestVersion}`,
title: 'Software Update',
width: 626,
height: 406,
@@ -29,11 +34,11 @@ const showUpdateWindow = (newVersion: string) => {
export const checkForAppUpdates = async (
_: TranslationFunc,
autoCheck = true,
isAutoCheck = true,
): Promise<boolean> => {
const lastCheck = localStorage.getItem(LAST_CHECK_KEY);
const now = Date.now();
if (autoCheck && lastCheck && now - parseInt(lastCheck, 10) < CHECK_UPDATE_INTERVAL_SEC * 1000)
if (isAutoCheck && lastCheck && now - parseInt(lastCheck, 10) < CHECK_UPDATE_INTERVAL_SEC * 1000)
return false;
localStorage.setItem(LAST_CHECK_KEY, now.toString());
@@ -51,7 +56,7 @@ export const checkForAppUpdates = async (
const data = await response.json();
const isNewer = semver.gt(data.version, getAppVersion());
if (isNewer && ('android-arm64' in data.platforms || 'android-universal' in data.platforms)) {
setUpdaterWindowVisible(true, data.version);
setUpdaterWindowVisible(true, data.version!, getAppVersion());
}
return isNewer;
} catch (err) {
@@ -62,3 +67,33 @@ export const checkForAppUpdates = async (
return false;
};
const LAST_SHOWN_RELEASE_NOTES_KEY = 'lastShownReleaseNotesVersion';
export const setLastShownReleaseNotesVersion = (version: string) => {
localStorage.setItem(LAST_SHOWN_RELEASE_NOTES_KEY, version);
};
export const getLastShownReleaseNotesVersion = () => {
return localStorage.getItem(LAST_SHOWN_RELEASE_NOTES_KEY) || '';
};
export const checkAppReleaseNotes = async (isAutoCheck = true) => {
const currentVersion = getAppVersion();
const lastShownVersion = getLastShownReleaseNotesVersion();
if ((lastShownVersion && semver.gt(currentVersion, lastShownVersion)) || !isAutoCheck) {
try {
const fetchFunc = isTauriAppPlatform() ? fetch : window.fetch;
const res = await fetchFunc(READEST_CHANGELOG_FILE);
if (res.ok) {
setUpdaterWindowVisible(true, currentVersion, lastShownVersion, false);
return true;
}
} catch (err) {
console.warn('Failed to fetch release notes', err);
}
} else if (!lastShownVersion) {
setLastShownReleaseNotesVersion(currentVersion);
}
return false;
};
+15 -10
View File
@@ -21,14 +21,17 @@ interface UseLongPressResult {
};
}
export const useLongPress = ({
onTap,
onLongPress,
onContextMenu,
onCancel,
threshold = 500,
moveThreshold = 10,
}: UseLongPressOptions): UseLongPressResult => {
export const useLongPress = (
{
onTap,
onLongPress,
onContextMenu,
onCancel,
threshold = 500,
moveThreshold = 10,
}: UseLongPressOptions,
deps: React.DependencyList,
): UseLongPressResult => {
const [pressing, setPressing] = useState(false);
const timerRef = useRef<NodeJS.Timeout>();
const startPosRef = useRef<{ x: number; y: number } | null>(null);
@@ -114,15 +117,17 @@ export const useLongPress = ({
e.stopPropagation();
onContextMenu();
}
reset();
},
[onContextMenu],
[onContextMenu, reset],
);
useEffect(() => {
return () => {
clearTimeout(timerRef.current);
};
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return {
pressing,
+15 -7
View File
@@ -1,10 +1,12 @@
import { useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { isTauriAppPlatform } from '@/services/environment';
import { useLibraryStore } from '@/store/libraryStore';
import { navigateToLibrary } from '@/utils/nav';
import { navigateToLibrary, showLibraryWindow } from '@/utils/nav';
interface SingleInstancePayload {
args: string[];
@@ -13,6 +15,7 @@ interface SingleInstancePayload {
export function useOpenWithBooks() {
const router = useRouter();
const { appService } = useEnv();
const { setCheckOpenWithBooks } = useLibraryStore();
const listenedOpenWithBooks = useRef(false);
@@ -23,14 +26,19 @@ export function useOpenWithBooks() {
filePath = decodeURI(filePath.replace('file://', ''));
}
if (!/^(https?:|data:|blob:)/i.test(filePath)) {
window.OPEN_WITH_FILES = [filePath];
setCheckOpenWithBooks(true);
navigateToLibrary(router, `reload=${Date.now()}`);
const settings = useSettingsStore.getState().settings;
if (appService?.hasWindow && settings.openBookInNewWindow) {
showLibraryWindow(appService, [filePath]);
} else {
window.OPEN_WITH_FILES = [filePath];
setCheckOpenWithBooks(true);
navigateToLibrary(router, `reload=${Date.now()}`);
}
}
};
useEffect(() => {
if (!isTauriAppPlatform()) return;
if (!isTauriAppPlatform() || !appService) return;
if (listenedOpenWithBooks.current) return;
listenedOpenWithBooks.current = true;
@@ -54,5 +62,5 @@ export function useOpenWithBooks() {
unlistenOpenUrl.then((f) => f());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [appService]);
}
+1 -1
View File
@@ -8,7 +8,7 @@ export const useQuotaStats = () => {
const _ = useTranslation();
const { token, user } = useAuth();
const [quotas, setQuotas] = useState<QuotaType[]>([]);
const [userPlan, setUserPlan] = useState<UserPlan>('free');
const [userPlan, setUserPlan] = useState<UserPlan | undefined>(undefined);
useEffect(() => {
if (!user || !token) return;
+4
View File
@@ -100,5 +100,9 @@ export const useTheme = ({
const colorScheme = isDarkMode ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', `${themeColor}-${colorScheme}`);
document.documentElement.style.setProperty('color-scheme', colorScheme);
document.documentElement.style.setProperty(
'--overlayer-highlight-blend-mode',
isDarkMode ? 'lighten' : 'normal',
);
}, [themeColor, isDarkMode]);
};
+26 -11
View File
@@ -59,18 +59,33 @@ export interface SectionItem {
location?: Location;
}
export type BookMetadata = {
// NOTE: the title and author fields should be formatted
title: string | LanguageMap;
author: string | Contributor;
language: string | string[];
editor?: string;
publisher?: string;
published?: string;
description?: string;
subject?: string[];
identifier?: string;
subtitle?: string;
series?: string;
seriesIndex?: number;
seriesTotal?: number;
coverImageFile?: string;
coverImageUrl?: string;
coverImageBlobUrl?: string;
};
export interface BookDoc {
metadata: {
// NOTE: the title and author fields should be formatted
title: string | LanguageMap;
author: string | Contributor;
language: string | string[];
editor?: string;
publisher?: string;
published?: string;
description?: string;
subject?: string[];
identifier?: string;
metadata: BookMetadata;
rendition?: {
layout?: 'pre-paginated' | 'reflowable';
viewport?: { width: number; height: number };
};
dir: string;
toc?: Array<TOCItem>;
+31
View File
@@ -0,0 +1,31 @@
import { MetadataResult, SearchRequest } from '@/services/metadata/types';
import { getAPIBaseUrl } from '@/services/environment';
import { fetchWithAuth } from '@/utils/fetch';
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
timestamp: string;
responseTime?: number;
}
const API_ENDPOINT = getAPIBaseUrl() + '/metadata/search';
export const searchMetadata = async (request: SearchRequest): Promise<MetadataResult[]> => {
const response = await fetchWithAuth(API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
const result: ApiResponse<MetadataResult[]> = await response.json();
if (!result.success) {
throw new Error(result.error || 'Search failed');
}
return result.data || [];
};
+1 -1
View File
@@ -45,5 +45,5 @@ export function middleware(request: NextRequest) {
}
export const config = {
matcher: ['/api/stripe/:path*'],
matcher: ['/api/:path*', '/api/stripe/:path*', '/api/metadata/:path*'],
};
+68 -7
View File
@@ -18,7 +18,7 @@ import {
getPrimaryLanguage,
} from '@/utils/book';
import { partialMD5 } from '@/utils/md5';
import { BookDoc, DocumentLoader } from '@/libs/document';
import { BookDoc, DocumentLoader, EXTS } from '@/libs/document';
import {
DEFAULT_BOOK_LAYOUT,
DEFAULT_BOOK_STYLE,
@@ -36,6 +36,7 @@ import {
DEFAULT_SCREEN_CONFIG,
DEFAULT_TRANSLATOR_CONFIG,
} from './constants';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { getOSPlatform, getTargetLang, isCJKEnv, isContentURI, isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { downloadFile, uploadFile, deleteFile, createProgressHandler } from '@/libs/storage';
@@ -204,6 +205,7 @@ export abstract class BaseAppService implements AppService {
hash,
format,
title: formatTitle(loadedBook.metadata.title),
sourceTitle: formatTitle(loadedBook.metadata.title),
author: formatAuthors(loadedBook.metadata.author, loadedBook.metadata.language),
primaryLanguage: getPrimaryLanguage(loadedBook.metadata.language),
createdAt: existingBook ? existingBook.createdAt : Date.now(),
@@ -214,8 +216,9 @@ export abstract class BaseAppService implements AppService {
};
// update book metadata when reimporting the same book
if (existingBook) {
existingBook.title = book.title;
existingBook.author = book.author;
existingBook.title = existingBook.title ?? book.title;
existingBook.sourceTitle = existingBook.sourceTitle ?? book.sourceTitle;
existingBook.author = existingBook.author ?? book.author;
existingBook.primaryLanguage = existingBook.primaryLanguage ?? book.primaryLanguage;
}
@@ -297,6 +300,7 @@ export abstract class BaseAppService implements AppService {
if (includingLocal) {
book.deletedAt = Date.now();
book.downloadedAt = null;
book.coverDownloadedAt = null;
}
if (includingUploaded) {
book.uploadedAt = null;
@@ -356,6 +360,7 @@ export abstract class BaseAppService implements AppService {
book.updatedAt = Date.now();
book.uploadedAt = Date.now();
book.downloadedAt = Date.now();
book.coverDownloadedAt = Date.now();
} else {
throw new Error('Book file not uploaded');
}
@@ -376,12 +381,19 @@ export abstract class BaseAppService implements AppService {
}
}
async downloadBook(book: Book, onlyCover = false, onProgress?: ProgressHandler): Promise<void> {
async downloadBook(
book: Book,
onlyCover = false,
redownload = false,
onProgress?: ProgressHandler,
): Promise<void> {
let bookDownloaded = false;
let bookCoverDownloaded = false;
const completedFiles = { count: 0 };
let toDownloadFpCount = 0;
const needDownCover = !(await this.fs.exists(getCoverFilename(book), 'Books'));
const needDownBook = !onlyCover && !(await this.fs.exists(getLocalBookFilename(book), 'Books'));
const needDownCover = !(await this.fs.exists(getCoverFilename(book), 'Books')) || redownload;
const needDownBook =
(!onlyCover && !(await this.fs.exists(getLocalBookFilename(book), 'Books'))) || redownload;
if (needDownCover) {
toDownloadFpCount++;
}
@@ -401,6 +413,7 @@ export abstract class BaseAppService implements AppService {
const cfp = `${CLOUD_BOOKS_SUBDIR}/${lfp}`;
await this.downloadCloudFile(lfp, cfp, handleProgress);
completedFiles.count++;
bookCoverDownloaded = true;
}
} catch (error) {
// don't throw error here since some books may not have cover images at all
@@ -419,6 +432,9 @@ export abstract class BaseAppService implements AppService {
if (bookDownloaded || (!onlyCover && !needDownBook)) {
book.downloadedAt = Date.now();
}
if ((bookCoverDownloaded || !needDownCover) && !book.coverDownloadedAt) {
book.coverDownloadedAt = Date.now();
}
}
async isBookAvailable(book: Book): Promise<boolean> {
@@ -459,7 +475,19 @@ export abstract class BaseAppService implements AppService {
} else if (book.url) {
file = await this.fs.openFile(book.url, 'None');
} else {
throw new Error(BOOK_FILE_NOT_FOUND_ERROR);
// 0.9.64 has a bug that book.title might be modified but the filename is not updated
const bookDir = getDir(book);
const files = await this.fs.readDir(getDir(book), 'Books');
if (files.length > 0) {
const bookFile = files.find((f) => f.path.endsWith(`.${EXTS[book.format]}`));
if (bookFile) {
file = await this.fs.openFile(`${bookDir}/${bookFile.path}`, 'Books');
} else {
throw new Error(BOOK_FILE_NOT_FOUND_ERROR);
}
} else {
throw new Error(BOOK_FILE_NOT_FOUND_ERROR);
}
}
return { book, file, config: await this.loadBookConfig(book, settings) };
}
@@ -537,4 +565,37 @@ export abstract class BaseAppService implements AppService {
const libraryBooks = books.map(({ coverImageUrl, ...rest }) => rest);
await this.fs.writeFile(getLibraryFilename(), 'Books', JSON.stringify(libraryBooks));
}
private imageToArrayBuffer(imageUrl?: string, imageFile?: string): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
if (!imageUrl && !imageFile) {
reject(new Error('No image URL or file provided'));
return;
}
if (this.appPlatform === 'web' && imageUrl && imageUrl.startsWith('blob:')) {
fetch(imageUrl)
.then((response) => response.arrayBuffer())
.then((buffer) => resolve(buffer))
.catch((error) => reject(error));
} else if (this.appPlatform === 'tauri' && imageFile) {
this.fs
.openFile(imageFile, 'None')
.then((file) => file.arrayBuffer())
.then((buffer) => resolve(buffer))
.catch((error) => reject(error));
} else if (this.appPlatform === 'tauri' && imageUrl) {
tauriFetch(imageUrl, { method: 'GET' })
.then((response) => response.arrayBuffer())
.then((buffer) => resolve(buffer))
.catch((error) => reject(error));
} else {
reject(new Error('Unsupported platform or missing image data'));
}
});
}
async updateCoverImage(book: Book, imageUrl?: string, imageFile?: string): Promise<void> {
const arrayBuffer = await this.imageToArrayBuffer(imageUrl, imageFile);
await this.fs.writeFile(getCoverFilename(book), 'Books', arrayBuffer);
}
}
@@ -33,10 +33,14 @@ export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).j
export const BOOK_UNGROUPED_NAME = '';
export const BOOK_UNGROUPED_ID = '';
export const SUPPORTED_IMAGE_EXTS = ['png', 'jpg', 'jpeg'];
export const IMAGE_ACCEPT_FORMATS = SUPPORTED_IMAGE_EXTS.map((ext) => `.${ext}`).join(', ');
export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
keepLogin: false,
autoUpload: true,
alwaysOnTop: false,
openBookInNewWindow: false,
alwaysShowStatusBar: false,
autoCheckUpdates: true,
screenWakeLock: false,
@@ -0,0 +1,140 @@
import { MetadataProvider, SearchRequest, MetadataResult, Metadata } from '../types';
export abstract class BaseMetadataProvider implements MetadataProvider {
abstract name: string;
abstract label: string;
maxResults = 5;
protected abstract searchByISBN(isbn: string): Promise<Metadata[]>;
protected abstract searchByTitle(
title: string,
author?: string,
language?: string,
): Promise<Metadata[]>;
async search(request: SearchRequest): Promise<MetadataResult[]> {
const hasISBN = !!request.isbn && request.isbn.trim().length > 0;
const hasTitle = !!request.title && request.title.trim().length > 0;
let results: Metadata[] = [];
try {
if (hasISBN) {
results = await this.searchByISBN(request.isbn!.trim());
} else if (hasTitle) {
results = await this.searchByTitle(
request.title!.trim(),
request.author?.trim(),
request.language?.trim(),
);
}
if (results && results.length > 0) {
return results.map((metadata) => {
const confidence = this.calculateConfidence(metadata, request);
return {
metadata,
confidence,
providerName: this.name,
providerLabel: this.label,
};
});
}
return [];
} catch (error) {
console.error(`${this.name} provider search failed:`, error);
throw error;
}
}
protected calculateConfidence(metadata: Metadata, request: SearchRequest): number {
let confidence = 50;
// Higher confidence for ISBN matches
if (request.isbn && metadata.identifier) {
const cleanRequestIsbn = this.cleanISBN(request.isbn);
const cleanMetadataIsbn = this.cleanISBN(metadata.identifier);
if (cleanRequestIsbn === cleanMetadataIsbn) {
confidence += 40;
}
}
// Title match confidence
if (request.title && metadata.title) {
const titleSimilarity = this.calculateStringSimilarity(
request.title.toLowerCase(),
metadata.title.toLowerCase(),
);
confidence += Math.floor(titleSimilarity * 30);
}
// Author match confidence
if (request.author && metadata.author) {
const authorSimilarity = this.calculateStringSimilarity(
request.author.toLowerCase(),
metadata.author.toLowerCase(),
);
confidence += Math.floor(authorSimilarity * 20);
}
if (!metadata.coverImageUrl) {
confidence -= 20;
}
confidence += this.getProviderConfidenceBonus();
return Math.min(100, Math.max(0, confidence));
}
protected getProviderConfidenceBonus(): number {
return 0;
}
protected calculateStringSimilarity(str1: string, str2: string): number {
const longer = str1.length > str2.length ? str1 : str2;
const shorter = str1.length > str2.length ? str2 : str1;
if (longer.length === 0) return 1.0;
const editDistance = this.levenshteinDistance(longer, shorter);
return (longer.length - editDistance) / longer.length;
}
protected levenshteinDistance(str1: string, str2: string): number {
const matrix = [];
for (let i = 0; i <= str2.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= str1.length; j++) {
matrix[0]![j] = j;
}
for (let i = 1; i <= str2.length; i++) {
for (let j = 1; j <= str1.length; j++) {
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
matrix[i]![j] = matrix[i - 1]![j - 1]!;
} else {
matrix[i]![j] = Math.min(
matrix[i - 1]![j - 1]! + 1,
matrix[i]![j - 1]! + 1,
matrix[i - 1]![j]! + 1,
);
}
}
}
return matrix[str2.length]![str1.length]!;
}
protected cleanISBN(isbn: string): string {
return isbn.replace(/[-\s]/g, '');
}
protected validateISBN(isbn: string): boolean {
const cleanIsbn = this.cleanISBN(isbn);
return /^\d{10}(\d{3})?$/.test(cleanIsbn);
}
}
@@ -0,0 +1,216 @@
import { stubTranslation as _ } from '@/utils/misc';
import { fetchWithTimeout } from '@/utils/fetch';
import { normalizedLangCode } from '@/utils/lang';
import { Metadata } from '../types';
import { BaseMetadataProvider } from './base';
interface GoogleBooksImageLinks {
extraLarge?: string;
large?: string;
medium?: string;
small?: string;
thumbnail?: string;
smallThumbnail?: string;
}
interface GoogleBooksVolume {
title: string;
subtitle?: string;
authors?: string[];
publisher?: string;
publishedDate?: string;
language?: string;
industryIdentifiers?: { type: string; identifier: string }[];
imageLinks?: GoogleBooksImageLinks;
categories?: string[];
description?: string;
}
interface GoogleBooksItem {
volumeInfo: GoogleBooksVolume;
}
export class GoogleBooksProvider extends BaseMetadataProvider {
name = 'googlebooks';
label = _('Google Books');
private baseUrl = 'https://www.googleapis.com/books/v1';
private apiKeys: string[];
constructor(apiKeys: string) {
super();
if (!apiKeys) {
throw new Error('Google Books API keys are required');
}
this.apiKeys = apiKeys.split(',').map((key) => key.trim());
}
protected override getProviderConfidenceBonus(): number {
return 10;
}
private get apiKey(): string {
return this.apiKeys[Math.floor(Math.random() * this.apiKeys.length)]!;
}
protected async searchByISBN(isbn: string): Promise<Metadata[]> {
if (!this.validateISBN(isbn)) {
throw new Error('Invalid ISBN format');
}
try {
const response = await fetchWithTimeout(
`${this.baseUrl}/volumes?q=isbn:${isbn}&key=${this.apiKey}`,
);
if (!response.ok) {
if (response.status === 429) {
throw new Error('Google Books API rate limit exceeded');
}
if (response.status === 403) {
throw new Error('Google Books API access forbidden. Check your API key.');
}
throw new Error(`Google Books API error: ${response.status}`);
}
const data = await response.json();
if (!data.items || data.items.length === 0) {
return [];
}
return data.items
.slice(0, this.maxResults)
.map((item: GoogleBooksItem) => this.formatBookData(item.volumeInfo));
} catch (error) {
console.error('Google Books ISBN search failed:', error);
throw error;
}
}
protected async searchByTitle(
title: string,
author?: string,
language?: string,
): Promise<Metadata[]> {
if (!title || title.trim().length === 0) {
throw new Error('Title is required');
}
try {
let query = `intitle:${title.trim()}`;
if (author && author.trim()) {
query += `+inauthor:${author.trim()}`;
}
if (language && language.trim()) {
query += `+language:${normalizedLangCode(language.trim())}`;
}
const response = await fetchWithTimeout(
`${this.baseUrl}/volumes?q=${encodeURIComponent(query)}&key=${this.apiKey}`,
);
if (!response.ok) {
if (response.status === 429) {
throw new Error('Google Books API rate limit exceeded');
}
if (response.status === 403) {
throw new Error('Google Books API access forbidden. Check your API key.');
}
throw new Error(`Google Books API error: ${response.status}`);
}
const data = await response.json();
if (!data.items || data.items.length === 0) {
return [];
}
return data.items
.slice(0, this.maxResults)
.map((item: GoogleBooksItem) => this.formatBookData(item.volumeInfo));
} catch (error) {
console.error('Google Books title search failed:', error);
throw error;
}
}
private formatBookData(book: GoogleBooksVolume): Metadata {
return {
title: book.title || '',
subtitle: book.subtitle || '',
author: this.formatAuthors(book.authors),
publisher: book.publisher,
published: book.publishedDate,
language: book.language || 'en',
identifier: this.extractISBN(book.industryIdentifiers),
coverImageUrl: this.getCoverImage(book.imageLinks),
subjects: book.categories || [],
description: this.cleanDescription(book.description),
};
}
private formatAuthors(authors: string[] | undefined): string {
if (!authors || authors.length === 0) {
return '';
}
if (authors.length === 1) {
return authors[0]!;
}
if (authors.length === 2) {
return authors.join(' & ');
}
return `${authors[0]} et al.`;
}
private extractISBN(
identifiers: { type: string; identifier: string }[] | undefined,
): string | undefined {
if (!identifiers || identifiers.length === 0) {
return undefined;
}
const isbn13 = identifiers.find((id) => id.type === 'ISBN_13');
if (isbn13) {
return isbn13.identifier;
}
const isbn10 = identifiers.find((id) => id.type === 'ISBN_10');
if (isbn10) {
return isbn10.identifier;
}
return identifiers[0]!.identifier;
}
private getCoverImage(imageLinks?: GoogleBooksImageLinks): string | undefined {
if (!imageLinks) {
return undefined;
}
const coverUrl =
imageLinks.extraLarge ||
imageLinks.large ||
imageLinks.medium ||
imageLinks.small ||
imageLinks.thumbnail ||
imageLinks.smallThumbnail;
return coverUrl ? coverUrl.replace('http:', 'https:') : undefined;
}
private cleanDescription(description: string | undefined): string | undefined {
if (!description) {
return undefined;
}
return description
.replace(/<[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
}
@@ -0,0 +1,4 @@
export { OpenLibraryProvider } from './openlibrary';
export { GoogleBooksProvider } from './googlebooks';
export { BaseMetadataProvider } from './base';
export type { MetadataProvider } from '../types';
@@ -0,0 +1,156 @@
import { stubTranslation as _ } from '@/utils/misc';
import { fetchWithTimeout } from '@/utils/fetch';
import { Metadata } from '../types';
import { BaseMetadataProvider } from './base';
import { code6392to6391, normalizedLangCode } from '@/utils/lang';
interface OpenLibraryBookSearch {
title: string;
author_name?: { name: string }[];
publisher?: { name: string }[];
first_publish_year?: string;
language?: string[];
isbn?: string[];
cover_i?: number | string;
subject?: string[];
description?: string[];
}
interface OpenLibraryBookID {
title: string;
authors?: { name: string }[];
publishers?: { name: string }[];
publish_date?: string;
languages?: { name: string }[];
cover?: {
small?: string;
medium?: string;
large?: string;
};
subjects?: { name: string }[];
description?: string | { value: string };
}
export class OpenLibraryProvider extends BaseMetadataProvider {
name = 'openlibrary';
label = _('Open Library');
private baseUrl = 'https://openlibrary.org';
protected override getProviderConfidenceBonus(): number {
return 0;
}
protected async searchByISBN(isbn: string): Promise<Metadata[]> {
if (!this.validateISBN(isbn)) {
throw new Error('Invalid ISBN format');
}
try {
const response = await fetchWithTimeout(
`${this.baseUrl}/api/books?bibkeys=ISBN:${isbn}&format=json&jscmd=data`,
);
if (!response.ok) {
throw new Error(`Open Library API error: ${response.status}`);
}
const data = await response.json();
const bookKey = `ISBN:${isbn}`;
const book = data[bookKey];
if (!book) {
return [];
}
return [this.formatBookData(book, isbn)];
} catch (error) {
console.error('Open Library ISBN search failed:', error);
throw error;
}
}
protected async searchByTitle(
title: string,
author?: string,
language?: string,
): Promise<Metadata[]> {
if (!title || title.trim().length === 0) {
throw new Error('Title is required');
}
try {
let query = `title=${encodeURIComponent(title.trim())}`;
if (author && author.trim()) {
query += `&author=${encodeURIComponent(author.trim())}`;
}
if (language && language.trim()) {
query += `&lang=${encodeURIComponent(normalizedLangCode(language.trim()))}`;
}
const response = await fetchWithTimeout(`${this.baseUrl}/search.json?${query}`);
if (!response.ok) {
throw new Error(`Open Library API error: ${response.status}`);
}
const data = await response.json();
console.log('Open Library search results:', data);
if (!data.docs || data.docs.length === 0) {
return [];
}
return data.docs
.map((doc: OpenLibraryBookSearch) => this.formatSearchResult(doc))
.filter((metadata: Metadata) => (language ? metadata.language === language : true))
.slice(0, this.maxResults);
} catch (error) {
console.error('Open Library title search failed:', error);
throw error;
}
}
private formatBookData(book: OpenLibraryBookID, isbn: string): Metadata {
return {
title: book.title || '',
author: book.authors?.[0]?.name || '',
publisher: book.publishers?.[0]?.name,
published: book.publish_date,
language: code6392to6391(book.languages?.[0]?.name || ''),
identifier: isbn,
coverImage: book.cover?.large || book.cover?.medium || book.cover?.small,
subjects: book.subjects?.map((s: { name: string }) => s.name).slice(0, 5) || [],
description: this.extractDescription(book.description || ''),
} as Metadata;
}
private formatSearchResult(book: OpenLibraryBookSearch): Metadata {
return {
title: book.title || '',
author: book.author_name?.[0] || '',
publisher: book.publisher?.[0],
published: book.first_publish_year?.toString(),
language: code6392to6391(book.language?.[0] || ''),
identifier: book.isbn?.[0],
coverImageUrl: book.cover_i
? `https://covers.openlibrary.org/b/id/${book.cover_i}-L.jpg`
: undefined,
subjects: book.subject?.slice(0, 5) || [],
description: book.description?.[0],
} as Metadata;
}
private extractDescription(description: string | { value: string }): string | undefined {
if (!description) return undefined;
if (typeof description === 'string') {
return description;
}
if (description.value) {
return description.value;
}
return undefined;
}
}
@@ -0,0 +1,55 @@
import { MetadataProvider, SearchRequest, MetadataResult } from './types';
import { OpenLibraryProvider, GoogleBooksProvider } from './providers';
interface MetadataServiceConfig {
googleBooksApiKeys?: string;
providers?: MetadataProvider[];
}
export class MetadataService {
private providers: MetadataProvider[] = [];
constructor(config: MetadataServiceConfig = {}) {
this.providers.push(new OpenLibraryProvider());
if (config.googleBooksApiKeys) {
this.providers.push(new GoogleBooksProvider(config.googleBooksApiKeys));
}
if (config.providers) {
this.providers.push(...config.providers);
}
}
async search(request: SearchRequest): Promise<MetadataResult[]> {
const allResults: MetadataResult[] = [];
const promises = this.providers.map(async (provider) => {
try {
const results = await provider.search(request);
return results || [];
} catch (error) {
console.warn(`Provider ${provider.name} failed:`, error);
return [];
}
});
const responses = await Promise.allSettled(promises);
responses.forEach((response) => {
if (response.status === 'fulfilled') {
allResults.push(...response.value);
}
});
return allResults.sort((a, b) => b.confidence - a.confidence);
}
getProviders(): string[] {
return this.providers.map((p) => p.name);
}
getProviderCount(): number {
return this.providers.length;
}
}
@@ -0,0 +1,31 @@
export interface Metadata {
title: string;
subtitle?: string;
author: string;
publisher?: string;
published?: string;
language?: string;
identifier?: string;
subjects?: string[];
description?: string;
coverImageUrl?: string;
}
export interface SearchRequest {
title?: string;
isbn?: string;
author?: string;
language?: string;
}
export interface MetadataResult {
metadata: Metadata;
providerName: string;
providerLabel: string;
confidence: number;
}
export interface MetadataProvider {
name: string;
search(request: SearchRequest): Promise<MetadataResult[] | null>;
}
@@ -220,7 +220,7 @@ export class NativeAppService extends BaseAppService {
override hasWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
override hasWindowBar = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
override hasContextMenu = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
override hasRoundedWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android') && !!window.IS_ROUNDED;
override hasRoundedWindow = OS_TYPE === 'linux';
override hasSafeAreaInset = OS_TYPE === 'ios' || OS_TYPE === 'android';
override hasHaptics = OS_TYPE === 'ios' || OS_TYPE === 'android';
override hasUpdater =
@@ -2,6 +2,7 @@ import { TranslationProvider } from '../types';
import { deeplProvider } from './deepl';
import { azureProvider } from './azure';
import { googleProvider } from './google';
import { yandexProvider } from './yandex';
function createTranslator<T extends string>(
name: T,
@@ -18,11 +19,13 @@ function createTranslator<T extends string>(
const deeplTranslator = createTranslator('deepl', deeplProvider);
const azureTranslator = createTranslator('azure', azureProvider);
const googleTranslator = createTranslator('google', googleProvider);
const yandexTranslator = createTranslator('yandex', yandexProvider);
const availableTranslators = [
deeplTranslator,
azureTranslator,
googleTranslator,
yandexTranslator,
// Add more translators here
];
@@ -0,0 +1,77 @@
import { stubTranslation as _ } from '@/utils/misc';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { isTauriAppPlatform } from '@/services/environment';
import { normalizeToShortLang } from '@/utils/lang';
import { TranslationProvider } from '../types';
/**
* Based on https://translate.toil.cc/v2/docs API specification
*/
async function translateSingleTextForService(
text: string,
lang: string,
service: string,
): Promise<string[]> {
const fetchImpl = isTauriAppPlatform() ? tauriFetch : window.fetch;
const url = 'https://translate.toil.cc/v2/translate/';
const request = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
lang: lang,
service: service,
text: text,
})
};
const response = await fetchImpl(url, request);
if (!response.ok) {
const response_json = JSON.stringify(await response.json());
throw new Error(`${service} failed with status ${response.status}\n${text.length}\n${JSON.stringify(request)}\n${response_json}`);
}
const data = await response.json();
if (
data &&
Array.isArray(data.translations)
) {
return data.translations;
} else {
// fallback: return original texts if translation failed
return [text];
}
};
export const yandexProvider: TranslationProvider = {
name: 'yandex',
label: _('Yandex Translate'),
authRequired: false,
translate: async (texts: string[], sourceLang: string, targetLang: string): Promise<string[]> => {
if (!texts.length) return [];
/**
Possible options:
- yandexcloud: often returns 500: {"error":"The text couldn't be translated, because Forbidden"}
- yandexgpt: often better than others
- yandextranslate
- yandexbrowser
*/
const service = "yandexgpt";
// Yandex does not accept "auto" language
const source_lang = sourceLang == "AUTO" ? "en" : normalizeToShortLang(sourceLang).toLowerCase();
const target_lang = normalizeToShortLang(targetLang).toLowerCase();
const lang = `${source_lang}-${target_lang}`;
const responses = await Promise.all(texts.map(async text => {
return await translateSingleTextForService(text, lang, service)
}));
const translatedTexts = responses.flat();
return translatedTexts;
}
};

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