feat(transfer): add a background transfer queue system for book uploads/downloads (#2821)

This commit is contained in:
Huang Xin
2025-12-31 08:40:28 +01:00
committed by GitHub
parent 22f9c45232
commit 71e97998b6
35 changed files with 1888 additions and 89 deletions
@@ -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;
+44 -59
View File
@@ -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();
+295
View File
@@ -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 });
},
}));