feat: support to edit book metadata, closes #753 (#1583)

This commit is contained in:
Huang Xin
2025-07-15 01:46:57 +08:00
committed by GitHub
parent eda71725da
commit e3c411f043
51 changed files with 3260 additions and 514 deletions
+1
View File
@@ -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": "القسم السابق",
@@ -434,5 +432,37 @@
"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?": "هل أنت متأكد من حذف النسخة الاحتياطية السحابية للكتاب المحدد؟",
"What's New in Readest": "ما الجديد في Readest"
"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": "خيال، علوم، تاريخ"
}
@@ -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",
@@ -418,5 +416,37 @@
"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?",
"What's New in Readest": "Was gibt es Neues in Readest"
"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"
}
@@ -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": "Προηγούμενη ενότητα",
@@ -418,5 +416,37 @@
"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?": "Είστε σίγουροι ότι θέλετε να διαγράψετε το αντίγραφο ασφαλείας του επιλεγμένου βιβλίου;",
"What's New in Readest": "Τι νέο υπάρχει στο Readest"
"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": "Μυθοπλασία, Επιστήμη, Ιστορία"
}
@@ -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",
@@ -422,5 +420,37 @@
"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?",
"What's New in Readest": "¿Qué hay de nuevo en Readest"
"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"
}
@@ -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",
@@ -422,5 +420,37 @@
"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",
"What's New in Readest": "Quoi de neuf dans Readest"
"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"
}
@@ -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": "पिछला अनुभाग",
@@ -418,5 +416,37 @@
"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?": "क्या आप सुनिश्चित हैं कि आप चयनित पुस्तक का क्लाउड बैकअप हटाना चाहते हैं?",
"What's New in Readest": "Readest में नया क्या है"
"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": "काल्पनिक, विज्ञान, इतिहास"
}
@@ -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",
@@ -414,5 +412,37 @@
"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?",
"What's New in Readest": "Yang Baru di Readest"
"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"
}
@@ -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",
@@ -422,5 +420,37 @@
"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?",
"What's New in Readest": "Novità in Readest"
"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"
}
@@ -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": "前のセクション",
@@ -414,5 +412,37 @@
"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?": "選択した書籍のクラウドバックアップを削除してもよろしいですか?",
"What's New in Readest": "Readestの新機能"
"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": "小説、科学、歴史"
}
@@ -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": "이전 섹션",
@@ -414,5 +412,37 @@
"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?": "선택한 책의 클라우드 백업을 삭제하시겠습니까?",
"What's New in Readest": "Readest의 새로운 기능"
"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": "소설, 과학, 역사"
}
@@ -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",
@@ -418,5 +416,37 @@
"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?",
"What's New in Readest": "Wat is er nieuw in Readest"
"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"
}
@@ -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",
@@ -426,5 +424,37 @@
"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?",
"What's New in Readest": "Co nowego w Readest"
"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"
}
@@ -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",
@@ -422,5 +420,37 @@
"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?",
"What's New in Readest": "Novidades no Readest"
"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"
}
@@ -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": "Предыдущий раздел",
@@ -426,5 +424,37 @@
"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?": "Вы уверены, что хотите удалить облачное резервное копирование выбранной книги?",
"What's New in Readest": "Что нового в Readest"
"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": "Художественная литература, Наука, История"
}
@@ -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": "อักขระการแปล",
@@ -414,5 +412,37 @@
"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— วิลเลียม เชกสเปียร์",
"What's New in Readest": "มีอะไรใหม่ใน Readest"
"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": "นิยาย, วิทยาศาสตร์, ประวัติศาสตร์"
}
@@ -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",
@@ -418,5 +416,37 @@
"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?",
"What's New in Readest": "Readest'te Neler Yeni"
"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"
}
@@ -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": "Попередній розділ",
@@ -426,5 +424,37 @@
"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?": "Ви впевнені, що хочете видалити хмарну резервну копію вибраної книги?",
"What's New in Readest": "Що нового в Readest"
"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": "Художня література, Наука, Історія"
}
@@ -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",
@@ -414,5 +412,37 @@
"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?",
"What's New in Readest": "Những gì mới trong Readest"
"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ử"
}
@@ -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": "上一章",
@@ -414,5 +412,37 @@
"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?": "您确定要删除所选书籍的云备份吗?",
"What's New in Readest": "Readest 更新功能"
"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": "小说,科学,历史"
}
@@ -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": "上一章",
@@ -414,5 +412,37 @@
"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?": "您確定要刪除所選書籍的雲端備份嗎?",
"What's New in Readest": "Readest 更新功能"
"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": "小說,科學,歷史"
}
@@ -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,
});
}
}
+29 -2
View File
@@ -10,7 +10,13 @@ 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';
@@ -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';
@@ -553,6 +560,25 @@ 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);
if (metadata.coverImageBlobUrl || metadata.coverImageUrl) {
try {
appService?.updateCoverImage(
book,
(metadata.coverImageBlobUrl || metadata.coverImageUrl) as string,
);
} catch (error) {
console.warn('Failed to update cover image:', error);
}
}
metadata.coverImageBlobUrl = undefined;
await updateBook(envConfig, book);
};
const handleImportBooks = async () => {
setIsSelectMode(false);
console.log('Importing books...');
@@ -694,6 +720,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
handleBookDeleteCloudBackup={handleBookDeleteCloudBackup}
handleBookMetadataUpdate={handleUpdateMetadata}
/>
)}
<AboutWindow />
@@ -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';
@@ -37,7 +37,9 @@ 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.metadata?.coverImageBlobUrl || book.coverImageUrl!
}
alt={book.title}
fill={true}
className={clsx('crop-cover-img object-cover', imageClassName)}
@@ -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;
@@ -398,7 +398,7 @@ export const UpdaterContent = ({
)}
</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) => (
@@ -0,0 +1,353 @@
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 { useTranslation } from '@/hooks/useTranslation';
import { formatAuthors, formatTitle } from '@/utils/book';
import { FormField } from './FormField';
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 hasLockedFields = Object.values(lockedFields).some((locked) => locked);
const allFieldsLocked = Object.values(lockedFields).every((locked) => locked);
const isCoverLocked = lockedFields['coverImageUrl'] || false;
const [coverImageUpdated, setCoverImageUpdated] = useState(false);
useEffect(() => {
if (metadata.coverImageUrl) {
if (book.metadata) {
book.metadata.coverImageUrl = metadata.coverImageUrl;
}
setCoverImageUpdated(true);
}
}, [book.metadata, 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 selectImageFile = () => {
if (coverImageUpdated) setCoverImageUpdated(false);
return new Promise((resolve) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.png, .jpg, .jpeg';
fileInput.multiple = false;
fileInput.click();
fileInput.onchange = () => {
resolve(fileInput.files);
};
});
};
const handleSelectLocalImage = async () => {
const files = (await selectImageFile()) as [File];
if (files && files.length > 0) {
const file = files[0];
metadata.coverImageBlobUrl = URL.createObjectURL(file);
setCoverImageUpdated(true);
}
};
return (
<div className='bg-base-100 relative w-full rounded-lg'>
<div className='mb-6 flex items-start gap-4'>
<div className='flex w-[40%] max-w-36 flex-col gap-2'>
<div className='aspect-[28/41] h-full shadow-md'>
<BookCover
mode='list'
book={{
...book,
...(metadata.coverImageUrl || metadata.coverImageBlobUrl
? { coverImageUrl: metadata.coverImageUrl || metadata.coverImageBlobUrl }
: {}),
}}
/>
</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 text-xs',
'border border-gray-300 bg-white hover:bg-gray-50',
'disabled:cursor-not-allowed disabled:opacity-50',
isCoverLocked && '!text-base-content !bg-base-200',
)}
title={_('Change cover image')}
>
<MdEdit className='h-3 w-3' />
<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,159 @@
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,
...(metadata.coverImageUrl || metadata.coverImageBlobUrl
? { coverImageUrl: metadata.coverImageUrl || metadata.coverImageBlobUrl }
: {}),
}}
/>
</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,238 @@
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) {
console.log('initial metadata:', 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,
};
};
+21 -12
View File
@@ -59,19 +59,28 @@ 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;
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>;
+31
View File
@@ -0,0 +1,31 @@
import { MetadataResult, SearchRequest } from '@/services/metadata/types';
import { getAPIBaseUrl } from '@/services/environment';
import { fetchWithAuth } from '@/utils/fetch';
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
timestamp: string;
responseTime?: number;
}
const API_ENDPOINT = getAPIBaseUrl() + '/metadata/search';
export const searchMetadata = async (request: SearchRequest): Promise<MetadataResult[]> => {
const response = await fetchWithAuth(API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
const result: ApiResponse<MetadataResult[]> = await response.json();
if (!result.success) {
throw new Error(result.error || 'Search failed');
}
return result.data || [];
};
+1 -1
View File
@@ -45,5 +45,5 @@ export function middleware(request: NextRequest) {
}
export const config = {
matcher: ['/api/stripe/:path*'],
matcher: ['/api/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,27 @@ 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): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
if (imageUrl.startsWith('blob:')) {
fetch(imageUrl)
.then((response) => response.arrayBuffer())
.then((buffer) => resolve(buffer))
.catch((error) => reject(error));
} else if (this.appPlatform === 'web') {
reject(new Error('Skip non-blob URL'));
} else if (this.appPlatform === 'tauri') {
tauriFetch(imageUrl, { method: 'GET' })
.then((response) => response.arrayBuffer())
.then((buffer) => resolve(buffer))
.catch((error) => reject(error));
}
});
}
async updateCoverImage(book: Book, imageUrl: string): Promise<void> {
const arrayBuffer = await this.imageToArrayBuffer(imageUrl);
await this.fs.writeFile(getCoverFilename(book), 'Books', arrayBuffer);
}
}
@@ -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,
+4
View File
@@ -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 {
+3 -2
View File
@@ -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): Promise<void>;
}
+1 -1
View File
@@ -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);
+7
View File
@@ -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 || '';
};
+278
View File
@@ -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,
};
};
+8
View File
@@ -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):