forked from akai/readest
feat(transfer): add a background transfer queue system for book uploads/downloads (#2821)
This commit is contained in:
@@ -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": "يرجى تسجيل الدخول للمتابعة"
|
||||
}
|
||||
|
||||
@@ -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": "চালিয়ে যেতে লগইন করুন"
|
||||
}
|
||||
|
||||
@@ -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": "མུ་མཐུད་པར་ནང་འཛུལ་བྱོས་"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Παρακαλώ συνδεθείτε για να συνεχίσετε"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "لطفاً برای ادامه وارد شوید"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "जारी रखने के लिए लॉगिन करें"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "続行するにはログインしてください"
|
||||
}
|
||||
|
||||
@@ -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": "계속하려면 로그인하세요"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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ć"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Войдите, чтобы продолжить"
|
||||
}
|
||||
|
||||
@@ -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": "ඉදිරියට යාමට පිවිසෙන්න"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "தொடர உள்நுழையவும்"
|
||||
}
|
||||
|
||||
@@ -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": "กรุณาเข้าสู่ระบบเพื่อดำเนินการต่อ"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Увійдіть, щоб продовжити"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "请登录以继续"
|
||||
}
|
||||
|
||||
@@ -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": "請登入以繼續"
|
||||
}
|
||||
|
||||
@@ -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<BookshelfProps> = ({
|
||||
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<BookshelfProps> = ({
|
||||
/>
|
||||
</ModalPortal>
|
||||
)}
|
||||
{isTransferQueueOpen && (
|
||||
<ModalPortal>
|
||||
<TransferQueuePanel />
|
||||
</ModalPortal>
|
||||
)}
|
||||
{showDeleteAlert && (
|
||||
<div
|
||||
className={clsx('fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { PiUserCircle, PiUserCircleCheck, PiGear } from 'react-icons/pi';
|
||||
import { PiSun, PiMoon } from 'react-icons/pi';
|
||||
import { TbSunMoon } from 'react-icons/tb';
|
||||
import { MdCloudSync } from 'react-icons/md';
|
||||
|
||||
import { invoke, PermissionState } from '@tauri-apps/api/core';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
@@ -15,6 +16,7 @@ import { useQuotaStats } from '@/hooks/useQuotaStats';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useTransferQueue } from '@/hooks/useTransferQueue';
|
||||
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
|
||||
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
|
||||
import { optInTelemetry, optOutTelemetry } from '@/utils/telemetry';
|
||||
@@ -61,6 +63,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ 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<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
toggled={isAutoUpload}
|
||||
onClick={toggleAutoUploadBooks}
|
||||
/>
|
||||
<MenuItem
|
||||
label={_('File Transfers')}
|
||||
Icon={MdCloudSync}
|
||||
description={
|
||||
hasActiveTransfers
|
||||
? _('{{activeCount}} active, {{pendingCount}} pending', {
|
||||
activeCount: stats.active,
|
||||
pendingCount: stats.pending,
|
||||
})
|
||||
: stats.failed > 0
|
||||
? _('{{failedCount}} failed', { failedCount: stats.failed })
|
||||
: ''
|
||||
}
|
||||
onClick={openTransferQueue}
|
||||
/>
|
||||
{isTauriAppPlatform() && !appService?.isMobile && (
|
||||
<MenuItem
|
||||
label={_('Auto Import on File Open')}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
MdClose,
|
||||
MdRefresh,
|
||||
MdPause,
|
||||
MdPlayArrow,
|
||||
MdCloudUpload,
|
||||
MdCloudDownload,
|
||||
MdCheckCircle,
|
||||
MdError,
|
||||
MdCancel,
|
||||
MdDeleteSweep,
|
||||
} from 'react-icons/md';
|
||||
import { useTransferQueue } from '@/hooks/useTransferQueue';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { TransferItem, TransferStatus, useTransferStore } from '@/store/transferStore';
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
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 <MdCheckCircle className='text-success' size={size} />;
|
||||
case 'failed':
|
||||
return <MdError className='text-error' size={size} />;
|
||||
case 'cancelled':
|
||||
return <MdCancel className='text-warning' size={size} />;
|
||||
case 'in_progress':
|
||||
case 'pending':
|
||||
default:
|
||||
return type === 'upload' ? (
|
||||
<MdCloudUpload className='text-primary' size={size} />
|
||||
) : (
|
||||
<MdCloudDownload className='text-primary' size={size} />
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const TransferItemRow: React.FC<{
|
||||
transfer: TransferItem;
|
||||
onCancel: (id: string) => void;
|
||||
onRetry: (id: string) => void;
|
||||
iconSize: number;
|
||||
}> = ({ transfer, onCancel, onRetry, iconSize }) => {
|
||||
const _ = useTranslation();
|
||||
|
||||
return (
|
||||
<div className='hover:bg-base-200 flex items-center gap-3 rounded-lg p-3'>
|
||||
<StatusIcon status={transfer.status} type={transfer.type} size={iconSize} />
|
||||
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium'>{transfer.bookTitle}</div>
|
||||
<div className='text-base-content/60 text-xs'>
|
||||
{transfer.status === 'in_progress' && (
|
||||
<>
|
||||
{Math.round(transfer.progress)}% - {formatSpeed(transfer.transferSpeed)}
|
||||
</>
|
||||
)}
|
||||
{transfer.status === 'pending' && transfer.error && (
|
||||
<span className='text-warning'>{transfer.error}</span>
|
||||
)}
|
||||
{transfer.status === 'pending' && !transfer.error && _('Waiting...')}
|
||||
{transfer.status === 'failed' && (
|
||||
<span className='text-error'>{transfer.error || _('Failed')}</span>
|
||||
)}
|
||||
{transfer.status === 'completed' && _('Completed')}
|
||||
{transfer.status === 'cancelled' && _('Cancelled')}
|
||||
{' · '}
|
||||
{formatDateTime(transfer.completedAt || transfer.startedAt || transfer.createdAt)}
|
||||
</div>
|
||||
|
||||
{transfer.status === 'in_progress' && (
|
||||
<div className='bg-base-300 mt-1 h-1.5 w-full overflow-hidden rounded-full'>
|
||||
<div
|
||||
className='bg-primary h-full transition-all'
|
||||
style={{ width: `${transfer.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-1'>
|
||||
{(transfer.status === 'failed' || transfer.status === 'cancelled') && (
|
||||
<button
|
||||
onClick={() => onRetry(transfer.id)}
|
||||
className='btn btn-ghost btn-sm btn-circle'
|
||||
aria-label={_('Retry')}
|
||||
>
|
||||
<MdRefresh size={iconSize} />
|
||||
</button>
|
||||
)}
|
||||
{['pending', 'in_progress'].includes(transfer.status) && (
|
||||
<button
|
||||
onClick={() => onCancel(transfer.id)}
|
||||
className='btn btn-ghost btn-sm btn-circle'
|
||||
aria-label={_('Cancel')}
|
||||
>
|
||||
<MdClose size={iconSize} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<FilterType>('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<TransferStatus, number> = {
|
||||
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<FilterType, string> = {
|
||||
all: _('All'),
|
||||
active: _('Active'),
|
||||
completed: _('Completed'),
|
||||
failed: _('Failed'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div className='absolute inset-0 bg-black/50' onClick={onClose} />
|
||||
<div
|
||||
ref={divRef}
|
||||
className={clsx(
|
||||
'modal-box bg-base-100 relative flex max-h-[85%] min-h-[65%] w-[95%] flex-col rounded-2xl p-0 shadow-xl',
|
||||
'min-w-64 max-w-lg overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className='border-base-300 flex items-center justify-between border-b p-4'>
|
||||
<h2 className='text-lg font-semibold'>{_('Transfer Queue')}</h2>
|
||||
<div className='flex items-center gap-2'>
|
||||
{booksToUpload.length > 0 && (
|
||||
<button
|
||||
onClick={handleUploadAll}
|
||||
className='btn btn-ghost btn-sm gap-1'
|
||||
title={_('Upload All')}
|
||||
aria-label={_('Upload All')}
|
||||
>
|
||||
<MdCloudUpload size={iconSize} />
|
||||
<span className='text-xs'>{booksToUpload.length}</span>
|
||||
</button>
|
||||
)}
|
||||
{booksToDownload.length > 0 && (
|
||||
<button
|
||||
onClick={handleDownloadAll}
|
||||
className='btn btn-ghost btn-sm gap-1'
|
||||
title={_('Download All')}
|
||||
aria-label={_('Download All')}
|
||||
>
|
||||
<MdCloudDownload size={iconSize} />
|
||||
<span className='text-xs'>{booksToDownload.length}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={isQueuePaused ? resumeQueue : pauseQueue}
|
||||
className='btn btn-ghost btn-sm btn-circle'
|
||||
title={isQueuePaused ? _('Resume Transfers') : _('Pause Transfers')}
|
||||
aria-label={isQueuePaused ? _('Resume Transfers') : _('Pause Transfers')}
|
||||
>
|
||||
{isQueuePaused ? <MdPlayArrow size={iconSize} /> : <MdPause size={iconSize} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='btn btn-ghost btn-sm btn-circle'
|
||||
title={_('Close')}
|
||||
aria-label={_('Close')}
|
||||
>
|
||||
<MdClose size={iconSize} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className='bg-base-200 flex items-center gap-4 px-4 py-2 text-sm'>
|
||||
<span>
|
||||
{_('Active')}: {stats.active}
|
||||
</span>
|
||||
<span>
|
||||
{_('Pending')}: {stats.pending}
|
||||
</span>
|
||||
<span>
|
||||
{_('Completed')}: {stats.completed}
|
||||
</span>
|
||||
<span>
|
||||
{_('Failed')}: {stats.failed}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className='border-base-300 flex gap-2 border-b p-4'>
|
||||
{(['all', 'active', 'completed', 'failed'] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={clsx(
|
||||
'rounded-lg px-3 py-1 text-sm transition-colors',
|
||||
filter === f ? 'bg-primary text-primary-content' : 'bg-base-200 hover:bg-base-300',
|
||||
)}
|
||||
>
|
||||
{filterLabels[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Transfer list */}
|
||||
<div className='flex-1 overflow-y-auto p-2'>
|
||||
{filteredTransfers.length === 0 ? (
|
||||
<div className='text-base-content/60 py-8 text-center'>{_('No transfers')}</div>
|
||||
) : (
|
||||
filteredTransfers.map((transfer) => (
|
||||
<TransferItemRow
|
||||
key={transfer.id}
|
||||
transfer={transfer}
|
||||
onCancel={cancelTransfer}
|
||||
onRetry={retryTransfer}
|
||||
iconSize={iconSize}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className='border-base-300 flex items-center justify-between border-t p-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{stats.failed > 0 && (
|
||||
<button onClick={retryAllFailed} className='btn btn-ghost btn-sm gap-1'>
|
||||
<MdRefresh size={iconSize - 2} />
|
||||
{_('Retry All')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
{stats.completed > 0 && (
|
||||
<button onClick={clearCompleted} className='btn btn-ghost btn-sm gap-1'>
|
||||
<MdDeleteSweep size={iconSize - 2} />
|
||||
{_('Clear Completed')}
|
||||
</button>
|
||||
)}
|
||||
{stats.failed > 0 && (
|
||||
<button onClick={clearFailed} className='btn btn-ghost btn-sm gap-1'>
|
||||
<MdDeleteSweep size={iconSize - 2} />
|
||||
{_('Clear Failed')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransferQueuePanel;
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, TransferItem>;
|
||||
isQueuePaused: boolean;
|
||||
}
|
||||
|
||||
class TransferManager {
|
||||
private static instance: TransferManager;
|
||||
private appService: AppService | null = null;
|
||||
private isProcessing = false;
|
||||
private abortControllers: Map<string, AbortController> = new Map();
|
||||
private isInitialized = false;
|
||||
private getLibrary: (() => Book[]) | null = null;
|
||||
private updateBook: ((book: Book) => Promise<void>) | 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<void>,
|
||||
translationFn: TranslationFunc,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
if (this.isProcessing) return;
|
||||
|
||||
this.isProcessing = true;
|
||||
|
||||
try {
|
||||
await this._processQueueInternal();
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _processQueueInternal(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
@@ -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<string, TransferItem>;
|
||||
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<string, TransferItem>, isQueuePaused: boolean) => void;
|
||||
}
|
||||
|
||||
const generateTransferId = (): string => {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
};
|
||||
|
||||
export const useTransferStore = create<TransferState>((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<TransferItem> = { 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<string, TransferItem> = {};
|
||||
Object.entries(state.transfers).forEach(([id, transfer]) => {
|
||||
if (transfer.status !== 'completed') {
|
||||
remaining[id] = transfer;
|
||||
}
|
||||
});
|
||||
return { transfers: remaining };
|
||||
});
|
||||
},
|
||||
|
||||
clearFailed: () => {
|
||||
set((state) => {
|
||||
const remaining: Record<string, TransferItem> = {};
|
||||
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<string, TransferItem> = {};
|
||||
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 });
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user