From 71e97998b6e13c127c5885f3854c5b27d77313dc Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 31 Dec 2025 08:40:28 +0100 Subject: [PATCH] feat(transfer): add a background transfer queue system for book uploads/downloads (#2821) --- .../public/locales/ar/translation.json | 26 +- .../public/locales/bn/translation.json | 26 +- .../public/locales/bo/translation.json | 26 +- .../public/locales/de/translation.json | 26 +- .../public/locales/el/translation.json | 26 +- .../public/locales/es/translation.json | 26 +- .../public/locales/fa/translation.json | 26 +- .../public/locales/fr/translation.json | 26 +- .../public/locales/hi/translation.json | 26 +- .../public/locales/id/translation.json | 26 +- .../public/locales/it/translation.json | 26 +- .../public/locales/ja/translation.json | 26 +- .../public/locales/ko/translation.json | 26 +- .../public/locales/ms/translation.json | 26 +- .../public/locales/nl/translation.json | 26 +- .../public/locales/pl/translation.json | 26 +- .../public/locales/pt/translation.json | 26 +- .../public/locales/ru/translation.json | 26 +- .../public/locales/si/translation.json | 26 +- .../public/locales/sv/translation.json | 26 +- .../public/locales/ta/translation.json | 26 +- .../public/locales/th/translation.json | 26 +- .../public/locales/tr/translation.json | 26 +- .../public/locales/uk/translation.json | 26 +- .../public/locales/vi/translation.json | 26 +- .../public/locales/zh-CN/translation.json | 26 +- .../public/locales/zh-TW/translation.json | 26 +- .../src/app/library/components/Bookshelf.tsx | 9 + .../app/library/components/SettingsMenu.tsx | 24 ++ .../library/components/TransferQueuePanel.tsx | 354 ++++++++++++++++++ apps/readest-app/src/app/library/page.tsx | 103 +++-- .../src/app/reader/components/Reader.tsx | 8 +- .../readest-app/src/hooks/useTransferQueue.ts | 142 +++++++ .../src/services/transferManager.ts | 340 +++++++++++++++++ apps/readest-app/src/store/transferStore.ts | 295 +++++++++++++++ 35 files changed, 1888 insertions(+), 89 deletions(-) create mode 100644 apps/readest-app/src/app/library/components/TransferQueuePanel.tsx create mode 100644 apps/readest-app/src/hooks/useTransferQueue.ts create mode 100644 apps/readest-app/src/services/transferManager.ts create mode 100644 apps/readest-app/src/store/transferStore.ts diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 7a04777c..3f7f6cab 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -840,5 +840,29 @@ "Look up text in Wikipedia after selection": "البحث عن النص في ويكيبيديا بعد التحديد", "Translate text after selection": "ترجمة النص بعد التحديد", "Read text aloud after selection": "قراءة النص بصوت عالٍ بعد التحديد", - "Proofread text after selection": "تدقيق النص بعد التحديد" + "Proofread text after selection": "تدقيق النص بعد التحديد", + "File Transfers": "نقل الملفات", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} نشط، {{pendingCount}} قيد الانتظار", + "{{failedCount}} failed": "{{failedCount}} فشل", + "Waiting...": "جارٍ الانتظار...", + "Failed": "فشل", + "Completed": "مكتمل", + "Cancelled": "ملغى", + "Retry": "إعادة المحاولة", + "Active": "نشط", + "Transfer Queue": "قائمة انتظار النقل", + "Upload All": "رفع الكل", + "Download All": "تنزيل الكل", + "Resume Transfers": "استئناف النقل", + "Pause Transfers": "إيقاف النقل مؤقتًا", + "Pending": "قيد الانتظار", + "No transfers": "لا توجد عمليات نقل", + "Retry All": "إعادة محاولة الكل", + "Clear Completed": "مسح المكتمل", + "Clear Failed": "مسح الفاشل", + "Upload queued: {{title}}": "الرفع في قائمة الانتظار: {{title}}", + "Download queued: {{title}}": "التنزيل في قائمة الانتظار: {{title}}", + "Book not found in library": "الكتاب غير موجود في المكتبة", + "Unknown error": "خطأ غير معروف", + "Please log in to continue": "يرجى تسجيل الدخول للمتابعة" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 7e12af47..1ce6bed5 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -800,5 +800,29 @@ "Look up text in Wikipedia after selection": "নির্বাচনের পরে টেক্সট উইকিপিডিয়ায় দেখুন", "Translate text after selection": "নির্বাচনের পরে টেক্সট অনুবাদ করুন", "Read text aloud after selection": "নির্বাচনের পরে টেক্সট উচ্চারণ করুন", - "Proofread text after selection": "নির্বাচনের পরে টেক্সট প্রুফরিড করুন" + "Proofread text after selection": "নির্বাচনের পরে টেক্সট প্রুফরিড করুন", + "File Transfers": "ফাইল স্থানান্তর", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} সক্রিয়, {{pendingCount}} অপেক্ষমাণ", + "{{failedCount}} failed": "{{failedCount}} ব্যর্থ", + "Waiting...": "অপেক্ষা করছে...", + "Failed": "ব্যর্থ", + "Completed": "সম্পন্ন", + "Cancelled": "বাতিল", + "Retry": "পুনরায় চেষ্টা করুন", + "Active": "সক্রিয়", + "Transfer Queue": "স্থানান্তর সারি", + "Upload All": "সব আপলোড করুন", + "Download All": "সব ডাউনলোড করুন", + "Resume Transfers": "স্থানান্তর পুনরায় শুরু করুন", + "Pause Transfers": "স্থানান্তর বিরতি দিন", + "Pending": "অপেক্ষমাণ", + "No transfers": "কোনো স্থানান্তর নেই", + "Retry All": "সব পুনরায় চেষ্টা করুন", + "Clear Completed": "সম্পন্ন মুছুন", + "Clear Failed": "ব্যর্থ মুছুন", + "Upload queued: {{title}}": "আপলোড সারিতে: {{title}}", + "Download queued: {{title}}": "ডাউনলোড সারিতে: {{title}}", + "Book not found in library": "লাইব্রেরিতে বই পাওয়া যায়নি", + "Unknown error": "অজানা ত্রুটি", + "Please log in to continue": "চালিয়ে যেতে লগইন করুন" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 82ed7181..ffc257d8 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -790,5 +790,29 @@ "Look up text in Wikipedia after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་ཝེ་ཀི་པི་ཌི་ཡི་ནང་འཚོལ་", "Translate text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་སྐད་བསྒྱུར་བྱེད་", "Read text aloud after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་སྐད་ཀྱིས་ཀློག་", - "Proofread text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་ཞིབ་བཤེར་བྱེད་" + "Proofread text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་ཞིབ་བཤེར་བྱེད་", + "File Transfers": "ཡིག་ཆ་སྐྱེལ་འདྲེན་", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} བྱེད་བཞིན་པ་, {{pendingCount}} སྒུག་བཞིན་པ་", + "{{failedCount}} failed": "{{failedCount}} ཕམ་པ་", + "Waiting...": "སྒུག་བཞིན་པ་...", + "Failed": "ཕམ་པ་", + "Completed": "གྲུབ་སོང་", + "Cancelled": "དོར་སོང་", + "Retry": "བསྐྱར་ཚོད་", + "Active": "བྱེད་བཞིན་པ་", + "Transfer Queue": "སྐྱེལ་འདྲེན་སྒྲིག་གཅོད་", + "Upload All": "ཚང་མ་འཇོག་", + "Download All": "ཚང་མ་ལེན་", + "Resume Transfers": "སྐྱེལ་འདྲེན་མུ་མཐུད་", + "Pause Transfers": "སྐྱེལ་འདྲེན་མཚམས་འཇོག་", + "Pending": "སྒུག་བཞིན་པ་", + "No transfers": "སྐྱེལ་འདྲེན་མེད་", + "Retry All": "ཚང་མ་བསྐྱར་ཚོད་", + "Clear Completed": "གྲུབ་པ་གསལ་བ་", + "Clear Failed": "ཕམ་པ་གསལ་བ་", + "Upload queued: {{title}}": "འཇོག་པའི་སྒྲིག་གཅོད་ནང་བཅུག་: {{title}}", + "Download queued: {{title}}": "ལེན་པའི་སྒྲིག་གཅོད་ནང་བཅུག་: {{title}}", + "Book not found in library": "དཔེ་མཛོད་ནང་དཔེ་ཆ་རྙེད་མ་སོང་", + "Unknown error": "མ་ཤེས་པའི་ནོར་འཁྲུལ་", + "Please log in to continue": "མུ་མཐུད་པར་ནང་འཛུལ་བྱོས་" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 714e6fa5..f7506333 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -800,5 +800,29 @@ "Look up text in Wikipedia after selection": "Text nach Auswahl in Wikipedia nachschlagen", "Translate text after selection": "Text nach Auswahl übersetzen", "Read text aloud after selection": "Text nach Auswahl vorlesen", - "Proofread text after selection": "Text nach Auswahl Korrektur lesen" + "Proofread text after selection": "Text nach Auswahl Korrektur lesen", + "File Transfers": "Dateiübertragungen", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktiv, {{pendingCount}} ausstehend", + "{{failedCount}} failed": "{{failedCount}} fehlgeschlagen", + "Waiting...": "Warten...", + "Failed": "Fehlgeschlagen", + "Completed": "Abgeschlossen", + "Cancelled": "Abgebrochen", + "Retry": "Erneut versuchen", + "Active": "Aktiv", + "Transfer Queue": "Übertragungswarteschlange", + "Upload All": "Alle hochladen", + "Download All": "Alle herunterladen", + "Resume Transfers": "Übertragungen fortsetzen", + "Pause Transfers": "Übertragungen pausieren", + "Pending": "Ausstehend", + "No transfers": "Keine Übertragungen", + "Retry All": "Alle erneut versuchen", + "Clear Completed": "Abgeschlossene löschen", + "Clear Failed": "Fehlgeschlagene löschen", + "Upload queued: {{title}}": "Upload in Warteschlange: {{title}}", + "Download queued: {{title}}": "Download in Warteschlange: {{title}}", + "Book not found in library": "Buch nicht in der Bibliothek gefunden", + "Unknown error": "Unbekannter Fehler", + "Please log in to continue": "Bitte melden Sie sich an, um fortzufahren" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 9322f9c8..03587d04 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -800,5 +800,29 @@ "Look up text in Wikipedia after selection": "Αναζήτηση κειμένου στη Wikipedia μετά την επιλογή", "Translate text after selection": "Μετάφραση κειμένου μετά την επιλογή", "Read text aloud after selection": "Ανάγνωση κειμένου μετά την επιλογή", - "Proofread text after selection": "Διόρθωση κειμένου μετά την επιλογή" + "Proofread text after selection": "Διόρθωση κειμένου μετά την επιλογή", + "File Transfers": "Μεταφορές αρχείων", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} ενεργές, {{pendingCount}} σε αναμονή", + "{{failedCount}} failed": "{{failedCount}} απέτυχαν", + "Waiting...": "Αναμονή...", + "Failed": "Απέτυχε", + "Completed": "Ολοκληρώθηκε", + "Cancelled": "Ακυρώθηκε", + "Retry": "Επανάληψη", + "Active": "Ενεργές", + "Transfer Queue": "Ουρά μεταφορών", + "Upload All": "Ανέβασμα όλων", + "Download All": "Λήψη όλων", + "Resume Transfers": "Συνέχιση μεταφορών", + "Pause Transfers": "Παύση μεταφορών", + "Pending": "Σε αναμονή", + "No transfers": "Χωρίς μεταφορές", + "Retry All": "Επανάληψη όλων", + "Clear Completed": "Εκκαθάριση ολοκληρωμένων", + "Clear Failed": "Εκκαθάριση αποτυχημένων", + "Upload queued: {{title}}": "Ανέβασμα στην ουρά: {{title}}", + "Download queued: {{title}}": "Λήψη στην ουρά: {{title}}", + "Book not found in library": "Το βιβλίο δεν βρέθηκε στη βιβλιοθήκη", + "Unknown error": "Άγνωστο σφάλμα", + "Please log in to continue": "Παρακαλώ συνδεθείτε για να συνεχίσετε" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 36161cfe..a9fff187 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -810,5 +810,29 @@ "Look up text in Wikipedia after selection": "Buscar texto en Wikipedia después de la selección", "Translate text after selection": "Traducir texto después de la selección", "Read text aloud after selection": "Leer texto en voz alta después de la selección", - "Proofread text after selection": "Corregir texto después de la selección" + "Proofread text after selection": "Corregir texto después de la selección", + "File Transfers": "Transferencias de archivos", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} activas, {{pendingCount}} pendientes", + "{{failedCount}} failed": "{{failedCount}} fallidas", + "Waiting...": "Esperando...", + "Failed": "Fallido", + "Completed": "Completado", + "Cancelled": "Cancelado", + "Retry": "Reintentar", + "Active": "Activas", + "Transfer Queue": "Cola de transferencias", + "Upload All": "Subir todos", + "Download All": "Descargar todos", + "Resume Transfers": "Reanudar transferencias", + "Pause Transfers": "Pausar transferencias", + "Pending": "Pendientes", + "No transfers": "Sin transferencias", + "Retry All": "Reintentar todos", + "Clear Completed": "Limpiar completados", + "Clear Failed": "Limpiar fallidos", + "Upload queued: {{title}}": "Subida en cola: {{title}}", + "Download queued: {{title}}": "Descarga en cola: {{title}}", + "Book not found in library": "Libro no encontrado en la biblioteca", + "Unknown error": "Error desconocido", + "Please log in to continue": "Inicia sesión para continuar" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 849bb0d3..90ea0fe2 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -800,5 +800,29 @@ "Look up text in Wikipedia after selection": "جستجوی متن در ویکی‌پدیا پس از انتخاب", "Translate text after selection": "ترجمه متن پس از انتخاب", "Read text aloud after selection": "خواندن متن با صدای بلند پس از انتخاب", - "Proofread text after selection": "بازبینی متن پس از انتخاب" + "Proofread text after selection": "بازبینی متن پس از انتخاب", + "File Transfers": "انتقال فایل‌ها", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} فعال، {{pendingCount}} در انتظار", + "{{failedCount}} failed": "{{failedCount}} ناموفق", + "Waiting...": "در انتظار...", + "Failed": "ناموفق", + "Completed": "تکمیل شده", + "Cancelled": "لغو شده", + "Retry": "تلاش مجدد", + "Active": "فعال", + "Transfer Queue": "صف انتقال", + "Upload All": "بارگذاری همه", + "Download All": "دانلود همه", + "Resume Transfers": "ادامه انتقال‌ها", + "Pause Transfers": "توقف انتقال‌ها", + "Pending": "در انتظار", + "No transfers": "بدون انتقال", + "Retry All": "تلاش مجدد همه", + "Clear Completed": "پاک کردن تکمیل شده‌ها", + "Clear Failed": "پاک کردن ناموفق‌ها", + "Upload queued: {{title}}": "بارگذاری در صف: {{title}}", + "Download queued: {{title}}": "دانلود در صف: {{title}}", + "Book not found in library": "کتاب در کتابخانه یافت نشد", + "Unknown error": "خطای ناشناخته", + "Please log in to continue": "لطفاً برای ادامه وارد شوید" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 9da592eb..8e1186c6 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -810,5 +810,29 @@ "Look up text in Wikipedia after selection": "Chercher le texte dans Wikipédia après la sélection", "Translate text after selection": "Traduire le texte après la sélection", "Read text aloud after selection": "Lire le texte à haute voix après la sélection", - "Proofread text after selection": "Relire le texte après la sélection" + "Proofread text after selection": "Relire le texte après la sélection", + "File Transfers": "Transferts de fichiers", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} actifs, {{pendingCount}} en attente", + "{{failedCount}} failed": "{{failedCount}} échoués", + "Waiting...": "En attente...", + "Failed": "Échoué", + "Completed": "Terminé", + "Cancelled": "Annulé", + "Retry": "Réessayer", + "Active": "Actifs", + "Transfer Queue": "File de transfert", + "Upload All": "Tout téléverser", + "Download All": "Tout télécharger", + "Resume Transfers": "Reprendre les transferts", + "Pause Transfers": "Suspendre les transferts", + "Pending": "En attente", + "No transfers": "Aucun transfert", + "Retry All": "Tout réessayer", + "Clear Completed": "Effacer les terminés", + "Clear Failed": "Effacer les échoués", + "Upload queued: {{title}}": "Téléversement en file: {{title}}", + "Download queued: {{title}}": "Téléchargement en file: {{title}}", + "Book not found in library": "Livre non trouvé dans la bibliothèque", + "Unknown error": "Erreur inconnue", + "Please log in to continue": "Veuillez vous connecter pour continuer" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 9b8056a7..b5196d93 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -800,5 +800,29 @@ "Look up text in Wikipedia after selection": "चयन के बाद विकिपीडिया में पाठ देखें", "Translate text after selection": "चयन के बाद पाठ का अनुवाद करें", "Read text aloud after selection": "चयन के बाद पाठ को जोर से पढ़ें", - "Proofread text after selection": "चयन के बाद पाठ को प्रूफरीड करें" + "Proofread text after selection": "चयन के बाद पाठ को प्रूफरीड करें", + "File Transfers": "फ़ाइल स्थानांतरण", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} सक्रिय, {{pendingCount}} लंबित", + "{{failedCount}} failed": "{{failedCount}} विफल", + "Waiting...": "प्रतीक्षा कर रहे हैं...", + "Failed": "विफल", + "Completed": "पूर्ण", + "Cancelled": "रद्द", + "Retry": "पुनः प्रयास करें", + "Active": "सक्रिय", + "Transfer Queue": "स्थानांतरण कतार", + "Upload All": "सभी अपलोड करें", + "Download All": "सभी डाउनलोड करें", + "Resume Transfers": "स्थानांतरण जारी रखें", + "Pause Transfers": "स्थानांतरण रोकें", + "Pending": "लंबित", + "No transfers": "कोई स्थानांतरण नहीं", + "Retry All": "सभी पुनः प्रयास करें", + "Clear Completed": "पूर्ण साफ़ करें", + "Clear Failed": "विफल साफ़ करें", + "Upload queued: {{title}}": "अपलोड कतार में: {{title}}", + "Download queued: {{title}}": "डाउनलोड कतार में: {{title}}", + "Book not found in library": "पुस्तकालय में पुस्तक नहीं मिली", + "Unknown error": "अज्ञात त्रुटि", + "Please log in to continue": "जारी रखने के लिए लॉगिन करें" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index b729a00f..da52fc96 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -790,5 +790,29 @@ "Look up text in Wikipedia after selection": "Cari teks di Wikipedia setelah pemilihan", "Translate text after selection": "Terjemahkan teks setelah pemilihan", "Read text aloud after selection": "Bacakan teks setelah pemilihan", - "Proofread text after selection": "Periksa teks setelah pemilihan" + "Proofread text after selection": "Periksa teks setelah pemilihan", + "File Transfers": "Transfer File", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktif, {{pendingCount}} menunggu", + "{{failedCount}} failed": "{{failedCount}} gagal", + "Waiting...": "Menunggu...", + "Failed": "Gagal", + "Completed": "Selesai", + "Cancelled": "Dibatalkan", + "Retry": "Coba lagi", + "Active": "Aktif", + "Transfer Queue": "Antrian Transfer", + "Upload All": "Unggah Semua", + "Download All": "Unduh Semua", + "Resume Transfers": "Lanjutkan Transfer", + "Pause Transfers": "Jeda Transfer", + "Pending": "Menunggu", + "No transfers": "Tidak ada transfer", + "Retry All": "Coba Semua Lagi", + "Clear Completed": "Hapus Selesai", + "Clear Failed": "Hapus Gagal", + "Upload queued: {{title}}": "Unggahan dalam antrian: {{title}}", + "Download queued: {{title}}": "Unduhan dalam antrian: {{title}}", + "Book not found in library": "Buku tidak ditemukan di perpustakaan", + "Unknown error": "Kesalahan tidak diketahui", + "Please log in to continue": "Silakan masuk untuk melanjutkan" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 19cb0e13..93e37867 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -810,5 +810,29 @@ "Look up text in Wikipedia after selection": "Cerca testo in Wikipedia dopo la selezione", "Translate text after selection": "Traduci testo dopo la selezione", "Read text aloud after selection": "Leggi ad alta voce il testo dopo la selezione", - "Proofread text after selection": "Correggi il testo dopo la selezione" + "Proofread text after selection": "Correggi il testo dopo la selezione", + "File Transfers": "Trasferimenti file", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} attivi, {{pendingCount}} in attesa", + "{{failedCount}} failed": "{{failedCount}} falliti", + "Waiting...": "In attesa...", + "Failed": "Fallito", + "Completed": "Completato", + "Cancelled": "Annullato", + "Retry": "Riprova", + "Active": "Attivi", + "Transfer Queue": "Coda trasferimenti", + "Upload All": "Carica tutti", + "Download All": "Scarica tutti", + "Resume Transfers": "Riprendi trasferimenti", + "Pause Transfers": "Sospendi trasferimenti", + "Pending": "In attesa", + "No transfers": "Nessun trasferimento", + "Retry All": "Riprova tutti", + "Clear Completed": "Cancella completati", + "Clear Failed": "Cancella falliti", + "Upload queued: {{title}}": "Caricamento in coda: {{title}}", + "Download queued: {{title}}": "Download in coda: {{title}}", + "Book not found in library": "Libro non trovato nella libreria", + "Unknown error": "Errore sconosciuto", + "Please log in to continue": "Accedi per continuare" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 8f113d82..b31c4892 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -790,5 +790,29 @@ "Look up text in Wikipedia after selection": "選択後のテキストをWikipediaで調べる", "Translate text after selection": "選択後のテキストを翻訳する", "Read text aloud after selection": "選択後のテキストを音読する", - "Proofread text after selection": "選択後のテキストを校正する" + "Proofread text after selection": "選択後のテキストを校正する", + "File Transfers": "ファイル転送", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} 進行中、{{pendingCount}} 待機中", + "{{failedCount}} failed": "{{failedCount}} 失敗", + "Waiting...": "待機中...", + "Failed": "失敗", + "Completed": "完了", + "Cancelled": "キャンセル", + "Retry": "再試行", + "Active": "進行中", + "Transfer Queue": "転送キュー", + "Upload All": "すべてアップロード", + "Download All": "すべてダウンロード", + "Resume Transfers": "転送を再開", + "Pause Transfers": "転送を一時停止", + "Pending": "待機中", + "No transfers": "転送なし", + "Retry All": "すべて再試行", + "Clear Completed": "完了を消去", + "Clear Failed": "失敗を消去", + "Upload queued: {{title}}": "アップロードをキューに追加: {{title}}", + "Download queued: {{title}}": "ダウンロードをキューに追加: {{title}}", + "Book not found in library": "ライブラリに本が見つかりません", + "Unknown error": "不明なエラー", + "Please log in to continue": "続行するにはログインしてください" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 3c2b6ec2..b4af45f4 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -790,5 +790,29 @@ "Look up text in Wikipedia after selection": "선택 후 텍스트를 위키피디아에서 찾기", "Translate text after selection": "선택 후 텍스트 번역", "Read text aloud after selection": "선택 후 텍스트 음성 읽기", - "Proofread text after selection": "선택 후 텍스트 교정" + "Proofread text after selection": "선택 후 텍스트 교정", + "File Transfers": "파일 전송", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} 진행 중, {{pendingCount}} 대기 중", + "{{failedCount}} failed": "{{failedCount}} 실패", + "Waiting...": "대기 중...", + "Failed": "실패", + "Completed": "완료됨", + "Cancelled": "취소됨", + "Retry": "재시도", + "Active": "진행 중", + "Transfer Queue": "전송 대기열", + "Upload All": "모두 업로드", + "Download All": "모두 다운로드", + "Resume Transfers": "전송 재개", + "Pause Transfers": "전송 일시정지", + "Pending": "대기 중", + "No transfers": "전송 없음", + "Retry All": "모두 재시도", + "Clear Completed": "완료된 항목 지우기", + "Clear Failed": "실패한 항목 지우기", + "Upload queued: {{title}}": "업로드 대기열에 추가됨: {{title}}", + "Download queued: {{title}}": "다운로드 대기열에 추가됨: {{title}}", + "Book not found in library": "라이브러리에서 책을 찾을 수 없음", + "Unknown error": "알 수 없는 오류", + "Please log in to continue": "계속하려면 로그인하세요" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index 7a0035c8..e65715a8 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -790,5 +790,29 @@ "Look up text in Wikipedia after selection": "Cari teks dalam Wikipedia selepas pilihan", "Translate text after selection": "Terjemah teks selepas pilihan", "Read text aloud after selection": "Baca teks dengan kuat selepas pilihan", - "Proofread text after selection": "Semak teks selepas pilihan" + "Proofread text after selection": "Semak teks selepas pilihan", + "File Transfers": "Pemindahan Fail", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktif, {{pendingCount}} menunggu", + "{{failedCount}} failed": "{{failedCount}} gagal", + "Waiting...": "Menunggu...", + "Failed": "Gagal", + "Completed": "Selesai", + "Cancelled": "Dibatalkan", + "Retry": "Cuba lagi", + "Active": "Aktif", + "Transfer Queue": "Baris Gilir Pemindahan", + "Upload All": "Muat Naik Semua", + "Download All": "Muat Turun Semua", + "Resume Transfers": "Sambung Pemindahan", + "Pause Transfers": "Jeda Pemindahan", + "Pending": "Menunggu", + "No transfers": "Tiada pemindahan", + "Retry All": "Cuba Semua Lagi", + "Clear Completed": "Kosongkan Selesai", + "Clear Failed": "Kosongkan Gagal", + "Upload queued: {{title}}": "Muat naik dalam baris gilir: {{title}}", + "Download queued: {{title}}": "Muat turun dalam baris gilir: {{title}}", + "Book not found in library": "Buku tidak dijumpai dalam perpustakaan", + "Unknown error": "Ralat tidak diketahui", + "Please log in to continue": "Sila log masuk untuk meneruskan" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 925af63b..a62b7cb0 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -800,5 +800,29 @@ "Look up text in Wikipedia after selection": "Zoek tekst op in Wikipedia na selectie", "Translate text after selection": "Vertaal tekst na selectie", "Read text aloud after selection": "Lees tekst hardop na selectie", - "Proofread text after selection": "Corrigeer tekst na selectie" + "Proofread text after selection": "Corrigeer tekst na selectie", + "File Transfers": "Bestandsoverdrachten", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} actief, {{pendingCount}} wachtend", + "{{failedCount}} failed": "{{failedCount}} mislukt", + "Waiting...": "Wachten...", + "Failed": "Mislukt", + "Completed": "Voltooid", + "Cancelled": "Geannuleerd", + "Retry": "Opnieuw proberen", + "Active": "Actief", + "Transfer Queue": "Overdrachtwachtrij", + "Upload All": "Alles uploaden", + "Download All": "Alles downloaden", + "Resume Transfers": "Overdrachten hervatten", + "Pause Transfers": "Overdrachten pauzeren", + "Pending": "Wachtend", + "No transfers": "Geen overdrachten", + "Retry All": "Alles opnieuw proberen", + "Clear Completed": "Voltooide wissen", + "Clear Failed": "Mislukte wissen", + "Upload queued: {{title}}": "Upload in wachtrij: {{title}}", + "Download queued: {{title}}": "Download in wachtrij: {{title}}", + "Book not found in library": "Boek niet gevonden in bibliotheek", + "Unknown error": "Onbekende fout", + "Please log in to continue": "Log in om door te gaan" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index adaadaa1..ab2aa7f7 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -820,5 +820,29 @@ "Look up text in Wikipedia after selection": "Sprawdź zaznaczony tekst w Wikipedii", "Translate text after selection": "Przetłumacz zaznaczony tekst", "Read text aloud after selection": "Odczytaj zaznaczony tekst na głos", - "Proofread text after selection": "Sprawdź zaznaczony tekst" + "Proofread text after selection": "Sprawdź zaznaczony tekst", + "File Transfers": "Transfery plików", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktywnych, {{pendingCount}} oczekujących", + "{{failedCount}} failed": "{{failedCount}} nieudanych", + "Waiting...": "Oczekiwanie...", + "Failed": "Nieudane", + "Completed": "Ukończone", + "Cancelled": "Anulowane", + "Retry": "Ponów", + "Active": "Aktywne", + "Transfer Queue": "Kolejka transferów", + "Upload All": "Wyślij wszystko", + "Download All": "Pobierz wszystko", + "Resume Transfers": "Wznów transfery", + "Pause Transfers": "Wstrzymaj transfery", + "Pending": "Oczekujące", + "No transfers": "Brak transferów", + "Retry All": "Ponów wszystko", + "Clear Completed": "Wyczyść ukończone", + "Clear Failed": "Wyczyść nieudane", + "Upload queued: {{title}}": "Wysyłanie w kolejce: {{title}}", + "Download queued: {{title}}": "Pobieranie w kolejce: {{title}}", + "Book not found in library": "Książka nie została znaleziona w bibliotece", + "Unknown error": "Nieznany błąd", + "Please log in to continue": "Zaloguj się, aby kontynuować" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 41f7b473..0f71e6e1 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -810,5 +810,29 @@ "Look up text in Wikipedia after selection": "Procurar texto na Wikipedia após a seleção", "Translate text after selection": "Traduzir texto após a seleção", "Read text aloud after selection": "Ler texto em voz alta após a seleção", - "Proofread text after selection": "Revisar texto após a seleção" + "Proofread text after selection": "Revisar texto após a seleção", + "File Transfers": "Transferências de Arquivos", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} ativos, {{pendingCount}} pendentes", + "{{failedCount}} failed": "{{failedCount}} falharam", + "Waiting...": "Aguardando...", + "Failed": "Falhou", + "Completed": "Concluído", + "Cancelled": "Cancelado", + "Retry": "Tentar novamente", + "Active": "Ativos", + "Transfer Queue": "Fila de Transferências", + "Upload All": "Enviar Todos", + "Download All": "Baixar Todos", + "Resume Transfers": "Retomar Transferências", + "Pause Transfers": "Pausar Transferências", + "Pending": "Pendentes", + "No transfers": "Sem transferências", + "Retry All": "Tentar Todos Novamente", + "Clear Completed": "Limpar Concluídos", + "Clear Failed": "Limpar Falhos", + "Upload queued: {{title}}": "Upload na fila: {{title}}", + "Download queued: {{title}}": "Download na fila: {{title}}", + "Book not found in library": "Livro não encontrado na biblioteca", + "Unknown error": "Erro desconhecido", + "Please log in to continue": "Faça login para continuar" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index d82e46f6..d7ed8ee1 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -820,5 +820,29 @@ "Look up text in Wikipedia after selection": "Искать текст в Википедии после выделения", "Translate text after selection": "Перевести текст после выделения", "Read text aloud after selection": "Прочитать текст вслух после выделения", - "Proofread text after selection": "Корректировать текст после выделения" + "Proofread text after selection": "Корректировать текст после выделения", + "File Transfers": "Передача файлов", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} активных, {{pendingCount}} ожидающих", + "{{failedCount}} failed": "{{failedCount}} неудачных", + "Waiting...": "Ожидание...", + "Failed": "Ошибка", + "Completed": "Завершено", + "Cancelled": "Отменено", + "Retry": "Повторить", + "Active": "Активные", + "Transfer Queue": "Очередь передачи", + "Upload All": "Загрузить все", + "Download All": "Скачать все", + "Resume Transfers": "Возобновить передачу", + "Pause Transfers": "Приостановить передачу", + "Pending": "Ожидающие", + "No transfers": "Нет передач", + "Retry All": "Повторить все", + "Clear Completed": "Очистить завершенные", + "Clear Failed": "Очистить неудачные", + "Upload queued: {{title}}": "Загрузка в очереди: {{title}}", + "Download queued: {{title}}": "Скачивание в очереди: {{title}}", + "Book not found in library": "Книга не найдена в библиотеке", + "Unknown error": "Неизвестная ошибка", + "Please log in to continue": "Войдите, чтобы продолжить" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index d079757b..88594a3b 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -800,5 +800,29 @@ "Look up text in Wikipedia after selection": "තේරීමෙන් පසු විකිපීඩියාවේ පෙළ සෙවීම", "Translate text after selection": "තේරීමෙන් පසු පෙළ පරිවර්තනය කරන්න", "Read text aloud after selection": "තේරීමෙන් පසු පෙළ උච්චාරණය කරන්න", - "Proofread text after selection": "තේරීමෙන් පසු පෙළ සංශෝධනය කරන්න" + "Proofread text after selection": "තේරීමෙන් පසු පෙළ සංශෝධනය කරන්න", + "File Transfers": "ගොනු හුවමාරු", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} සක්‍රීය, {{pendingCount}} බලාපොරොත්තුවේ", + "{{failedCount}} failed": "{{failedCount}} අසාර්ථක", + "Waiting...": "බලාපොරොත්තුවේ...", + "Failed": "අසාර්ථකයි", + "Completed": "සම්පූර්ණයි", + "Cancelled": "අවලංගුයි", + "Retry": "නැවත උත්සාහ කරන්න", + "Active": "සක්‍රීය", + "Transfer Queue": "හුවමාරු පෝලිම", + "Upload All": "සියල්ල උඩුගත කරන්න", + "Download All": "සියල්ල බාගන්න", + "Resume Transfers": "හුවමාරු නැවත ආරම්භ කරන්න", + "Pause Transfers": "හුවමාරු විරාම කරන්න", + "Pending": "බලාපොරොත්තුවේ", + "No transfers": "හුවමාරු නැත", + "Retry All": "සියල්ල නැවත උත්සාහ කරන්න", + "Clear Completed": "සම්පූර්ණ කළ ඒවා මකන්න", + "Clear Failed": "අසාර්ථක ඒවා මකන්න", + "Upload queued: {{title}}": "උඩුගත කිරීම පෝලිමේ: {{title}}", + "Download queued: {{title}}": "බාගත කිරීම පෝලිමේ: {{title}}", + "Book not found in library": "පුස්තකාලයේ පොත හමු නොවීය", + "Unknown error": "නොදන්නා දෝෂයක්", + "Please log in to continue": "ඉදිරියට යාමට පිවිසෙන්න" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index aeb7eb44..c7e0fcf2 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -800,5 +800,29 @@ "Look up text in Wikipedia after selection": "Slå upp text i Wikipedia efter markering", "Translate text after selection": "Översätt text efter markering", "Read text aloud after selection": "Läs upp text efter markering", - "Proofread text after selection": "Korrekturläs text efter markering" + "Proofread text after selection": "Korrekturläs text efter markering", + "File Transfers": "Filöverföringar", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktiva, {{pendingCount}} väntande", + "{{failedCount}} failed": "{{failedCount}} misslyckades", + "Waiting...": "Väntar...", + "Failed": "Misslyckades", + "Completed": "Slutförd", + "Cancelled": "Avbruten", + "Retry": "Försök igen", + "Active": "Aktiva", + "Transfer Queue": "Överföringskö", + "Upload All": "Ladda upp alla", + "Download All": "Ladda ner alla", + "Resume Transfers": "Återuppta överföringar", + "Pause Transfers": "Pausa överföringar", + "Pending": "Väntande", + "No transfers": "Inga överföringar", + "Retry All": "Försök alla igen", + "Clear Completed": "Rensa slutförda", + "Clear Failed": "Rensa misslyckade", + "Upload queued: {{title}}": "Uppladdning köad: {{title}}", + "Download queued: {{title}}": "Nedladdning köad: {{title}}", + "Book not found in library": "Boken hittades inte i biblioteket", + "Unknown error": "Okänt fel", + "Please log in to continue": "Logga in för att fortsätta" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 7adca3f2..13f6c04f 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -800,5 +800,29 @@ "Look up text in Wikipedia after selection": "தேர்வுக்குப் பிறகு விக்கிப்பீடியாவில் உரையைத் தேடு", "Translate text after selection": "தேர்வுக்குப் பிறகு உரையை மொழிபெயர்", "Read text aloud after selection": "தேர்வுக்குப் பிறகு உரையை ஓதுக", - "Proofread text after selection": "தேர்வுக்குப் பிறகு உரையை திருத்துக" + "Proofread text after selection": "தேர்வுக்குப் பிறகு உரையை திருத்துக", + "File Transfers": "கோப்பு பரிமாற்றங்கள்", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} செயலில், {{pendingCount}} காத்திருப்பு", + "{{failedCount}} failed": "{{failedCount}} தோல்வி", + "Waiting...": "காத்திருக்கிறது...", + "Failed": "தோல்வி", + "Completed": "முடிந்தது", + "Cancelled": "ரத்து செய்யப்பட்டது", + "Retry": "மீண்டும் முயற்சிக்கவும்", + "Active": "செயலில்", + "Transfer Queue": "பரிமாற்ற வரிசை", + "Upload All": "அனைத்தையும் பதிவேற்றவும்", + "Download All": "அனைத்தையும் பதிவிறக்கவும்", + "Resume Transfers": "பரிமாற்றங்களை தொடரவும்", + "Pause Transfers": "பரிமாற்றங்களை இடைநிறுத்தவும்", + "Pending": "காத்திருப்பு", + "No transfers": "பரிமாற்றங்கள் இல்லை", + "Retry All": "அனைத்தையும் மீண்டும் முயற்சிக்கவும்", + "Clear Completed": "முடிந்ததை அழிக்கவும்", + "Clear Failed": "தோல்வியை அழிக்கவும்", + "Upload queued: {{title}}": "பதிவேற்றம் வரிசையில்: {{title}}", + "Download queued: {{title}}": "பதிவிறக்கம் வரிசையில்: {{title}}", + "Book not found in library": "நூலகத்தில் புத்தகம் கிடைக்கவில்லை", + "Unknown error": "தெரியாத பிழை", + "Please log in to continue": "தொடர உள்நுழையவும்" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 12abb074..35ea6739 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -790,5 +790,29 @@ "Look up text in Wikipedia after selection": "ค้นหาข้อความในวิกิพีเดียหลังการเลือก", "Translate text after selection": "แปลข้อความหลังการเลือก", "Read text aloud after selection": "อ่านข้อความออกเสียงหลังการเลือก", - "Proofread text after selection": "ตรวจทานข้อความหลังการเลือก" + "Proofread text after selection": "ตรวจทานข้อความหลังการเลือก", + "File Transfers": "การถ่ายโอนไฟล์", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} กำลังดำเนินการ, {{pendingCount}} รอดำเนินการ", + "{{failedCount}} failed": "{{failedCount}} ล้มเหลว", + "Waiting...": "กำลังรอ...", + "Failed": "ล้มเหลว", + "Completed": "เสร็จสิ้น", + "Cancelled": "ยกเลิก", + "Retry": "ลองใหม่", + "Active": "กำลังดำเนินการ", + "Transfer Queue": "คิวการถ่ายโอน", + "Upload All": "อัปโหลดทั้งหมด", + "Download All": "ดาวน์โหลดทั้งหมด", + "Resume Transfers": "ดำเนินการถ่ายโอนต่อ", + "Pause Transfers": "หยุดการถ่ายโอนชั่วคราว", + "Pending": "รอดำเนินการ", + "No transfers": "ไม่มีการถ่ายโอน", + "Retry All": "ลองใหม่ทั้งหมด", + "Clear Completed": "ล้างที่เสร็จสิ้น", + "Clear Failed": "ล้างที่ล้มเหลว", + "Upload queued: {{title}}": "อัปโหลดอยู่ในคิว: {{title}}", + "Download queued: {{title}}": "ดาวน์โหลดอยู่ในคิว: {{title}}", + "Book not found in library": "ไม่พบหนังสือในห้องสมุด", + "Unknown error": "ข้อผิดพลาดที่ไม่รู้จัก", + "Please log in to continue": "กรุณาเข้าสู่ระบบเพื่อดำเนินการต่อ" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index af07d42e..53586422 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -800,5 +800,29 @@ "Look up text in Wikipedia after selection": "Seçimden sonra metni Vikipedya'da ara", "Translate text after selection": "Seçimden sonra metni çevir", "Read text aloud after selection": "Seçimden sonra metni sesli oku", - "Proofread text after selection": "Seçimden sonra metni düzelt" + "Proofread text after selection": "Seçimden sonra metni düzelt", + "File Transfers": "Dosya Transferleri", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktif, {{pendingCount}} beklemede", + "{{failedCount}} failed": "{{failedCount}} başarısız", + "Waiting...": "Bekleniyor...", + "Failed": "Başarısız", + "Completed": "Tamamlandı", + "Cancelled": "İptal edildi", + "Retry": "Yeniden dene", + "Active": "Aktif", + "Transfer Queue": "Transfer Kuyruğu", + "Upload All": "Tümünü Yükle", + "Download All": "Tümünü İndir", + "Resume Transfers": "Transferleri Sürdür", + "Pause Transfers": "Transferleri Duraklat", + "Pending": "Beklemede", + "No transfers": "Transfer yok", + "Retry All": "Tümünü Yeniden Dene", + "Clear Completed": "Tamamlananları Temizle", + "Clear Failed": "Başarısızları Temizle", + "Upload queued: {{title}}": "Yükleme sıraya alındı: {{title}}", + "Download queued: {{title}}": "İndirme sıraya alındı: {{title}}", + "Book not found in library": "Kitap kütüphanede bulunamadı", + "Unknown error": "Bilinmeyen hata", + "Please log in to continue": "Devam etmek için giriş yapın" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 5711af51..96c44282 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -820,5 +820,29 @@ "Look up text in Wikipedia after selection": "Шукати текст у Вікіпедії після виділення", "Translate text after selection": "Перекласти текст після виділення", "Read text aloud after selection": "Прочитати текст вголос після виділення", - "Proofread text after selection": "Вичитати текст після виділення" + "Proofread text after selection": "Вичитати текст після виділення", + "File Transfers": "Передача файлів", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} активних, {{pendingCount}} очікують", + "{{failedCount}} failed": "{{failedCount}} невдалих", + "Waiting...": "Очікування...", + "Failed": "Помилка", + "Completed": "Завершено", + "Cancelled": "Скасовано", + "Retry": "Повторити", + "Active": "Активні", + "Transfer Queue": "Черга передачі", + "Upload All": "Завантажити все", + "Download All": "Завантажити все", + "Resume Transfers": "Продовжити передачу", + "Pause Transfers": "Призупинити передачу", + "Pending": "Очікують", + "No transfers": "Немає передач", + "Retry All": "Повторити все", + "Clear Completed": "Очистити завершені", + "Clear Failed": "Очистити невдалі", + "Upload queued: {{title}}": "Завантаження в черзі: {{title}}", + "Download queued: {{title}}": "Завантаження в черзі: {{title}}", + "Book not found in library": "Книгу не знайдено в бібліотеці", + "Unknown error": "Невідома помилка", + "Please log in to continue": "Увійдіть, щоб продовжити" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 9aa0f127..3ad822d7 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -790,5 +790,29 @@ "Look up text in Wikipedia after selection": "Tra cứu văn bản trong Wikipedia sau khi chọn", "Translate text after selection": "Dịch văn bản sau khi chọn", "Read text aloud after selection": "Đọc to văn bản sau khi chọn", - "Proofread text after selection": "Hiệu đính văn bản sau khi chọn" + "Proofread text after selection": "Hiệu đính văn bản sau khi chọn", + "File Transfers": "Truyền tập tin", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} đang hoạt động, {{pendingCount}} đang chờ", + "{{failedCount}} failed": "{{failedCount}} thất bại", + "Waiting...": "Đang chờ...", + "Failed": "Thất bại", + "Completed": "Hoàn thành", + "Cancelled": "Đã hủy", + "Retry": "Thử lại", + "Active": "Đang hoạt động", + "Transfer Queue": "Hàng đợi truyền", + "Upload All": "Tải lên tất cả", + "Download All": "Tải xuống tất cả", + "Resume Transfers": "Tiếp tục truyền", + "Pause Transfers": "Tạm dừng truyền", + "Pending": "Đang chờ", + "No transfers": "Không có truyền", + "Retry All": "Thử lại tất cả", + "Clear Completed": "Xóa đã hoàn thành", + "Clear Failed": "Xóa thất bại", + "Upload queued: {{title}}": "Tải lên trong hàng đợi: {{title}}", + "Download queued: {{title}}": "Tải xuống trong hàng đợi: {{title}}", + "Book not found in library": "Không tìm thấy sách trong thư viện", + "Unknown error": "Lỗi không xác định", + "Please log in to continue": "Vui lòng đăng nhập để tiếp tục" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index fa334c05..ee46cf74 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -790,5 +790,29 @@ "Look up text in Wikipedia after selection": "在维基百科中查找选中文本", "Translate text after selection": "翻译选中文本", "Read text aloud after selection": "朗读选中文本", - "Proofread text after selection": "校对选中文本" + "Proofread text after selection": "校对选中文本", + "File Transfers": "文件传输", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} 进行中,{{pendingCount}} 等待中", + "{{failedCount}} failed": "{{failedCount}} 失败", + "Waiting...": "等待中...", + "Failed": "失败", + "Completed": "已完成", + "Cancelled": "已取消", + "Retry": "重试", + "Active": "进行中", + "Transfer Queue": "传输队列", + "Upload All": "全部上传", + "Download All": "全部下载", + "Resume Transfers": "继续传输", + "Pause Transfers": "暂停传输", + "Pending": "等待中", + "No transfers": "没有传输", + "Retry All": "全部重试", + "Clear Completed": "清除已完成", + "Clear Failed": "清除失败", + "Upload queued: {{title}}": "上传已排队:{{title}}", + "Download queued: {{title}}": "下载已排队:{{title}}", + "Book not found in library": "在书库中找不到书籍", + "Unknown error": "未知错误", + "Please log in to continue": "请登录以继续" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 7a34a358..ea5479b9 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -790,5 +790,29 @@ "Look up text in Wikipedia after selection": "在維基百科中查找選取後的文字", "Translate text after selection": "翻譯選取後的文字", "Read text aloud after selection": "朗讀選取後的文字", - "Proofread text after selection": "校對選取後的文字" + "Proofread text after selection": "校對選取後的文字", + "File Transfers": "檔案傳輸", + "{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} 進行中,{{pendingCount}} 等待中", + "{{failedCount}} failed": "{{failedCount}} 失敗", + "Waiting...": "等待中...", + "Failed": "失敗", + "Completed": "已完成", + "Cancelled": "已取消", + "Retry": "重試", + "Active": "進行中", + "Transfer Queue": "傳輸佇列", + "Upload All": "全部上傳", + "Download All": "全部下載", + "Resume Transfers": "繼續傳輸", + "Pause Transfers": "暫停傳輸", + "Pending": "等待中", + "No transfers": "沒有傳輸", + "Retry All": "全部重試", + "Clear Completed": "清除已完成", + "Clear Failed": "清除失敗", + "Upload queued: {{title}}": "上傳已排隊:{{title}}", + "Download queued: {{title}}": "下載已排隊:{{title}}", + "Book not found in library": "在書庫中找不到書籍", + "Unknown error": "未知錯誤", + "Please log in to continue": "請登入以繼續" } diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index 606219dd..8bcded40 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -10,6 +10,7 @@ import { useThemeStore } from '@/store/themeStore'; import { useAutoFocus } from '@/hooks/useAutoFocus'; import { useSettingsStore } from '@/store/settingsStore'; import { useLibraryStore } from '@/store/libraryStore'; +import { useTransferStore } from '@/store/transferStore'; import { useTranslation } from '@/hooks/useTranslation'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav'; @@ -23,6 +24,7 @@ import ModalPortal from '@/components/ModalPortal'; import BookshelfItem, { generateBookshelfItems } from './BookshelfItem'; import SelectModeActions from './SelectModeActions'; import GroupingModal from './GroupingModal'; +import TransferQueuePanel from './TransferQueuePanel'; interface BookshelfProps { libraryBooks: Book[]; @@ -82,6 +84,8 @@ const Bookshelf: React.FC = ({ const { setSelectedBooks, getSelectedBooks, toggleSelectedBook } = useLibraryStore(); const { getGroupName } = useLibraryStore(); + const { isTransferQueueOpen } = useTransferStore(); + const uiLanguage = localStorage?.getItem('i18nextLng') || ''; const updateUrlParams = useCallback( @@ -376,6 +380,11 @@ const Bookshelf: React.FC = ({ /> )} + {isTransferQueueOpen && ( + + + + )} {showDeleteAlert && (
= ({ setIsDropdownOpen }) => { ); const iconSize = useResponsiveSize(16); + const { stats, hasActiveTransfers, setIsTransferQueueOpen } = useTransferQueue(); + + const openTransferQueue = () => { + setIsTransferQueueOpen(true); + setIsDropdownOpen?.(false); + }; + const showAboutReadest = () => { setAboutDialogVisible(true); setIsDropdownOpen?.(false); @@ -260,6 +269,21 @@ const SettingsMenu: React.FC = ({ setIsDropdownOpen }) => { toggled={isAutoUpload} onClick={toggleAutoUploadBooks} /> + 0 + ? _('{{failedCount}} failed', { failedCount: stats.failed }) + : '' + } + onClick={openTransferQueue} + /> {isTauriAppPlatform() && !appService?.isMobile && ( { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +}; + +const formatSpeed = (bytesPerSec: number): string => { + return `${formatBytes(bytesPerSec)}/s`; +}; + +const formatDateTime = (timestamp: number): string => { + const date = new Date(timestamp); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + const isYesterday = date.toDateString() === yesterday.toDateString(); + + const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + if (isToday) { + return timeStr; + } else if (isYesterday) { + return `Yesterday ${timeStr}`; + } else { + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + timeStr; + } +}; + +const StatusIcon: React.FC<{ + status: TransferStatus; + type: 'upload' | 'download'; + size: number; +}> = ({ status, type, size }) => { + switch (status) { + case 'completed': + return ; + case 'failed': + return ; + case 'cancelled': + return ; + case 'in_progress': + case 'pending': + default: + return type === 'upload' ? ( + + ) : ( + + ); + } +}; + +const TransferItemRow: React.FC<{ + transfer: TransferItem; + onCancel: (id: string) => void; + onRetry: (id: string) => void; + iconSize: number; +}> = ({ transfer, onCancel, onRetry, iconSize }) => { + const _ = useTranslation(); + + return ( +
+ + +
+
{transfer.bookTitle}
+
+ {transfer.status === 'in_progress' && ( + <> + {Math.round(transfer.progress)}% - {formatSpeed(transfer.transferSpeed)} + + )} + {transfer.status === 'pending' && transfer.error && ( + {transfer.error} + )} + {transfer.status === 'pending' && !transfer.error && _('Waiting...')} + {transfer.status === 'failed' && ( + {transfer.error || _('Failed')} + )} + {transfer.status === 'completed' && _('Completed')} + {transfer.status === 'cancelled' && _('Cancelled')} + {' · '} + {formatDateTime(transfer.completedAt || transfer.startedAt || transfer.createdAt)} +
+ + {transfer.status === 'in_progress' && ( +
+
+
+ )} +
+ +
+ {(transfer.status === 'failed' || transfer.status === 'cancelled') && ( + + )} + {['pending', 'in_progress'].includes(transfer.status) && ( + + )} +
+
+ ); +}; + +type FilterType = 'all' | 'active' | 'completed' | 'failed'; + +const TransferQueuePanel: React.FC = () => { + const _ = useTranslation(); + const iconSize = useResponsiveSize(18); + const setIsOpen = useTransferStore((state) => state.setIsTransferQueueOpen); + const getVisibleLibrary = useLibraryStore((state) => state.getVisibleLibrary); + const { + transfers, + stats, + isQueuePaused, + cancelTransfer, + retryTransfer, + retryAllFailed, + pauseQueue, + resumeQueue, + clearCompleted, + clearFailed, + queueUpload, + queueDownload, + } = useTransferQueue(); + + const [filter, setFilter] = useState('all'); + + const onClose = () => setIsOpen(false); + const divRef = useKeyDownActions({ onCancel: onClose, onConfirm: onClose }); + + const booksToUpload = getVisibleLibrary().filter( + (book) => book.downloadedAt && !book.uploadedAt, + ); + const booksToDownload = getVisibleLibrary().filter( + (book) => book.uploadedAt && !book.downloadedAt, + ); + + const handleUploadAll = () => { + booksToUpload.forEach((book) => queueUpload(book)); + }; + + const handleDownloadAll = () => { + booksToDownload.forEach((book) => queueDownload(book)); + }; + + const filteredTransfers = transfers + .filter((t) => { + switch (filter) { + case 'active': + return ['pending', 'in_progress'].includes(t.status); + case 'completed': + return t.status === 'completed'; + case 'failed': + return t.status === 'failed' || t.status === 'cancelled'; + default: + return true; + } + }) + .sort((a, b) => { + // Sort by status priority then by createdAt + const statusOrder: Record = { + in_progress: 0, + pending: 1, + failed: 2, + cancelled: 3, + completed: 4, + }; + const statusDiff = statusOrder[a.status] - statusOrder[b.status]; + if (statusDiff !== 0) return statusDiff; + return b.createdAt - a.createdAt; + }); + + const filterLabels: Record = { + all: _('All'), + active: _('Active'), + completed: _('Completed'), + failed: _('Failed'), + }; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
+
+ {/* Header */} +
+

{_('Transfer Queue')}

+
+ {booksToUpload.length > 0 && ( + + )} + {booksToDownload.length > 0 && ( + + )} + + +
+
+ + {/* Stats bar */} +
+ + {_('Active')}: {stats.active} + + + {_('Pending')}: {stats.pending} + + + {_('Completed')}: {stats.completed} + + + {_('Failed')}: {stats.failed} + +
+ + {/* Filter tabs */} +
+ {(['all', 'active', 'completed', 'failed'] as const).map((f) => ( + + ))} +
+ + {/* Transfer list */} +
+ {filteredTransfers.length === 0 ? ( +
{_('No transfers')}
+ ) : ( + filteredTransfers.map((transfer) => ( + + )) + )} +
+ + {/* Footer actions */} +
+
+ {stats.failed > 0 && ( + + )} +
+
+ {stats.completed > 0 && ( + + )} + {stats.failed > 0 && ( + + )} +
+
+
+
+ ); +}; + +export default TransferQueuePanel; diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index d52f5de5..a19e6ef9 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -10,11 +10,12 @@ import 'overlayscrollbars/overlayscrollbars.css'; import { Book } from '@/types/book'; import { AppService, DeleteAction } from '@/types/system'; -import { navigateToLibrary, navigateToLogin, navigateToReader } from '@/utils/nav'; +import { navigateToLibrary, navigateToReader } from '@/utils/nav'; import { formatAuthors, formatTitle, getPrimaryLanguage, listFormater } from '@/utils/book'; import { eventDispatcher } from '@/utils/event'; import { ProgressPayload } from '@/utils/transfer'; import { throttle } from '@/utils/throttle'; +import { transferManager } from '@/services/transferManager'; import { getDirPath, getFilename, joinPaths } from '@/utils/path'; import { parseOpenWithFiles } from '@/helpers/openWith'; import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment'; @@ -55,6 +56,7 @@ import { UpdaterWindow } from '@/components/UpdaterWindow'; import { CatalogDialog } from './components/OPDSDialog'; import { MigrateDataWindow } from './components/MigrateDataWindow'; import { useDragDropImport } from './hooks/useDragDropImport'; +import { useTransferQueue } from '@/hooks/useTransferQueue'; import { Toast } from '@/components/Toast'; import { getBreadcrumbs } from './utils/libraryUtils'; import Spinner from '@/components/Spinner'; @@ -120,6 +122,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP useUICSS(); useOpenWithBooks(); + useTransferQueue(libraryLoaded); const { pullLibrary, pushLibrary } = useBooksSync(); const { isDragging } = useDragDropImport(); @@ -409,8 +412,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP await updateBook(envConfig, book); } if (user && book && !book.uploadedAt && settings.autoUpload) { - console.log('Uploading book:', book.title); - handleBookUpload(book, false); + console.log('Queueing upload for book:', book.title); + transferManager.queueUpload(book); } if (book) { successfulImports.push(book.title); @@ -471,83 +474,65 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP }, 500); const handleBookUpload = useCallback( - async (book: Book, syncBooks = true) => { - try { - await appService?.uploadBook(book, (progress) => { - updateBookTransferProgress(book.hash, progress); - }); - setBooksTransferProgress((prev) => { - const updated = { ...prev }; - delete updated[book.hash]; - return updated; - }); - await updateBook(envConfig, book); - if (syncBooks) pushLibrary(); + async (book: Book, _syncBooks = true) => { + // Use transfer queue for uploads - priority 1 for manual uploads (higher priority) + const transferId = transferManager.queueUpload(book, 1); + if (transferId) { eventDispatcher.dispatch('toast', { type: 'info', timeout: 2000, - message: _('Book uploaded: {{title}}', { + message: _('Upload queued: {{title}}', { title: book.title, }), }); return true; - } catch (err) { - setBooksTransferProgress((prev) => { - const updated = { ...prev }; - delete updated[book.hash]; - return updated; - }); - if (err instanceof Error) { - if (err.message.includes('Not authenticated') && settings.keepLogin) { - settings.keepLogin = false; - setSettings(settings); - navigateToLogin(router); - return false; - } else if (err.message.includes('Insufficient storage quota')) { - eventDispatcher.dispatch('toast', { - type: 'error', - message: _('Insufficient storage quota'), - }); - return false; - } - } - eventDispatcher.dispatch('toast', { - type: 'error', - message: _('Failed to upload book: {{title}}', { - title: book.title, - }), - }); - return false; } + return false; }, // eslint-disable-next-line react-hooks/exhaustive-deps - [appService], + [], ); const handleBookDownload = useCallback( - async (book: Book, redownload = false) => { - try { - await appService?.downloadBook(book, false, redownload, (progress) => { - updateBookTransferProgress(book.hash, progress); - }); - await updateBook(envConfig, book); + async (book: Book, redownload = false, queued = false) => { + if (redownload || !queued) { + try { + await appService?.downloadBook(book, false, redownload, (progress) => { + updateBookTransferProgress(book.hash, progress); + }); + await updateBook(envConfig, book); + eventDispatcher.dispatch('toast', { + type: 'info', + timeout: 2000, + message: _('Book downloaded: {{title}}', { + title: book.title, + }), + }); + return true; + } catch { + eventDispatcher.dispatch('toast', { + message: _('Failed to download book: {{title}}', { + title: book.title, + }), + type: 'error', + }); + return false; + } + } + + // Use transfer queue for normal downloads - priority 1 for manual downloads + const transferId = transferManager.queueDownload(book, 1); + if (transferId) { eventDispatcher.dispatch('toast', { type: 'info', timeout: 2000, - message: _('Book downloaded: {{title}}', { + message: _('Download queued: {{title}}', { title: book.title, }), }); return true; - } catch { - eventDispatcher.dispatch('toast', { - message: _('Failed to download book: {{title}}', { - title: book.title, - }), - type: 'error', - }); - return false; } + return false; }, // eslint-disable-next-line react-hooks/exhaustive-deps [appService], diff --git a/apps/readest-app/src/app/reader/components/Reader.tsx b/apps/readest-app/src/app/reader/components/Reader.tsx index 9a365860..f131aa59 100644 --- a/apps/readest-app/src/app/reader/components/Reader.tsx +++ b/apps/readest-app/src/app/reader/components/Reader.tsx @@ -15,6 +15,7 @@ import { useNotebookStore } from '@/store/notebookStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useDeviceControlStore } from '@/store/deviceStore'; import { useScreenWakeLock } from '@/hooks/useScreenWakeLock'; +import { useTransferQueue } from '@/hooks/useTransferQueue'; import { eventDispatcher } from '@/utils/event'; import { interceptWindowOpen } from '@/utils/open'; import { mountAdditionalFonts } from '@/styles/fonts'; @@ -54,18 +55,19 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => { const router = useRouter(); const { appService } = useEnv(); const { settings } = useSettingsStore(); + const { libraryLoaded } = useLibrary(); const { sideBarBookKey } = useSidebarStore(); const { hoveredBookKey, getView } = useReaderStore(); + const { showSystemUI, dismissSystemUI } = useThemeStore(); const { getScreenBrightness, setScreenBrightness } = useDeviceControlStore(); + const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore(); const { isSideBarVisible, getIsSideBarVisible, setSideBarVisible } = useSidebarStore(); const { isNotebookVisible, getIsNotebookVisible, setNotebookVisible } = useNotebookStore(); const { isDarkMode, systemUIAlwaysHidden, isRoundedWindow } = useThemeStore(); - const { showSystemUI, dismissSystemUI } = useThemeStore(); - const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore(); - const { libraryLoaded } = useLibrary(); useTheme({ systemUIVisible: settings.alwaysShowStatusBar, appThemeColor: 'base-100' }); useScreenWakeLock(settings.screenWakeLock); + useTransferQueue(libraryLoaded, 5000); useEffect(() => { mountAdditionalFonts(document); diff --git a/apps/readest-app/src/hooks/useTransferQueue.ts b/apps/readest-app/src/hooks/useTransferQueue.ts new file mode 100644 index 00000000..ae3d402c --- /dev/null +++ b/apps/readest-app/src/hooks/useTransferQueue.ts @@ -0,0 +1,142 @@ +import { useEffect, useCallback, useMemo } from 'react'; +import { useEnv } from '@/context/EnvContext'; +import { useTranslation } from './useTranslation'; +import { useLibraryStore } from '@/store/libraryStore'; +import { useTransferStore, TransferType } from '@/store/transferStore'; +import { transferManager } from '@/services/transferManager'; +import { Book } from '@/types/book'; + +export function useTransferQueue(libraryLoaded = true, delayInit = 0) { + const { envConfig, appService } = useEnv(); + const _ = useTranslation(); + + const transfers = useTransferStore((state) => state.transfers); + const isQueuePaused = useTransferStore((state) => state.isQueuePaused); + const setIsTransferQueueOpen = useTransferStore((state) => state.setIsTransferQueueOpen); + + useEffect(() => { + const initManager = async () => { + if (appService && envConfig) { + const getLibrary = () => useLibraryStore.getState().library; + const updateBookFn = async (book: Book) => { + await useLibraryStore.getState().updateBook(envConfig, book); + }; + const translationFn = _; + await transferManager.initialize(appService, getLibrary, updateBookFn, translationFn); + } + }; + + // Initialize transfer manager only when library is loaded + if (libraryLoaded) { + setTimeout(() => { + initManager(); + }, delayInit); + } + }, [appService, envConfig, libraryLoaded, delayInit, _]); + + const queueUpload = useCallback((book: Book, priority?: number) => { + return transferManager.queueUpload(book, priority); + }, []); + + const queueDownload = useCallback((book: Book, priority?: number) => { + return transferManager.queueDownload(book, priority); + }, []); + + const queueBatchUploads = useCallback((books: Book[], priority?: number) => { + return transferManager.queueBatchUploads(books, priority); + }, []); + + const cancelTransfer = useCallback((transferId: string) => { + transferManager.cancelTransfer(transferId); + }, []); + + const retryTransfer = useCallback((transferId: string) => { + transferManager.retryTransfer(transferId); + }, []); + + const retryAllFailed = useCallback(() => { + transferManager.retryAllFailed(); + }, []); + + const pauseQueue = useCallback(() => { + transferManager.pauseQueue(); + }, []); + + const resumeQueue = useCallback(() => { + transferManager.resumeQueue(); + }, []); + + const clearCompleted = useCallback(() => { + useTransferStore.getState().clearCompleted(); + }, []); + + const clearFailed = useCallback(() => { + useTransferStore.getState().clearFailed(); + }, []); + + const clearAll = useCallback(() => { + useTransferStore.getState().clearAll(); + }, []); + + const getTransferProgress = useCallback((bookHash: string, type: TransferType) => { + return useTransferStore.getState().getTransferByBookHash(bookHash, type); + }, []); + + const stats = useMemo(() => { + const transferList = Object.values(transfers); + return { + pending: transferList.filter((t) => t.status === 'pending').length, + active: transferList.filter((t) => t.status === 'in_progress').length, + completed: transferList.filter((t) => t.status === 'completed').length, + failed: transferList.filter((t) => t.status === 'failed' || t.status === 'cancelled').length, + total: transferList.length, + }; + }, [transfers]); + + const pendingTransfers = useMemo(() => { + return Object.values(transfers).filter((t) => t.status === 'pending'); + }, [transfers]); + + const activeTransfers = useMemo(() => { + return Object.values(transfers).filter((t) => t.status === 'in_progress'); + }, [transfers]); + + const failedTransfers = useMemo(() => { + return Object.values(transfers).filter( + (t) => t.status === 'failed' || t.status === 'cancelled', + ); + }, [transfers]); + + const completedTransfers = useMemo(() => { + return Object.values(transfers).filter((t) => t.status === 'completed'); + }, [transfers]); + + const hasActiveTransfers = useMemo(() => { + return pendingTransfers.length > 0 || activeTransfers.length > 0; + }, [pendingTransfers, activeTransfers]); + + return { + transfers: Object.values(transfers), + isQueuePaused, + stats, + pendingTransfers, + activeTransfers, + failedTransfers, + completedTransfers, + hasActiveTransfers, + + setIsTransferQueueOpen, + queueUpload, + queueDownload, + queueBatchUploads, + cancelTransfer, + retryTransfer, + retryAllFailed, + pauseQueue, + resumeQueue, + clearCompleted, + clearFailed, + clearAll, + getTransferProgress, + }; +} diff --git a/apps/readest-app/src/services/transferManager.ts b/apps/readest-app/src/services/transferManager.ts new file mode 100644 index 00000000..9bc858aa --- /dev/null +++ b/apps/readest-app/src/services/transferManager.ts @@ -0,0 +1,340 @@ +import { Book } from '@/types/book'; +import { AppService } from '@/types/system'; +import { useTransferStore, TransferItem } from '@/store/transferStore'; +import { TranslationFunc } from '@/hooks/useTranslation'; +import { ProgressPayload } from '@/utils/transfer'; +import { eventDispatcher } from '@/utils/event'; + +const TRANSFER_QUEUE_KEY = 'readest_transfer_queue'; +const RETRY_DELAY_BASE_MS = 2000; + +interface PersistedQueueData { + transfers: Record; + isQueuePaused: boolean; +} + +class TransferManager { + private static instance: TransferManager; + private appService: AppService | null = null; + private isProcessing = false; + private abortControllers: Map = new Map(); + private isInitialized = false; + private getLibrary: (() => Book[]) | null = null; + private updateBook: ((book: Book) => Promise) | null = null; + private _: TranslationFunc | null = null; + + private constructor() {} + + static getInstance(): TransferManager { + if (!TransferManager.instance) { + TransferManager.instance = new TransferManager(); + } + return TransferManager.instance; + } + + async initialize( + appService: AppService, + getLibrary: () => Book[], + updateBook: (book: Book) => Promise, + translationFn: TranslationFunc, + ): Promise { + if (this.isInitialized) return; + + this.appService = appService; + this.getLibrary = getLibrary; + this.updateBook = updateBook; + this._ = translationFn; + await this.loadPersistedQueue(); + this.isInitialized = true; + + // Start processing queue + this.processQueue(); + } + + isReady(): boolean { + return this.isInitialized && this.appService !== null; + } + + queueUpload(book: Book, priority: number = 10): string | null { + if (!this.isReady()) { + console.warn('TransferManager not initialized'); + return null; + } + + const store = useTransferStore.getState(); + + // Check if already queued or in progress + const existing = store.getTransferByBookHash(book.hash, 'upload'); + if (existing) { + return existing.id; + } + + const transferId = store.addTransfer(book.hash, book.title, 'upload', priority); + this.persistQueue(); + this.processQueue(); + return transferId; + } + + queueDownload(book: Book, priority: number = 10): string | null { + if (!this.isReady()) { + console.warn('TransferManager not initialized'); + return null; + } + + const store = useTransferStore.getState(); + + const existing = store.getTransferByBookHash(book.hash, 'download'); + if (existing) { + return existing.id; + } + + const transferId = store.addTransfer(book.hash, book.title, 'download', priority); + this.persistQueue(); + this.processQueue(); + return transferId; + } + + queueBatchUploads(books: Book[], priority: number = 10): string[] { + return books + .map((book) => this.queueUpload(book, priority)) + .filter((id): id is string => id !== null); + } + + cancelTransfer(transferId: string): void { + const controller = this.abortControllers.get(transferId); + if (controller) { + controller.abort(); + this.abortControllers.delete(transferId); + } + + useTransferStore.getState().setTransferStatus(transferId, 'cancelled'); + this.persistQueue(); + } + + retryTransfer(transferId: string): void { + const store = useTransferStore.getState(); + store.retryTransfer(transferId); + this.persistQueue(); + this.processQueue(); + } + + retryAllFailed(): void { + const store = useTransferStore.getState(); + const failed = store.getFailedTransfers(); + failed.forEach((transfer) => { + store.retryTransfer(transfer.id); + }); + this.persistQueue(); + this.processQueue(); + } + + pauseQueue(): void { + useTransferStore.getState().pauseQueue(); + this.persistQueue(); + } + + resumeQueue(): void { + useTransferStore.getState().resumeQueue(); + this.processQueue(); + this.persistQueue(); + } + + private async processQueue(): Promise { + if (this.isProcessing) return; + + this.isProcessing = true; + + try { + await this._processQueueInternal(); + } finally { + this.isProcessing = false; + } + } + + private async _processQueueInternal(): Promise { + const store = useTransferStore.getState(); + + if (store.isQueuePaused) return; + + const pending = store.getPendingTransfers(); + const activeCount = store.getActiveTransfers().length; + const maxConcurrent = store.maxConcurrent; + + const availableSlots = maxConcurrent - activeCount; + if (availableSlots <= 0 || pending.length === 0) return; + + // Sort by priority (lower = higher priority) then by createdAt + const sortedPending = [...pending].sort((a, b) => { + if (a.priority !== b.priority) return a.priority - b.priority; + return a.createdAt - b.createdAt; + }); + + const toProcess = sortedPending.slice(0, availableSlots); + + await Promise.all(toProcess.map((transfer) => this.executeTransfer(transfer))); + + // Check if more items to process + const newStore = useTransferStore.getState(); + if (newStore.getPendingTransfers().length > 0 && !newStore.isQueuePaused) { + setTimeout(() => this.processQueue(), 100); + } + } + + private async executeTransfer(transfer: TransferItem): Promise { + if (!this.appService || !this.getLibrary || !this.updateBook) { + console.error('TransferManager not properly initialized'); + return; + } + + const _ = this._!; + const store = useTransferStore.getState(); + const abortController = new AbortController(); + this.abortControllers.set(transfer.id, abortController); + + store.setTransferStatus(transfer.id, 'in_progress'); + store.setActiveCount(store.getActiveTransfers().length + 1); + + const progressHandler = (progress: ProgressPayload) => { + if (abortController.signal.aborted) return; + + const percentage = progress.total > 0 ? (progress.progress / progress.total) * 100 : 0; + + useTransferStore + .getState() + .updateTransferProgress( + transfer.id, + percentage, + progress.progress, + progress.total, + progress.transferSpeed, + ); + }; + + try { + const library = this.getLibrary(); + const book = library.find((b) => b.hash === transfer.bookHash); + + if (!book) { + throw new Error(_('Book not found in library')); + } + + if (transfer.type === 'upload') { + await this.appService.uploadBook(book, progressHandler); + book.uploadedAt = Date.now(); + } else { + await this.appService.downloadBook(book, false, false, progressHandler); + book.downloadedAt = Date.now(); + } + + await this.updateBook(book); + + useTransferStore.getState().setTransferStatus(transfer.id, 'completed'); + + eventDispatcher.dispatch('toast', { + type: 'info', + timeout: 2000, + message: + transfer.type === 'upload' + ? _('Book uploaded: {{title}}', { title: transfer.bookTitle }) + : _('Book downloaded: {{title}}', { title: transfer.bookTitle }), + }); + } catch (error) { + if (abortController.signal.aborted) { + // Already cancelled, don't update status + return; + } + + const errorMessage = error instanceof Error ? error.message : _('Unknown error'); + const currentStore = useTransferStore.getState(); + const currentTransfer = currentStore.transfers[transfer.id]; + + if (currentTransfer && currentTransfer.retryCount < currentTransfer.maxRetries) { + // Schedule retry with exponential backoff + const delay = RETRY_DELAY_BASE_MS * Math.pow(2, currentTransfer.retryCount); + currentStore.incrementRetryCount(transfer.id); + currentStore.setTransferStatus( + transfer.id, + 'pending', + `Retry ${currentTransfer.retryCount + 1}/${currentTransfer.maxRetries}`, + ); + + setTimeout(() => { + this.processQueue(); + }, delay); + } else { + if (errorMessage.includes('Not authenticated')) { + eventDispatcher.dispatch('toast', { + type: 'error', + message: _('Please log in to continue'), + }); + } else if (errorMessage.includes('Insufficient storage quota')) { + eventDispatcher.dispatch('toast', { + type: 'error', + message: _('Insufficient storage quota'), + }); + } else { + eventDispatcher.dispatch('toast', { + type: 'error', + message: + transfer.type === 'upload' + ? _('Failed to upload book: {{title}}', { + title: transfer.bookTitle, + }) + : _('Failed to download book: {{title}}', { + title: transfer.bookTitle, + }), + }); + } + + useTransferStore.getState().setTransferStatus(transfer.id, 'failed', errorMessage); + } + } finally { + this.abortControllers.delete(transfer.id); + + const currentStore = useTransferStore.getState(); + currentStore.setActiveCount(Math.max(0, currentStore.getActiveTransfers().length)); + this.persistQueue(); + + // Continue processing + setTimeout(() => this.processQueue(), 100); + } + } + + private async loadPersistedQueue(): Promise { + try { + if (typeof localStorage === 'undefined') return; + + const stored = localStorage.getItem(TRANSFER_QUEUE_KEY); + if (!stored) return; + + const data: PersistedQueueData = JSON.parse(stored); + const store = useTransferStore.getState(); + + // Restore all transfers using the store's restore method + // This preserves the original IDs and handles in_progress -> pending conversion + store.restoreTransfers(data.transfers, data.isQueuePaused); + } catch (error) { + console.error('Failed to load transfer queue:', error); + } + } + + private persistQueue(): void { + try { + if (typeof localStorage === 'undefined') return; + + const store = useTransferStore.getState(); + + // Persist all transfers including completed (for history) + const data: PersistedQueueData = { + transfers: store.transfers, + isQueuePaused: store.isQueuePaused, + }; + + localStorage.setItem(TRANSFER_QUEUE_KEY, JSON.stringify(data)); + } catch (error) { + console.error('Failed to persist transfer queue:', error); + } + } +} + +export const transferManager = TransferManager.getInstance(); diff --git a/apps/readest-app/src/store/transferStore.ts b/apps/readest-app/src/store/transferStore.ts new file mode 100644 index 00000000..6a913058 --- /dev/null +++ b/apps/readest-app/src/store/transferStore.ts @@ -0,0 +1,295 @@ +import { create } from 'zustand'; + +export type TransferType = 'upload' | 'download'; +export type TransferStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled'; + +export interface TransferItem { + id: string; + bookHash: string; + bookTitle: string; + type: TransferType; + status: TransferStatus; + progress: number; // 0-100 percentage + totalBytes: number; + transferredBytes: number; + transferSpeed: number; // bytes per second + error?: string; + retryCount: number; + maxRetries: number; + createdAt: number; + startedAt?: number; + completedAt?: number; + priority: number; // Lower = higher priority +} + +interface TransferState { + transfers: Record; + isQueuePaused: boolean; + isTransferQueueOpen: boolean; + maxConcurrent: number; + activeCount: number; + + // UI Actions + setIsTransferQueueOpen: (isOpen: boolean) => void; + + // Actions + addTransfer: ( + bookHash: string, + bookTitle: string, + type: TransferType, + priority?: number, + ) => string; + removeTransfer: (transferId: string) => void; + updateTransferProgress: ( + transferId: string, + progress: number, + transferred: number, + total: number, + speed: number, + ) => void; + setTransferStatus: (transferId: string, status: TransferStatus, error?: string) => void; + retryTransfer: (transferId: string) => void; + incrementRetryCount: (transferId: string) => void; + + // Queue control + pauseQueue: () => void; + resumeQueue: () => void; + clearCompleted: () => void; + clearFailed: () => void; + clearAll: () => void; + + // Getters + getPendingTransfers: () => TransferItem[]; + getActiveTransfers: () => TransferItem[]; + getFailedTransfers: () => TransferItem[]; + getCompletedTransfers: () => TransferItem[]; + getTransferByBookHash: (bookHash: string, type: TransferType) => TransferItem | undefined; + getQueueStats: () => { + pending: number; + active: number; + completed: number; + failed: number; + total: number; + }; + + // Internal + setActiveCount: (count: number) => void; + + // Persistence + restoreTransfers: (transfers: Record, isQueuePaused: boolean) => void; +} + +const generateTransferId = (): string => { + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +}; + +export const useTransferStore = create((set, get) => ({ + transfers: {}, + isQueuePaused: false, + isTransferQueueOpen: false, + maxConcurrent: 2, + activeCount: 0, + + setIsTransferQueueOpen: (isOpen) => set({ isTransferQueueOpen: isOpen }), + + addTransfer: (bookHash, bookTitle, type, priority = 10) => { + const id = generateTransferId(); + const transfer: TransferItem = { + id, + bookHash, + bookTitle, + type, + status: 'pending', + progress: 0, + totalBytes: 0, + transferredBytes: 0, + transferSpeed: 0, + retryCount: 0, + maxRetries: 3, + createdAt: Date.now(), + priority, + }; + + set((state) => ({ + transfers: { ...state.transfers, [id]: transfer }, + })); + + return id; + }, + + removeTransfer: (transferId) => { + set((state) => { + const { [transferId]: _, ...remaining } = state.transfers; + return { transfers: remaining }; + }); + }, + + updateTransferProgress: (transferId, progress, transferred, total, speed) => { + set((state) => { + const transfer = state.transfers[transferId]; + if (!transfer) return state; + + return { + transfers: { + ...state.transfers, + [transferId]: { + ...transfer, + progress, + transferredBytes: transferred, + totalBytes: total, + transferSpeed: speed, + }, + }, + }; + }); + }, + + setTransferStatus: (transferId, status, error) => { + set((state) => { + const transfer = state.transfers[transferId]; + if (!transfer) return state; + + const updates: Partial = { status, error }; + + if (status === 'in_progress' && !transfer.startedAt) { + updates.startedAt = Date.now(); + } + + if (status === 'completed' || status === 'failed' || status === 'cancelled') { + updates.completedAt = Date.now(); + } + + return { + transfers: { + ...state.transfers, + [transferId]: { ...transfer, ...updates }, + }, + }; + }); + }, + + retryTransfer: (transferId) => { + set((state) => { + const transfer = state.transfers[transferId]; + if (!transfer) return state; + + return { + transfers: { + ...state.transfers, + [transferId]: { + ...transfer, + status: 'pending', + progress: 0, + transferredBytes: 0, + transferSpeed: 0, + error: undefined, + startedAt: undefined, + completedAt: undefined, + }, + }, + }; + }); + }, + + incrementRetryCount: (transferId) => { + set((state) => { + const transfer = state.transfers[transferId]; + if (!transfer) return state; + + return { + transfers: { + ...state.transfers, + [transferId]: { + ...transfer, + retryCount: transfer.retryCount + 1, + }, + }, + }; + }); + }, + + pauseQueue: () => set({ isQueuePaused: true }), + resumeQueue: () => set({ isQueuePaused: false }), + + clearCompleted: () => { + set((state) => { + const remaining: Record = {}; + Object.entries(state.transfers).forEach(([id, transfer]) => { + if (transfer.status !== 'completed') { + remaining[id] = transfer; + } + }); + return { transfers: remaining }; + }); + }, + + clearFailed: () => { + set((state) => { + const remaining: Record = {}; + Object.entries(state.transfers).forEach(([id, transfer]) => { + if (transfer.status !== 'failed' && transfer.status !== 'cancelled') { + remaining[id] = transfer; + } + }); + return { transfers: remaining }; + }); + }, + + clearAll: () => set({ transfers: {} }), + + getPendingTransfers: () => { + return Object.values(get().transfers).filter((t) => t.status === 'pending'); + }, + + getActiveTransfers: () => { + return Object.values(get().transfers).filter((t) => t.status === 'in_progress'); + }, + + getFailedTransfers: () => { + return Object.values(get().transfers).filter( + (t) => t.status === 'failed' || t.status === 'cancelled', + ); + }, + + getCompletedTransfers: () => { + return Object.values(get().transfers).filter((t) => t.status === 'completed'); + }, + + getTransferByBookHash: (bookHash, type) => { + return Object.values(get().transfers).find( + (t) => t.bookHash === bookHash && t.type === type && ['pending', 'in_progress'].includes(t.status), + ); + }, + + getQueueStats: () => { + const transfers = Object.values(get().transfers); + return { + pending: transfers.filter((t) => t.status === 'pending').length, + active: transfers.filter((t) => t.status === 'in_progress').length, + completed: transfers.filter((t) => t.status === 'completed').length, + failed: transfers.filter((t) => t.status === 'failed' || t.status === 'cancelled').length, + total: transfers.length, + }; + }, + + setActiveCount: (count) => set({ activeCount: count }), + + restoreTransfers: (transfers, isQueuePaused) => { + // Reset in_progress transfers to pending when restoring + const restoredTransfers: Record = {}; + Object.entries(transfers).forEach(([id, transfer]) => { + if (transfer.status === 'in_progress') { + restoredTransfers[id] = { + ...transfer, + status: 'pending', + progress: 0, + transferredBytes: 0, + transferSpeed: 0, + }; + } else { + restoredTransfers[id] = transfer; + } + }); + set({ transfers: restoredTransfers, isQueuePaused }); + }, +}));