forked from akai/readest
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08957ce3d7 | |||
| 33515f1f09 | |||
| e3c411f043 | |||
| eda71725da | |||
| f19f45c00e | |||
| 6fb7897092 | |||
| 2de941d7fb | |||
| 3e4b7ebb13 | |||
| 2b52f82b57 | |||
| 9fb7e80757 | |||
| 38869ce438 | |||
| 1dbf93c1cf | |||
| aafcab8232 | |||
| bcfe1b8830 | |||
| 5db1847f7e | |||
| 165ca4a6e9 | |||
| 9556e1305f |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.63",
|
||||
"version": "0.9.64",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -72,6 +72,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",
|
||||
|
||||
@@ -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,39 @@
|
||||
"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": "اختر صورة الغلاف"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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": "Επιλέξτε εικόνα εξωφύλλου"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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": "कवर इमेज चुनें"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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": "表紙画像を選択"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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": "표지 이미지 선택"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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": "Выбрать изображение обложки"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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": "เลือกภาพปก"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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ç"
|
||||
}
|
||||
|
||||
@@ -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,30 +383,30 @@
|
||||
"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": "Необмежені Години Озвучування ШІ",
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "Слухайте без обмежень—перетворюйте стільки тексту, скільки хочете, в захоплююче аудіо.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Розблокуйте покращені можливості перекладу з більшим щоденним використанням та розширеними опціями.",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Розблокуйте покращені можливості перекладу з більшим щоденним використанням та розширеними функціями.",
|
||||
"DeepL Pro Access": "Доступ до DeepL Pro",
|
||||
"2 GB Cloud Sync Storage": "2 ГБ Хмарного Сховища",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "Насолоджуйтеся швидшими відповідями та персональною допомогою, коли вам потрібна підтримка.",
|
||||
@@ -418,12 +416,46 @@
|
||||
"Be the first to explore new features, updates, and innovations before anyone else.": "Досліджуйте нові функції, оновлення та інновації першими.",
|
||||
"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 символів щодня з найточнішим доступним движком перекладу.",
|
||||
"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 Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Художня література, Наука, Історія",
|
||||
"Select Cover Image": "Оберіть зображення обкладинки"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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": "选择封面图片"
|
||||
}
|
||||
|
||||
@@ -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,39 @@
|
||||
"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": "選擇封面圖片"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
{
|
||||
"releases": {
|
||||
"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 +20,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
|
||||
|
||||
@@ -30,8 +30,7 @@ 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 = { version = "2.5.1", features = [ "protocol-asset" ] }
|
||||
tauri-build = "2.3.0"
|
||||
tauri-plugin-log = "2.4.0"
|
||||
tauri-plugin-fs = "2.2.1"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.readest.native_bridge"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
|
||||
@@ -5,7 +5,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.readest.native_tts"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
|
||||
@@ -105,6 +105,11 @@ async fn start_server(window: Window) -> Result<u16, String> {
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_environment_variable(name: &str) -> String {
|
||||
std::env::var(String::from(name)).unwrap_or(String::from(""))
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[allow(dead_code)]
|
||||
struct Payload {
|
||||
@@ -121,6 +126,7 @@ pub fn run() {
|
||||
start_server,
|
||||
download_file,
|
||||
upload_file,
|
||||
get_environment_variable,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos::safari_auth::auth_with_safari,
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -220,28 +226,10 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
{
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
app.deep_link().register_all()?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
fn has_xdg_mime() -> bool {
|
||||
std::process::Command::new("which")
|
||||
.arg("xdg-mime")
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
if has_xdg_mime() {
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
app.deep_link().register_all()?;
|
||||
} else {
|
||||
println!("xdg-mime not found; skipping deep link setup on Linux.");
|
||||
}
|
||||
let _ = app.deep_link().register_all();
|
||||
}
|
||||
|
||||
if let Err(e) = app.handle().plugin(
|
||||
@@ -264,27 +252,12 @@ pub fn run() {
|
||||
.title("");
|
||||
|
||||
#[cfg(all(not(target_os = "macos"), desktop))]
|
||||
let win_builder = {
|
||||
let mut win_builder = win_builder
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.visible(false)
|
||||
.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);
|
||||
}
|
||||
|
||||
win_builder
|
||||
};
|
||||
let win_builder = win_builder
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.visible(false)
|
||||
.shadow(true)
|
||||
.title("Readest");
|
||||
|
||||
win_builder.build().unwrap();
|
||||
// let win = win_builder.build().unwrap();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -4,19 +4,25 @@ import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useState, useRef, useEffect, Suspense } 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) {
|
||||
@@ -550,15 +560,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);
|
||||
};
|
||||
@@ -626,9 +667,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 +732,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
handleBookDownload={handleBookDownload}
|
||||
handleBookDelete={handleBookDelete}
|
||||
handleBookDeleteCloudBackup={handleBookDeleteCloudBackup}
|
||||
handleBookMetadataUpdate={handleUpdateMetadata}
|
||||
/>
|
||||
)}
|
||||
<AboutWindow />
|
||||
{appService?.isAndroidApp && <UpdaterWindow />}
|
||||
<UpdaterWindow />
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,12 @@ 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 {
|
||||
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 +134,8 @@ const FoliateViewer: React.FC<{
|
||||
|
||||
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
|
||||
|
||||
applyImageStyle(detail.doc);
|
||||
|
||||
// Inline scripts in tauri platforms are not executed by default
|
||||
if (viewSettings.allowScript && isTauriAppPlatform()) {
|
||||
evalInlineScripts(detail.doc);
|
||||
@@ -242,7 +249,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!;
|
||||
|
||||
@@ -118,7 +118,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
<Suspense>
|
||||
<ReaderContent ids={ids} settings={settings} />
|
||||
<AboutWindow />
|
||||
{appService?.isAndroidApp && <UpdaterWindow />}
|
||||
<UpdaterWindow />
|
||||
<Toast />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -20,10 +20,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';
|
||||
|
||||
@@ -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)]',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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,6 +9,7 @@ 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 Dropdown from '@/components/Dropdown';
|
||||
import SearchOptions from './SearchOptions';
|
||||
@@ -38,6 +39,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 +50,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 +93,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,14 +117,6 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
return searchTerm.length >= minLength;
|
||||
};
|
||||
|
||||
const handleSearchTermChange = (term: string) => {
|
||||
if (exceedMinSearchTermLength(term)) {
|
||||
handleSearch(term);
|
||||
} else {
|
||||
resetSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const createAcceptNode = ({ withRT = true } = {}) => {
|
||||
return (node: Node): number => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
@@ -153,46 +130,72 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
};
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (result.progress) {
|
||||
//console.log('search progress:', result.progress);
|
||||
} else {
|
||||
results.push(result);
|
||||
onSearchResultChange([...results]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
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: createAcceptNode({ withRT: !primaryLang.startsWith('ja') }),
|
||||
});
|
||||
const results: BookSearchResult[] = [];
|
||||
let lastProgressLogTime = 0;
|
||||
|
||||
const resetSearch = () => {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
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'>
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -37,7 +37,7 @@ const BookCover: React.FC<BookCoverProps> = ({
|
||||
<div 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)}
|
||||
@@ -51,7 +51,7 @@ const BookCover: React.FC<BookCoverProps> = ({
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={book.coverImageUrl!}
|
||||
src={book.metadata?.coverImageUrl || book.coverImageUrl!}
|
||||
alt={book.title}
|
||||
width={0}
|
||||
height={0}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,377 @@
|
||||
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 (files.length > 0) {
|
||||
metadata.coverImageFile = files[0]!;
|
||||
metadata.coverImageUrl = appService?.fs.getURL(files[0]!);
|
||||
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'>
|
||||
<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='h-5 w-5 sm:h-4 sm:w-4' />
|
||||
<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}
|
||||
className='rounded-md bg-green-500 px-4 py-2 text-white hover:bg-green-600'
|
||||
>
|
||||
{_('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) => 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);
|
||||
}
|
||||
};
|
||||
|
||||
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) || _('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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -59,19 +59,30 @@ 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;
|
||||
dir: string;
|
||||
toc?: Array<TOCItem>;
|
||||
sections?: Array<SectionItem>;
|
||||
|
||||
@@ -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 || [];
|
||||
};
|
||||
@@ -45,5 +45,5 @@ export function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/api/stripe/:path*'],
|
||||
matcher: ['/api/stripe/:path*', '/api/metadata/:path*'],
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
@@ -537,4 +538,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,6 +33,9 @@ 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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -140,6 +140,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
// Set the book's language for formerly imported books, newly imported books have this field set
|
||||
book.primaryLanguage =
|
||||
book.primaryLanguage ?? getPrimaryLanguage(bookDoc.metadata.language);
|
||||
book.metadata = book.metadata ?? bookDoc.metadata;
|
||||
useBookDataStore.setState((state) => ({
|
||||
booksData: {
|
||||
...state.booksData,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BookMetadata } from '@/libs/document';
|
||||
|
||||
export type BookFormat = 'EPUB' | 'PDF' | 'MOBI' | 'CBZ' | 'FB2' | 'FBZ';
|
||||
export type BookNoteType = 'bookmark' | 'annotation' | 'excerpt';
|
||||
export type HighlightStyle = 'highlight' | 'underline' | 'squiggly';
|
||||
@@ -28,6 +30,8 @@ export interface Book {
|
||||
lastUpdated?: number; // deprecated in favor of updatedAt
|
||||
progress?: [number, number]; // Add progress field: [current, total], 1-based page number
|
||||
primaryLanguage?: string;
|
||||
|
||||
metadata?: BookMetadata;
|
||||
}
|
||||
|
||||
export interface BookGroupType {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SystemSettings } from './settings';
|
||||
import { Book, BookConfig, BookContent, ViewSettings } from './book';
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { BookMetadata } from '@/libs/document';
|
||||
import { ProgressHandler } from '@/utils/transfer';
|
||||
|
||||
export type AppPlatform = 'web' | 'tauri';
|
||||
@@ -63,7 +63,7 @@ export interface AppService {
|
||||
isBookAvailable(book: Book): Promise<boolean>;
|
||||
getBookFileSize(book: Book): Promise<number | null>;
|
||||
loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig>;
|
||||
fetchBookDetails(book: Book, settings: SystemSettings): Promise<BookDoc['metadata']>;
|
||||
fetchBookDetails(book: Book, settings: SystemSettings): Promise<BookMetadata>;
|
||||
saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings): Promise<void>;
|
||||
loadBookContent(book: Book, settings: SystemSettings): Promise<BookContent>;
|
||||
loadLibraryBooks(): Promise<Book[]>;
|
||||
@@ -71,4 +71,5 @@ export interface AppService {
|
||||
getCoverImageUrl(book: Book): string;
|
||||
getCoverImageBlobUrl(book: Book): Promise<string>;
|
||||
generateCoverImageUrl(book: Book): Promise<string>;
|
||||
updateCoverImage(book: Book, imageUrl?: string, imageFile?: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getAccessToken } from './access';
|
||||
|
||||
export const fetchWithTimeout = (url: string, options: RequestInit, timeout = 8000) => {
|
||||
export const fetchWithTimeout = (url: string, options: RequestInit = {}, timeout = 10000) => {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort('Request timed out'), timeout);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { iso6392 } from 'iso-639-2';
|
||||
|
||||
export const isCJKStr = (str: string) => {
|
||||
return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(str ?? '');
|
||||
};
|
||||
@@ -65,3 +67,8 @@ export const isSameLang = (lang1?: string | null, lang2?: string | null): boolea
|
||||
const normalizedLang2 = normalizedLangCode(lang2);
|
||||
return normalizedLang1 === normalizedLang2;
|
||||
};
|
||||
|
||||
export const code6392to6391 = (code: string): string => {
|
||||
const lang = iso6392.find((l) => l.iso6392B === code);
|
||||
return lang?.iso6391 || '';
|
||||
};
|
||||
|
||||
@@ -84,7 +84,24 @@ export const getPosition = (
|
||||
const [sx, , , sy] = match?.[1]?.split(/\s*,\s*/)?.map((x) => parseFloat(x)) ?? [];
|
||||
|
||||
const frame = frameElement?.getBoundingClientRect() ?? { top: 0, left: 0 };
|
||||
const rects = Array.from(target.getClientRects());
|
||||
let padding = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
if ('nodeType' in target && target.nodeType === 1) {
|
||||
const computedStyle = window.getComputedStyle(target);
|
||||
padding = {
|
||||
top: parseInt(computedStyle.paddingTop, 10) || 0,
|
||||
right: parseInt(computedStyle.paddingRight, 10) || 0,
|
||||
bottom: parseInt(computedStyle.paddingBottom, 10) || 0,
|
||||
left: parseInt(computedStyle.paddingLeft, 10) || 0,
|
||||
};
|
||||
}
|
||||
const rects = Array.from(target.getClientRects()).map((rect) => {
|
||||
return {
|
||||
top: rect.top + padding.top,
|
||||
right: rect.right - padding.right,
|
||||
bottom: rect.bottom - padding.bottom,
|
||||
left: rect.left + padding.left,
|
||||
};
|
||||
});
|
||||
const first = frameRect(frame, rects[0], sx, sy);
|
||||
const last = frameRect(frame, rects.at(-1), sx, sy);
|
||||
|
||||
|
||||
@@ -211,6 +211,11 @@ const getLayoutStyles = (
|
||||
width: auto;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
/* enlarge the clickable area of links */
|
||||
a {
|
||||
padding: 10px;
|
||||
margin: -10px;
|
||||
}
|
||||
p, blockquote, dd, div:not(:has(*:not(b, a, em, i, strong, u, span))) {
|
||||
line-height: ${lineSpacing} ${overrideLayout ? '!important' : ''};
|
||||
word-spacing: ${wordSpacing}px ${overrideLayout ? '!important' : ''};
|
||||
@@ -227,12 +232,13 @@ const getLayoutStyles = (
|
||||
widows: 2;
|
||||
}
|
||||
p:has(> img:only-child), p:has(> span:only-child > img:only-child),
|
||||
p:has(> img:not(.has-text-siblings)),
|
||||
p:has(> a:first-child + img:last-child) {
|
||||
text-indent: unset !important;
|
||||
text-indent: initial !important;
|
||||
}
|
||||
blockquote[align="center"], div[align="center"],
|
||||
p[align="center"], dd[align="center"] {
|
||||
text-indent: unset !important;
|
||||
text-indent: initial !important;
|
||||
}
|
||||
p {
|
||||
${vertical ? `margin-left: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
|
||||
@@ -286,13 +292,12 @@ const getLayoutStyles = (
|
||||
}
|
||||
|
||||
/* inline images without dimension */
|
||||
p > img, span > img, sup img {
|
||||
sup img {
|
||||
height: 1em;
|
||||
}
|
||||
p:has(> img:only-child) img, span:has(> img:only-child) img,
|
||||
p:has(> a:first-child + img:last-child) img,
|
||||
span:has(> a:first-child + img:last-child) img {
|
||||
height: auto;
|
||||
img.has-text-siblings {
|
||||
height: 1em;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.ie6 img {
|
||||
width: auto;
|
||||
@@ -328,6 +333,8 @@ export const getFootnoteStyles = () => `
|
||||
|
||||
a:any-link {
|
||||
text-decoration: none;
|
||||
padding: unset;
|
||||
margin: unset;
|
||||
}
|
||||
|
||||
ol {
|
||||
@@ -446,8 +453,8 @@ export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) =>
|
||||
return `${layoutStyles}\n${fontStyles}\n${colorStyles}\n${translationStyles}\n${userStylesheet}`;
|
||||
};
|
||||
|
||||
export const applyTranslationStyles = (viewSettings: ViewSettings) => {
|
||||
const styleId = 'translation-styles';
|
||||
export const applyTranslationStyle = (viewSettings: ViewSettings) => {
|
||||
const styleId = 'translation-style';
|
||||
|
||||
const existingStyle = document.getElementById(styleId);
|
||||
if (existingStyle) {
|
||||
@@ -509,3 +516,16 @@ export const transformStylesheet = (vw: number, vh: number, css: string) => {
|
||||
.replace(/[\s;]color\s*:\s*rgb\(0,\s*0,\s*0\)/gi, 'color: var(--theme-fg-color)');
|
||||
return css;
|
||||
};
|
||||
|
||||
export const applyImageStyle = (document: Document) => {
|
||||
document.querySelectorAll('img').forEach((img) => {
|
||||
const parent = img.parentNode;
|
||||
if (!parent || parent.nodeType !== Node.ELEMENT_NODE) return;
|
||||
const hasTextSiblings = Array.from(parent.childNodes).some(
|
||||
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim(),
|
||||
);
|
||||
if (hasTextSiblings) {
|
||||
img.classList.add('has-text-siblings');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Validation utilities for metadata fields
|
||||
*/
|
||||
|
||||
// ISO 639-1 language codes (2-letter codes)
|
||||
// prettier-ignore
|
||||
const ISO_639_1_CODES = new Set([
|
||||
'aa', 'ab', 'ae', 'af', 'ak', 'am', 'an', 'ar', 'as', 'av', 'ay', 'az',
|
||||
'ba', 'be', 'bg', 'bh', 'bi', 'bm', 'bn', 'bo', 'br', 'bs',
|
||||
'ca', 'ce', 'ch', 'co', 'cr', 'cs', 'cu', 'cv', 'cy',
|
||||
'da', 'de', 'dv', 'dz',
|
||||
'ee', 'el', 'en', 'eo', 'es', 'et', 'eu',
|
||||
'fa', 'ff', 'fi', 'fj', 'fo', 'fr', 'fy',
|
||||
'ga', 'gd', 'gl', 'gn', 'gu', 'gv',
|
||||
'ha', 'he', 'hi', 'ho', 'hr', 'ht', 'hu', 'hy', 'hz',
|
||||
'ia', 'id', 'ie', 'ig', 'ii', 'ik', 'io', 'is', 'it', 'iu',
|
||||
'ja', 'jv',
|
||||
'ka', 'kg', 'ki', 'kj', 'kk', 'kl', 'km', 'kn', 'ko', 'kr', 'ks', 'ku', 'kv', 'kw', 'ky',
|
||||
'la', 'lb', 'lg', 'li', 'ln', 'lo', 'lt', 'lu', 'lv',
|
||||
'mg', 'mh', 'mi', 'mk', 'ml', 'mn', 'mr', 'ms', 'mt', 'my',
|
||||
'na', 'nb', 'nd', 'ne', 'ng', 'nl', 'nn', 'no', 'nr', 'nv', 'ny',
|
||||
'oc', 'oj', 'om', 'or', 'os',
|
||||
'pa', 'pi', 'pl', 'ps', 'pt',
|
||||
'qu',
|
||||
'rm', 'rn', 'ro', 'ru', 'rw',
|
||||
'sa', 'sc', 'sd', 'se', 'sg', 'si', 'sk', 'sl', 'sm', 'sn', 'so', 'sq', 'sr', 'ss', 'st', 'su', 'sv', 'sw',
|
||||
'ta', 'te', 'tg', 'th', 'ti', 'tk', 'tl', 'tn', 'to', 'tr', 'ts', 'tt', 'tw', 'ty',
|
||||
'ug', 'uk', 'ur', 'uz',
|
||||
've', 'vi', 'vo',
|
||||
'wa', 'wo',
|
||||
'xh',
|
||||
'yi', 'yo',
|
||||
'za', 'zh', 'zu'
|
||||
]);
|
||||
|
||||
export interface ValidationResult<T> {
|
||||
isValid: boolean;
|
||||
value: T | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and normalizes date input
|
||||
* Accepts YYYY, YYYY-MM, or YYYY-MM-DD formats
|
||||
* Returns ISO string if valid, null if invalid
|
||||
*/
|
||||
export const validateAndNormalizeDate = (dateInput: string): ValidationResult<string> => {
|
||||
if (!dateInput) {
|
||||
return { isValid: true, value: '' };
|
||||
}
|
||||
|
||||
const cleaned = dateInput.trim();
|
||||
// Pattern for YYYY, YYYY-MM, or YYYY-MM-DD
|
||||
const datePatterns = [
|
||||
{ pattern: /^\d{4}$/, format: 'YYYY' },
|
||||
{ pattern: /^\d{4}-\d{2}$/, format: 'YYYY-MM' },
|
||||
{ pattern: /^\d{4}-\d{2}-\d{2}$/, format: 'YYYY-MM-DD' },
|
||||
];
|
||||
|
||||
const matchingPattern = datePatterns.find(({ pattern }) => pattern.test(cleaned));
|
||||
if (!matchingPattern) {
|
||||
return {
|
||||
isValid: false,
|
||||
value: null,
|
||||
error: 'Invalid date format. Use YYYY, YYYY-MM, or YYYY-MM-DD',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
let date: Date;
|
||||
|
||||
if (cleaned.length === 4) {
|
||||
// YYYY format - set to January 1st
|
||||
date = new Date(`${cleaned}-01-01`);
|
||||
} else if (cleaned.length === 7) {
|
||||
// YYYY-MM format - set to 1st day of month
|
||||
date = new Date(`${cleaned}-01`);
|
||||
} else {
|
||||
// YYYY-MM-DD format
|
||||
date = new Date(cleaned);
|
||||
}
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
return {
|
||||
isValid: false,
|
||||
value: null,
|
||||
error: 'Invalid date',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if year is reasonable (between 1000 and current year + 10)
|
||||
const year = date.getFullYear();
|
||||
const currentYear = new Date().getFullYear();
|
||||
if (year < 1000 || year > currentYear + 10) {
|
||||
return {
|
||||
isValid: false,
|
||||
value: null,
|
||||
error: `Year must be between 1000 and ${currentYear + 10}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate month and day if provided
|
||||
if (cleaned.length >= 7) {
|
||||
const month = parseInt(cleaned.substring(5, 7));
|
||||
if (month < 1 || month > 12) {
|
||||
return {
|
||||
isValid: false,
|
||||
value: null,
|
||||
error: 'Month must be between 01 and 12',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned.length === 10) {
|
||||
const day = parseInt(cleaned.substring(8, 10));
|
||||
if (day < 1 || day > 31) {
|
||||
return {
|
||||
isValid: false,
|
||||
value: null,
|
||||
error: 'Day must be between 01 and 31',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
value: date.toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
isValid: false,
|
||||
value: null,
|
||||
error: 'Failed to parse date',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates and normalizes language code input
|
||||
* Accepts ISO 639-1 codes with optional country codes (e.g., en, en-US, zh-CN)
|
||||
* Returns normalized language code if valid, null if invalid
|
||||
*/
|
||||
export const validateAndNormalizeLanguage = (languageInput: string): ValidationResult<string> => {
|
||||
if (!languageInput) {
|
||||
return { isValid: true, value: 'unknown' };
|
||||
}
|
||||
|
||||
const cleaned = languageInput.trim().toLowerCase();
|
||||
// Pattern for language codes with optional country codes (e.g., en-US, zh-CN)
|
||||
const languagePattern = /^[a-z]{2}(-[a-z]{2,4})?$/i;
|
||||
if (!languagePattern.test(cleaned)) {
|
||||
return {
|
||||
isValid: false,
|
||||
value: null,
|
||||
error: 'Invalid language format. Use ISO 639-1 codes (e.g., en, zh-CN)',
|
||||
};
|
||||
}
|
||||
|
||||
const languageCode = cleaned.substring(0, 2);
|
||||
if (!ISO_639_1_CODES.has(languageCode)) {
|
||||
return {
|
||||
isValid: false,
|
||||
value: null,
|
||||
error: `Invalid language code: ${languageCode}. Must be a valid ISO 639-1 code`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
value: cleaned,
|
||||
};
|
||||
};
|
||||
|
||||
export const validateISBN = (isbn: string): ValidationResult<string> => {
|
||||
if (!isbn) {
|
||||
return { isValid: true, value: '' };
|
||||
}
|
||||
|
||||
const cleaned = isbn.replace(/[-\s]/g, '');
|
||||
if (cleaned.length !== 10 && cleaned.length !== 13) {
|
||||
return {
|
||||
isValid: false,
|
||||
value: null,
|
||||
error: 'ISBN must be 10 or 13 digits',
|
||||
};
|
||||
}
|
||||
|
||||
// Validate ISBN-10
|
||||
if (cleaned.length === 10) {
|
||||
const isValid = validateISBN10(cleaned);
|
||||
return {
|
||||
isValid,
|
||||
value: isValid ? cleaned : null,
|
||||
error: isValid ? undefined : 'Invalid ISBN-10 checksum',
|
||||
};
|
||||
}
|
||||
|
||||
// Validate ISBN-13
|
||||
if (cleaned.length === 13) {
|
||||
const isValid = validateISBN13(cleaned);
|
||||
return {
|
||||
isValid,
|
||||
value: isValid ? cleaned : null,
|
||||
error: isValid ? undefined : 'Invalid ISBN-13 checksum',
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: false, value: null, error: 'Invalid ISBN format' };
|
||||
};
|
||||
|
||||
const validateISBN10 = (isbn: string): boolean => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const digit = parseInt(isbn[i]!);
|
||||
if (isNaN(digit)) return false;
|
||||
sum += digit * (10 - i);
|
||||
}
|
||||
|
||||
const lastChar = isbn[9]!;
|
||||
const checkDigit = lastChar === 'X' ? 10 : parseInt(lastChar);
|
||||
if (isNaN(checkDigit)) return false;
|
||||
|
||||
sum += checkDigit;
|
||||
return sum % 11 === 0;
|
||||
};
|
||||
|
||||
const validateISBN13 = (isbn: string): boolean => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const digit = parseInt(isbn[i]!);
|
||||
if (isNaN(digit)) return false;
|
||||
sum += digit * (i % 2 === 0 ? 1 : 3);
|
||||
}
|
||||
|
||||
const checkDigit = parseInt(isbn[12]!);
|
||||
if (isNaN(checkDigit)) return false;
|
||||
|
||||
const calculatedCheck = (10 - (sum % 10)) % 10;
|
||||
return checkDigit === calculatedCheck;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates subjects/genres input
|
||||
* Accepts comma-separated values and returns cleaned array
|
||||
*/
|
||||
export const validateAndNormalizeSubjects = (subjectsInput: string): ValidationResult<string[]> => {
|
||||
if (!subjectsInput) {
|
||||
return { isValid: true, value: [] };
|
||||
}
|
||||
|
||||
const subjects = subjectsInput.split(',').map((s) => s.trim());
|
||||
|
||||
const maxSubjects = 20;
|
||||
const maxSubjectLength = 100;
|
||||
|
||||
if (subjects.length > maxSubjects) {
|
||||
return {
|
||||
isValid: false,
|
||||
value: null,
|
||||
error: `Too many subjects (max ${maxSubjects})`,
|
||||
};
|
||||
}
|
||||
|
||||
const tooLongSubject = subjects.find((s) => s.length > maxSubjectLength);
|
||||
if (tooLongSubject) {
|
||||
return {
|
||||
isValid: false,
|
||||
value: null,
|
||||
error: `Subject too long (max ${maxSubjectLength} characters): ${tooLongSubject}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
value: subjects,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.bilingify.readest</id>
|
||||
|
||||
<name>Readest</name>
|
||||
<summary>Modern, feature-rich ebook reader for immersive reading experiences</summary>
|
||||
<icon type="stock">com.bilingify.readest</icon>
|
||||
|
||||
<description>
|
||||
<p>Readest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.</p>
|
||||
<p>Built as a modern rewrite of Foliate, Readest leverages Next.js 15 and Tauri v2 to deliver a smooth, cross-platform experience across macOS, Windows, Linux, Android, iOS, and the Web.</p>
|
||||
<p>Whether you're analyzing complex texts, immersing yourself in stories, or exploring new ideas, Readest adapts to your needs with powerful features for deep, thoughtful reading.</p>
|
||||
<p>Key features include:</p>
|
||||
<ul>
|
||||
<li>Multi-format support for EPUB, MOBI, KF8 (AZW3), FB2, CBZ, TXT, and PDF (experimental).</li>
|
||||
<li>Switch between scrolling or paginated reading modes for your preferred reading style.</li>
|
||||
<li>Full-text search across the entire book to find relevant sections quickly.</li>
|
||||
<li>Add highlights, bookmarks, and notes to enhance your reading experience and learning.</li>
|
||||
<li>Easily excerpt text from books for detailed notes and analysis.</li>
|
||||
<li>Instantly look up words and terms with built-in dictionary and Wikipedia integration.</li>
|
||||
<li>Translate selected text instantly using DeepL for accurate translations.</li>
|
||||
<li>Parallel Reading: Read two books or documents simultaneously in a split-screen view, perfect for researchers, language learners, and multitaskers.</li>
|
||||
<li>Customize fonts, layout, theme mode, and theme colors for a personalized reading experience.</li>
|
||||
<li>Cross-platform synchronization of book files, reading progress, notes, and bookmarks across all supported platforms.</li>
|
||||
<li>Text-to-Speech functionality for a more accessible reading experience.</li>
|
||||
<li>Organize, sort, and manage your entire ebook library with advanced library management tools.</li>
|
||||
<li>One-click file association to quickly open files in Readest from your file browser.</li>
|
||||
</ul>
|
||||
<p>Readest also includes custom CSS support for personalized styling, and code syntax highlighting for technical books. The application is designed to honor the legacy of deep reading while embracing modern technology for an enhanced experience.</p>
|
||||
</description>
|
||||
|
||||
<categories>
|
||||
<category>Office</category>
|
||||
<category>Education</category>
|
||||
<category>Literature</category>
|
||||
</categories>
|
||||
|
||||
<keywords>
|
||||
<keyword translate="no">readest</keyword>
|
||||
<keyword>ebook</keyword>
|
||||
<keyword>reader</keyword>
|
||||
<keyword>epub</keyword>
|
||||
<keyword>mobi</keyword>
|
||||
<keyword>pdf</keyword>
|
||||
<keyword>reading</keyword>
|
||||
<keyword>books</keyword>
|
||||
<keyword>annotation</keyword>
|
||||
<keyword>highlight</keyword>
|
||||
<keyword>notes</keyword>
|
||||
<keyword>translation</keyword>
|
||||
<keyword>tts</keyword>
|
||||
<keyword>sync</keyword>
|
||||
</keywords>
|
||||
|
||||
<url type="homepage">https://readest.com</url>
|
||||
<url type="bugtracker">https://github.com/readest/readest/issues</url>
|
||||
<url type="help">https://github.com/readest/readest/wiki</url>
|
||||
<url type="donation">https://github.com/sponsors/readest</url>
|
||||
|
||||
<launchable type="desktop-id">com.bilingify.readest.desktop</launchable>
|
||||
|
||||
<releases>
|
||||
<release version="0.9.63" date="2025-07-07">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Reader: Fixed links not working in some MOBI books</li>
|
||||
<li>TTS: Fixed an issue where some text was skipped during reading</li>
|
||||
<li>Library: You can now delete only the cloud backup of a book without removing the local copy</li>
|
||||
<li>Performance: Fonts in the font list now load only when displayed for faster startup</li>
|
||||
<li>Performance: Opening large MOBI books is now significantly faster</li>
|
||||
<li>Layout: Fixed overlay scrollbar display in virtualized book lists</li>
|
||||
<li>Layout: Improved grid cover image height in crop mode</li>
|
||||
<li>Layout: Removed unsupported screen orientation lock option on iPad</li>
|
||||
<li>Layout: System context menu no longer appears when selecting text on iPad</li>
|
||||
<li>Translation: Added Thai (th-TH) language support</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.9.62" date="2025-07-02">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Subscription: You can now upgrade to Readest Premium and manage your subscription</li>
|
||||
<li>Reader: Added an option to toggle Parallel Reading when viewing multiple books</li>
|
||||
<li>Translation: Added an option to show or hide the original text in translations</li>
|
||||
<li>Layout: TOC on Android now uses an overlay scrollbar for easier navigation</li>
|
||||
<li>Layout: Custom CSS can now also be applied to the library page</li>
|
||||
<li>Fonts: Added three new CJK fonts to choose from</li>
|
||||
<li>Shortcuts: Press ESC to quickly close the search bar</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.9.61" date="2025-06-25">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Layout: Fixed view menu width on smaller screens</li>
|
||||
<li>Layout: Added option to crop or fit book covers in library view</li>
|
||||
<li>Reader: You can now apply custom CSS to the reading interface (UI)</li>
|
||||
<li>Reader: Added an option to show remaining pages in the current chapter</li>
|
||||
<li>Settings: Added options to reset settings from the config dialog</li>
|
||||
<li>Import: Fixed encoded filenames when importing TXT files on iOS</li>
|
||||
<li>Linux: Fixed missing app icon on some Linux distributions</li>
|
||||
<li>GitHub: Added donation link to support Readest development</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.9.60" date="2025-06-18">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Layout has been improved for iPad devices</li>
|
||||
<li>Screen orientation changes are now smoother on iOS</li>
|
||||
<li>Importing books on iOS during the first launch is now more reliable</li>
|
||||
<li>Font size no longer changes unexpectedly in scrolled mode on iOS</li>
|
||||
<li>Top and bottom safe insets are now ignored when no header or footer is present</li>
|
||||
<li>Interactive books now load correctly on the Desktop platform</li>
|
||||
<li>Translation is now disabled when the primary language is undefined</li>
|
||||
<li>Android: the task list background color has been updated with current theme color</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.9.59" date="2025-06-12">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Layout: Image size now adjusts more accurately by converting screen units to pixels properly</li>
|
||||
<li>Layout: Improved header layout on iOS landscape mode — extra spacing has been removed</li>
|
||||
<li>Settings: Top and bottom margin settings are now more intuitive and better constrained</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
|
||||
<metadata_license>FSFAP</metadata_license>
|
||||
<project_license>AGPL-3.0-or-later</project_license>
|
||||
|
||||
<developer id="com.readest">
|
||||
<name>Readest Team</name>
|
||||
</developer>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Readest main interface with annotation</caption>
|
||||
<image type="source">https://raw.githubusercontent.com/readest/readest/refs/heads/main/data/screenshots/annotations.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Reading interface with dark mode</caption>
|
||||
<image type="source">https://raw.githubusercontent.com/readest/readest/refs/heads/main/data/screenshots/dark_mode.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<content_rating type="oars-1.0"></content_rating>
|
||||
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#d97706</color>
|
||||
<color type="primary" scheme_preference="dark">#b45309</color>
|
||||
</branding>
|
||||
</component>
|
||||
@@ -0,0 +1,2 @@
|
||||
package_name(ENV["PACKAGE_NAME"])
|
||||
json_key_file(ENV["GOOGLE_PLAY_JSON_KEY_FILE"])
|
||||
@@ -0,0 +1,44 @@
|
||||
default_platform(:android)
|
||||
|
||||
platform :android do
|
||||
aab_file = "./apps/readest-app/src-tauri/gen/android/app/build/outputs/bundle/universalRelease/app-universal-release.aab"
|
||||
|
||||
desc "Upload AAB to Google Play Production"
|
||||
lane :upload_production do
|
||||
upload_to_play_store(
|
||||
aab: aab_file,
|
||||
track: "production",
|
||||
skip_upload_apk: true,
|
||||
skip_upload_metadata: true,
|
||||
skip_upload_images: true,
|
||||
skip_upload_screenshots: true,
|
||||
skip_upload_changelogs: true
|
||||
)
|
||||
end
|
||||
|
||||
desc "Upload AAB to Google Play Internal Testing"
|
||||
lane :upload_internal do
|
||||
upload_to_play_store(
|
||||
aab: aab_file,
|
||||
track: "internal",
|
||||
skip_upload_apk: true,
|
||||
skip_upload_metadata: true,
|
||||
skip_upload_images: true,
|
||||
skip_upload_screenshots: true,
|
||||
skip_upload_changelogs: true
|
||||
)
|
||||
end
|
||||
|
||||
desc "Upload AAB to Google Play Beta"
|
||||
lane :upload_beta do
|
||||
upload_to_play_store(
|
||||
aab: aab_file,
|
||||
track: "beta",
|
||||
skip_upload_apk: true,
|
||||
skip_upload_metadata: true,
|
||||
skip_upload_images: true,
|
||||
skip_upload_screenshots: true,
|
||||
skip_upload_changelogs: true
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
fastlane documentation
|
||||
----
|
||||
|
||||
# Installation
|
||||
|
||||
Make sure you have the latest version of the Xcode command line tools installed:
|
||||
|
||||
```sh
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||
|
||||
# Available Actions
|
||||
|
||||
## Android
|
||||
|
||||
### android upload_production
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane android upload_production
|
||||
```
|
||||
|
||||
Upload AAB to Google Play Production
|
||||
|
||||
### android upload_internal
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane android upload_internal
|
||||
```
|
||||
|
||||
Upload AAB to Google Play Internal Testing
|
||||
|
||||
### android upload_beta
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane android upload_beta
|
||||
```
|
||||
|
||||
Upload AAB to Google Play Beta
|
||||
|
||||
----
|
||||
|
||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||
|
||||
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||
|
||||
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||
+1
-1
Submodule packages/foliate-js updated: 77c04ae1de...ff36292b7a
Generated
+8
@@ -140,6 +140,9 @@ importers:
|
||||
i18next-http-backend:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
iso-639-2:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
js-md5:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
@@ -4345,6 +4348,9 @@ packages:
|
||||
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
iso-639-2@3.0.2:
|
||||
resolution: {integrity: sha512-tna50aWwcGTIn81S9MzD1NSovHYTpFgmPVszHiLF5Vg/xmXAJ9XAkMOB9a8TH9Vi7qwf/x/8NJy2F+lM5OEwAw==}
|
||||
|
||||
isobject@3.0.1:
|
||||
resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -11446,6 +11452,8 @@ snapshots:
|
||||
|
||||
isexe@3.1.1: {}
|
||||
|
||||
iso-639-2@3.0.2: {}
|
||||
|
||||
isobject@3.0.1: {}
|
||||
|
||||
isows@1.0.7(ws@8.18.2):
|
||||
|
||||
Reference in New Issue
Block a user