feat(opds): support downloading ebooks from OPDS catalogs (#2571)

This commit is contained in:
Huang Xin
2025-12-01 04:22:54 +08:00
committed by GitHub
parent 97f021da87
commit 2ca3561093
74 changed files with 4450 additions and 181 deletions
+15 -1
View File
@@ -1,11 +1,25 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import next from 'eslint-config-next';
import nextVitals from 'eslint-config-next/core-web-vitals';
import tseslint from 'eslint-config-next/typescript';
import jsxA11y from 'eslint-plugin-jsx-a11y';
const eslintConfig = defineConfig([
...tseslint,
...next,
...nextVitals,
{
rules: jsxA11y.configs.recommended.rules,
rules: {
...jsxA11y.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
globalIgnores([
'node_modules/**',
+2
View File
@@ -130,6 +130,8 @@
"@types/semver": "^7.7.0",
"@types/tinycolor2": "^1.4.6",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.48.0",
"@typescript-eslint/parser": "^8.48.0",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20",
"caniuse-lite": "^1.0.30001746",
@@ -691,5 +691,54 @@
"Download from Cloud": "تحميل من السحابة",
"Upload to Cloud": "رفع إلى السحابة",
"Clear Custom Fonts": "مسح الخطوط المخصصة",
"Columns": "الأعمدة"
"Columns": "الأعمدة",
"OPDS Catalogs": "كتالوجات OPDS",
"Adding LAN addresses is not supported in the web app version.": "لا يتم دعم إضافة عناوين LAN في إصدار تطبيق الويب.",
"Invalid OPDS catalog. Please check the URL.": "كتالوج OPDS غير صالح. يرجى التحقق من عنوان URL.",
"Browse and download books from online catalogs": "تصفح وتنزيل الكتب من الكتالوجات عبر الإنترنت",
"My Catalogs": "كتالوجاتي",
"Add Catalog": "إضافة كتالوج",
"No catalogs yet": "لا توجد كتالوجات بعد",
"Add your first OPDS catalog to start browsing books": "أضف كتالوج OPDS الأول لتبدأ في تصفح الكتب",
"Add Your First Catalog": "أضف كتالوجك الأول",
"Browse": "تصفح",
"Popular Catalogs": "الكتالوجات الشائعة",
"Add": "إضافة",
"Add OPDS Catalog": "إضافة كتالوج OPDS",
"Catalog Name": "اسم الكتالوج",
"My Calibre Library": "مكتبة Calibre الخاصة بي",
"OPDS URL": "رابط OPDS",
"Username (optional)": "اسم المستخدم (اختياري)",
"Password (optional)": "كلمة المرور (اختياري)",
"Description (optional)": "الوصف (اختياري)",
"A brief description of this catalog": "وصف موجز لهذا الكتالوج",
"Validating...": "جارٍ التحقق...",
"View All": "عرض الكل",
"Forward": "إلى الأمام",
"OPDS Catalog": "كتالوج OPDS",
"Home": "الصفحة الرئيسية",
"Library": "المكتبة",
"{{count}} items_zero": "{{count}} عناصر",
"{{count}} items_one": "{{count}} عنصر",
"{{count}} items_two": "{{count}} عنصران",
"{{count}} items_few": "{{count}} عناصر",
"{{count}} items_many": "{{count}} عنصرًا",
"{{count}} items_other": "{{count}} من العناصر",
"Download completed": "اكتمل التنزيل",
"Download failed": "فشل التنزيل",
"Open Access": "الوصول المفتوح",
"Borrow": "استعارة",
"Buy": "شراء",
"Subscribe": "الاشتراك",
"Sample": "عينة",
"Download": "تنزيل",
"Open & Read": "فتح وقراءة",
"Tags": "العلامات",
"Tag": "علامة",
"First": "الأول",
"Previous": "السابق",
"Next": "التالي",
"Last": "الأخير",
"Cannot Load Page": "تعذر تحميل الصفحة",
"An error occurred": "حدث خطأ ما"
}
@@ -675,5 +675,50 @@
"Download from Cloud": "ক্লাউড থেকে ডাউনলোড করুন",
"Upload to Cloud": "ক্লাউডে আপলোড করুন",
"Clear Custom Fonts": "কাস্টম ফন্টস মুছুন",
"Columns": "কলামস"
"Columns": "কলামস",
"OPDS Catalogs": "OPDS ক্যাটালগস",
"Adding LAN addresses is not supported in the web app version.": "ওয়েব অ্যাপ সংস্করণে LAN ঠিকানা যোগ করা সমর্থিত নয়।",
"Invalid OPDS catalog. Please check the URL.": "অবৈধ OPDS ক্যাটালগ। দয়া করে URL চেক করুন।",
"Browse and download books from online catalogs": "অনলাইন ক্যাটালগ থেকে বই ব্রাউজ এবং ডাউনলোড করুন",
"My Catalogs": "আমার ক্যাটালগস",
"Add Catalog": "ক্যাটালগ যোগ করুন",
"No catalogs yet": "এখনও কোন ক্যাটালগ নেই",
"Add your first OPDS catalog to start browsing books": "আপনার প্রথম OPDS ক্যাটালগ যোগ করুন বই ব্রাউজ শুরু করতে",
"Add Your First Catalog": "আপনার প্রথম ক্যাটালগ যোগ করুন",
"Browse": "ব্রাউজ করুন",
"Popular Catalogs": "জনপ্রিয় ক্যাটালগস",
"Add": "যোগ করুন",
"Add OPDS Catalog": "OPDS ক্যাটালগ যোগ করুন",
"Catalog Name": "ক্যাটালগের নাম",
"My Calibre Library": "আমার ক্যালিব্র লাইব্রেরি",
"OPDS URL": "OPDS URL",
"Username (optional)": "ব্যবহারকারীর নাম (ঐচ্ছিক)",
"Password (optional)": "পাসওয়ার্ড (ঐচ্ছিক)",
"Description (optional)": "বর্ণনা (ঐচ্ছিক)",
"A brief description of this catalog": "এই ক্যাটালগের সংক্ষিপ্ত বিবরণ",
"Validating...": "যাচাই করা হচ্ছে...",
"View All": "সব দেখুন",
"Forward": "ফরোয়ার্ড",
"OPDS Catalog": "OPDS ক্যাটালগ",
"Home": "হোম",
"Library": "লাইব্রেরি",
"{{count}} items_one": "{{count}} আইটেম",
"{{count}} items_other": "{{count}} আইটেম",
"Download completed": "ডাউনলোড সম্পন্ন হয়েছে",
"Download failed": "ডাউনলোড ব্যর্থ হয়েছে",
"Open Access": "ওপেন অ্যাক্সেস",
"Borrow": "ঋণ নিন",
"Buy": "কিনুন",
"Subscribe": "সাবস্ক্রাইব করুন",
"Sample": "নমুনা",
"Download": "ডাউনলোড",
"Open & Read": "খুলুন ও পড়ুন",
"Tags": "ট্যাগসমূহ",
"Tag": "ট্যাগ",
"First": "প্রথম",
"Previous": "পূর্ববর্তী",
"Next": "পরবর্তী",
"Last": "শেষ",
"Cannot Load Page": "পৃষ্ঠা লোড করা যায়নি",
"An error occurred": "একটি ত্রুটি ঘটেছে"
}
@@ -671,5 +671,49 @@
"Download from Cloud": "སྤྲིན་ནས་ཕབ་སྟེ་འབེབས་",
"Upload to Cloud": "སྤྲིན་ལ་ཡར་སྤེལ་",
"Clear Custom Fonts": "རང་བཞིན་ཡིག་གཟུགས་སུབ་པ།",
"Columns": "མཚན་ཉིད།"
"Columns": "མཚན་ཉིད།",
"OPDS Catalogs": "OPDS གནས་ཚུལ།",
"Adding LAN addresses is not supported in the web app version.": "གནས་ཚུལ་དང་ལུས་སྐོར་གྱི་གྲོང་ཁྱེར་གྱི་གློག་འཕྲིན་ལས་མཐུན་པ་མེད།",
"Invalid OPDS catalog. Please check the URL.": "མི་འབྱུང་བའི OPDS གནས་ཚུལ། ཨེ་ཨོ་ཨེར་ལུས་སྐོར་བཟོ་བྱས།",
"Browse and download books from online catalogs": "དེབ་གནས་བཅས་པའི་གྲོང་ཁྱེར་ལས་དེབ་འདེམས་དང་ཕབ་སྟེ་འབེབས།",
"My Catalogs": "ངའི་དཀར་ཆག",
"Add Catalog": "དཀར་ཆག་ཁ་སྣོན་",
"No catalogs yet": "དཀར་ཆག་མེད་པས།",
"Add your first OPDS catalog to start browsing books": "དེབ་ལྟ་བཤེར་འགོ་བཙུགས་ནས་ དང་པོའི་ OPDS དཀར་ཆག་ཁ་སྣོན་གནང་།",
"Add Your First Catalog": "དཀར་ཆག་དང་པོ་ཁ་སྣོན་",
"Browse": "ལྟ་བཤེར་",
"Popular Catalogs": "མི་མང་མཐོང་བའི་དཀར་ཆག",
"Add": "ཁ་སྣོན་",
"Add OPDS Catalog": "OPDS དཀར་ཆག་ཁ་སྣོན་",
"Catalog Name": "དཀར་ཆག་མིང་",
"My Calibre Library": "ངའི Calibre དེབ་མཛོད་",
"OPDS URL": "OPDS URL",
"Username (optional)": "མིང་སྒྲོམ་ (འདེམས་རུང་)",
"Password (optional)": "གསང་ཚིག (འདེམས་རུང་)",
"Description (optional)": "འགྲེལ་བཤད་ (འདེམས་རུང་)",
"A brief description of this catalog": "དཀར་ཆག་འདིའི་གསལ་བཤད་ཐུང་ཐུང་",
"Validating...": "བདེན་སྦྱོར་བཞིན་...",
"View All": "ཡོངས་ལྟ་བ་",
"Forward": "མདུན་དུ་",
"OPDS Catalog": "OPDS དཀར་ཆག",
"Home": "གཙོ་ངོས་",
"Library": "དེབ་མཛོད་",
"{{count}} items_other": "{{count}} རྣམ་གྲངས་",
"Download completed": "ཕབ་ལེན་རྫོགས་སོང་།",
"Download failed": "ཕབ་ལེན་ཕམ་པ་",
"Open Access": "ཁ་ཕྱབ་ལྟ་བཤེར་",
"Borrow": "ལེན་པ་",
"Buy": "ཉོ་",
"Subscribe": "མཁོ་མངགས་",
"Sample": "དཔེ་དབང་",
"Download": "ཕབ་ལེན་",
"Open & Read": "ཁ་ཕྱེས་ནས་ཀློག་",
"Tags": "མཚོན་འགྲེལ་",
"Tag": "མཚོན་འགྲེལ་",
"First": "དང་པོ།",
"Previous": "སྔོན་མ།",
"Next": "རྗེས་མ།",
"Last": "མཐའ་མ།",
"Cannot Load Page": "ཤོག་ངོས་འགུལ་སྐྱོང་བྱེད་ཐུབ་མེད།",
"An error occurred": "ནོར་འཁྲུལ་ཞིག་བྱུང་སོང་།"
}
@@ -675,5 +675,50 @@
"Download from Cloud": "Aus der Cloud herunterladen",
"Upload to Cloud": "In die Cloud hochladen",
"Clear Custom Fonts": "Benutzerdefinierte Schriftarten löschen",
"Columns": "Spalten"
"Columns": "Spalten",
"OPDS Catalogs": "OPDS-Kataloge",
"Adding LAN addresses is not supported in the web app version.": "Das Hinzufügen von LAN-Adressen wird in der Web-App nicht unterstützt.",
"Invalid OPDS catalog. Please check the URL.": "Ungültiger OPDS-Katalog. Bitte überprüfe die URL.",
"Browse and download books from online catalogs": "Bücher aus Online-Katalogen durchsuchen und herunterladen",
"My Catalogs": "Meine Kataloge",
"Add Catalog": "Katalog hinzufügen",
"No catalogs yet": "Noch keine Kataloge",
"Add your first OPDS catalog to start browsing books": "Füge deinen ersten OPDS-Katalog hinzu, um Bücher zu durchsuchen.",
"Add Your First Catalog": "Ersten Katalog hinzufügen",
"Browse": "Durchsuchen",
"Popular Catalogs": "Beliebte Kataloge",
"Add": "Hinzufügen",
"Add OPDS Catalog": "OPDS-Katalog hinzufügen",
"Catalog Name": "Katalogname",
"My Calibre Library": "Meine Calibre-Bibliothek",
"OPDS URL": "OPDS-URL",
"Username (optional)": "Benutzername (optional)",
"Password (optional)": "Passwort (optional)",
"Description (optional)": "Beschreibung (optional)",
"A brief description of this catalog": "Kurzbeschreibung dieses Katalogs",
"Validating...": "Wird überprüft...",
"View All": "Alle anzeigen",
"Forward": "Weiter",
"OPDS Catalog": "OPDS-Katalog",
"Home": "Start",
"Library": "Bibliothek",
"{{count}} items_one": "{{count}} Element",
"{{count}} items_other": "{{count}} Elemente",
"Download completed": "Download abgeschlossen",
"Download failed": "Download fehlgeschlagen",
"Open Access": "Offener Zugriff",
"Borrow": "Ausleihen",
"Buy": "Kaufen",
"Subscribe": "Abonnieren",
"Sample": "Leseprobe",
"Download": "Herunterladen",
"Open & Read": "Öffnen & Lesen",
"Tags": "Tags",
"Tag": "Tag",
"First": "Erste",
"Previous": "Vorherige",
"Next": "Nächste",
"Last": "Letzte",
"Cannot Load Page": "Seite kann nicht geladen werden",
"An error occurred": "Ein Fehler ist aufgetreten"
}
@@ -675,5 +675,50 @@
"Download from Cloud": "Λήψη από το Cloud",
"Upload to Cloud": "Ανέβασμα στο Cloud",
"Clear Custom Fonts": "Καθαρισμός προσαρμοσμένων γραμματοσειρών",
"Columns": "Στήλες"
"Columns": "Στήλες",
"OPDS Catalogs": "Κατάλογοι OPDS",
"Adding LAN addresses is not supported in the web app version.": "Η προσθήκη διευθύνσεων LAN δεν υποστηρίζεται στην έκδοση web της εφαρμογής.",
"Invalid OPDS catalog. Please check the URL.": "Μη έγκυρος κατάλογος OPDS. Ελέγξτε το URL.",
"Browse and download books from online catalogs": "Περιηγηθείτε και κατεβάστε βιβλία από online καταλόγους",
"My Catalogs": "Οι Κατάλογοί μου",
"Add Catalog": "Προσθήκη Καταλόγου",
"No catalogs yet": "Δεν υπάρχουν ακόμα κατάλογοι",
"Add your first OPDS catalog to start browsing books": "Προσθέστε τον πρώτο σας κατάλογο OPDS για να αρχίσετε την περιήγηση.",
"Add Your First Catalog": "Προσθήκη Πρώτου Καταλόγου",
"Browse": "Περιήγηση",
"Popular Catalogs": "Δημοφιλείς Κατάλογοι",
"Add": "Προσθήκη",
"Add OPDS Catalog": "Προσθήκη Καταλόγου OPDS",
"Catalog Name": "Όνομα Καταλόγου",
"My Calibre Library": "Η Βιβλιοθήκη μου στο Calibre",
"OPDS URL": "OPDS URL",
"Username (optional)": "Όνομα χρήστη (προαιρετικό)",
"Password (optional)": "Κωδικός πρόσβασης (προαιρετικό)",
"Description (optional)": "Περιγραφή (προαιρετική)",
"A brief description of this catalog": "Μια σύντομη περιγραφή του καταλόγου",
"Validating...": "Γίνεται έλεγχος...",
"View All": "Προβολή όλων",
"Forward": "Μπροστά",
"OPDS Catalog": "Κατάλογος OPDS",
"Home": "Αρχική",
"Library": "Βιβλιοθήκη",
"{{count}} items_one": "{{count}} στοιχείο",
"{{count}} items_other": "{{count}} στοιχεία",
"Download completed": "Η λήψη ολοκληρώθηκε",
"Download failed": "Η λήψη απέτυχε",
"Open Access": "Ανοιχτή πρόσβαση",
"Borrow": "Δανεισμός",
"Buy": "Αγορά",
"Subscribe": "Συνδρομή",
"Sample": "Δείγμα",
"Download": "Λήψη",
"Open & Read": "Άνοιγμα & Ανάγνωση",
"Tags": "Ετικέτες",
"Tag": "Ετικέτα",
"First": "Πρώτο",
"Previous": "Προηγούμενο",
"Next": "Επόμενο",
"Last": "Τελευταίο",
"Cannot Load Page": "Δεν είναι δυνατή η φόρτωση της σελίδας",
"An error occurred": "Προέκυψε σφάλμα"
}
@@ -679,5 +679,51 @@
"Download from Cloud": "Descargar desde la nube",
"Upload to Cloud": "Subir a la nube",
"Clear Custom Fonts": "Limpiar fuentes personalizadas",
"Columns": "Columnas"
"Columns": "Columnas",
"OPDS Catalogs": "Catálogos OPDS",
"Adding LAN addresses is not supported in the web app version.": "La versión web no permite añadir direcciones LAN.",
"Invalid OPDS catalog. Please check the URL.": "Catálogo OPDS no válido. Por favor, comprueba la URL.",
"Browse and download books from online catalogs": "Explora y descarga libros de catálogos en línea",
"My Catalogs": "Mis catálogos",
"Add Catalog": "Añadir catálogo",
"No catalogs yet": "Aún no hay catálogos",
"Add your first OPDS catalog to start browsing books": "Añade tu primer catálogo OPDS para empezar a explorar libros.",
"Add Your First Catalog": "Añadir primer catálogo",
"Browse": "Explorar",
"Popular Catalogs": "Catálogos populares",
"Add": "Añadir",
"Add OPDS Catalog": "Añadir catálogo OPDS",
"Catalog Name": "Nombre del catálogo",
"My Calibre Library": "Mi biblioteca Calibre",
"OPDS URL": "URL OPDS",
"Username (optional)": "Usuario (opcional)",
"Password (optional)": "Contraseña (opcional)",
"Description (optional)": "Descripción (opcional)",
"A brief description of this catalog": "Una breve descripción de este catálogo",
"Validating...": "Validando...",
"View All": "Ver todo",
"Forward": "Adelante",
"OPDS Catalog": "Catálogo OPDS",
"Home": "Inicio",
"Library": "Biblioteca",
"{{count}} items_one": "{{count}} elemento",
"{{count}} items_many": "{{count}} elementos",
"{{count}} items_other": "{{count}} elementos",
"Download completed": "Descarga completa",
"Download failed": "Error en la descarga",
"Open Access": "Acceso abierto",
"Borrow": "Prestar",
"Buy": "Comprar",
"Subscribe": "Suscribirse",
"Sample": "Muestra",
"Download": "Descargar",
"Open & Read": "Abrir y leer",
"Tags": "Etiquetas",
"Tag": "Etiqueta",
"First": "Primero",
"Previous": "Anterior",
"Next": "Siguiente",
"Last": "Último",
"Cannot Load Page": "No se puede cargar la página",
"An error occurred": "Ocurrió un error"
}
@@ -675,5 +675,50 @@
"Download from Cloud": "دانلود از فضای ابری",
"Upload to Cloud": "آپلود به فضای ابری",
"Clear Custom Fonts": "پاک‌کردن فونت‌های سفارشی",
"Columns": "ستون‌ها"
"Columns": "ستون‌ها",
"OPDS Catalogs": "فهرست‌های OPDS",
"Adding LAN addresses is not supported in the web app version.": "افزودن آدرس‌های LAN در نسخه وب پشتیبانی نمی‌شود.",
"Invalid OPDS catalog. Please check the URL.": "فهرست OPDS نامعتبر است. لطفاً آدرس را بررسی کنید.",
"Browse and download books from online catalogs": "مرور و دانلود کتاب‌ها از فهرست‌های آنلاین",
"My Catalogs": "فهرست‌های من",
"Add Catalog": "افزودن فهرست",
"No catalogs yet": "هنوز فهرستی وجود ندارد",
"Add your first OPDS catalog to start browsing books": "برای شروع مرور کتاب‌ها، اولین فهرست OPDS خود را اضافه کنید.",
"Add Your First Catalog": "افزودن اولین فهرست",
"Browse": "مرور",
"Popular Catalogs": "فهرست‌های محبوب",
"Add": "افزودن",
"Add OPDS Catalog": "افزودن فهرست OPDS",
"Catalog Name": "نام فهرست",
"My Calibre Library": "کتابخانه Calibre من",
"OPDS URL": "آدرس OPDS",
"Username (optional)": "نام کاربری (اختیاری)",
"Password (optional)": "رمز عبور (اختیاری)",
"Description (optional)": "توضیحات (اختیاری)",
"A brief description of this catalog": "توضیح کوتاهی درباره این فهرست",
"Validating...": "در حال بررسی...",
"View All": "مشاهده همه",
"Forward": "بعدی",
"OPDS Catalog": "فهرست OPDS",
"Home": "خانه",
"Library": "کتابخانه",
"{{count}} items_one": "{{count}} مورد",
"{{count}} items_other": "{{count}} مورد",
"Download completed": "دانلود کامل شد",
"Download failed": "دانلود ناموفق بود",
"Open Access": "دسترسی آزاد",
"Borrow": "امانت گرفتن",
"Buy": "خرید",
"Subscribe": "اشتراک",
"Sample": "نمونه",
"Download": "دانلود",
"Open & Read": "باز کردن و خواندن",
"Tags": "برچسب‌ها",
"Tag": "برچسب",
"First": "اولین",
"Previous": "قبلی",
"Next": "بعدی",
"Last": "آخرین",
"Cannot Load Page": "بارگذاری صفحه امکان‌پذیر نیست",
"An error occurred": "خطایی رخ داد"
}
@@ -679,5 +679,51 @@
"Download from Cloud": "Télécharger depuis le Cloud",
"Upload to Cloud": "Téléverser vers le Cloud",
"Clear Custom Fonts": "Effacer les Polices Personnalisées",
"Columns": "Colonnes"
"Columns": "Colonnes",
"OPDS Catalogs": "Catalogues OPDS",
"Adding LAN addresses is not supported in the web app version.": "Lajout dadresses LAN nest pas pris en charge dans la version web.",
"Invalid OPDS catalog. Please check the URL.": "Catalogue OPDS invalide. Veuillez vérifier lURL.",
"Browse and download books from online catalogs": "Parcourez et téléchargez des livres depuis des catalogues en ligne",
"My Catalogs": "Mes catalogues",
"Add Catalog": "Ajouter un catalogue",
"No catalogs yet": "Aucun catalogue pour le moment",
"Add your first OPDS catalog to start browsing books": "Ajoutez votre premier catalogue OPDS pour commencer à parcourir des livres.",
"Add Your First Catalog": "Ajouter votre premier catalogue",
"Browse": "Parcourir",
"Popular Catalogs": "Catalogues populaires",
"Add": "Ajouter",
"Add OPDS Catalog": "Ajouter un catalogue OPDS",
"Catalog Name": "Nom du catalogue",
"My Calibre Library": "Ma bibliothèque Calibre",
"OPDS URL": "URL OPDS",
"Username (optional)": "Nom dutilisateur (optionnel)",
"Password (optional)": "Mot de passe (optionnel)",
"Description (optional)": "Description (optionnelle)",
"A brief description of this catalog": "Brève description de ce catalogue",
"Validating...": "Validation...",
"View All": "Tout afficher",
"Forward": "Suivant",
"OPDS Catalog": "Catalogue OPDS",
"Home": "Accueil",
"Library": "Bibliothèque",
"{{count}} items_one": "{{count}} élément",
"{{count}} items_many": "{{count}} éléments",
"{{count}} items_other": "{{count}} éléments",
"Download completed": "Téléchargement terminé",
"Download failed": "Échec du téléchargement",
"Open Access": "Accès libre",
"Borrow": "Emprunter",
"Buy": "Acheter",
"Subscribe": "Sabonner",
"Sample": "Extrait",
"Download": "Télécharger",
"Open & Read": "Ouvrir et lire",
"Tags": "Tags",
"Tag": "Tag",
"First": "Premier",
"Previous": "Précédent",
"Next": "Suivant",
"Last": "Dernier",
"Cannot Load Page": "Impossible de charger la page",
"An error occurred": "Une erreur est survenue"
}
@@ -675,5 +675,50 @@
"Download from Cloud": "क्लाउड से डाउनलोड करें",
"Upload to Cloud": "क्लाउड पर अपलोड करें",
"Clear Custom Fonts": "कस्टम फ़ॉन्ट साफ़ करें",
"Columns": "कॉलम"
"Columns": "कॉलम",
"OPDS Catalogs": "OPDS कैटलॉग",
"Adding LAN addresses is not supported in the web app version.": "वेब ऐप संस्करण में LAN पता जोड़ना समर्थित नहीं है।",
"Invalid OPDS catalog. Please check the URL.": "अमान्य OPDS कैटलॉग। कृपया URL जांचें।",
"Browse and download books from online catalogs": "ऑनलाइन कैटलॉग से किताबें ब्राउज़ और डाउनलोड करें",
"My Catalogs": "मेरे कैटलॉग",
"Add Catalog": "कैटलॉग जोड़ें",
"No catalogs yet": "अभी कोई कैटलॉग नहीं",
"Add your first OPDS catalog to start browsing books": "किताबें ब्राउज़ करने के लिए अपना पहला OPDS कैटलॉग जोड़ें",
"Add Your First Catalog": "अपना पहला कैटलॉग जोड़ें",
"Browse": "ब्राउज़",
"Popular Catalogs": "लोकप्रिय कैटलॉग",
"Add": "जोड़ें",
"Add OPDS Catalog": "OPDS कैटलॉग जोड़ें",
"Catalog Name": "कैटलॉग नाम",
"My Calibre Library": "मेरी Calibre लाइब्रेरी",
"OPDS URL": "OPDS URL",
"Username (optional)": "यूज़रनेम (वैकल्पिक)",
"Password (optional)": "पासवर्ड (वैकल्पिक)",
"Description (optional)": "विवरण (वैकल्पिक)",
"A brief description of this catalog": "इस कैटलॉग का संक्षिप्त विवरण",
"Validating...": "मान्य किया जा रहा है...",
"View All": "सभी देखें",
"Forward": "आगे",
"OPDS Catalog": "OPDS कैटलॉग",
"Home": "होम",
"Library": "लाइब्रेरी",
"{{count}} items_one": "{{count}} आइटम",
"{{count}} items_other": "{{count}} आइटम",
"Download completed": "डाउनलोड पूरा हुआ",
"Download failed": "डाउनलोड विफल हुआ",
"Open Access": "ओपन एक्सेस",
"Borrow": "उधार लें",
"Buy": "खरीदें",
"Subscribe": "सदस्यता लें",
"Sample": "नमूना",
"Download": "डाउनलोड",
"Open & Read": "खोलें और पढ़ें",
"Tags": "टैग",
"Tag": "टैग",
"First": "पहला",
"Previous": "पिछला",
"Next": "अगला",
"Last": "अंतिम",
"Cannot Load Page": "पृष्ठ लोड नहीं किया जा सका",
"An error occurred": "एक त्रुटि हुई"
}
@@ -671,5 +671,49 @@
"Download from Cloud": "Unduh dari Cloud",
"Upload to Cloud": "Unggah ke Cloud",
"Clear Custom Fonts": "Bersihkan Font Kustom",
"Columns": "Kolom"
"Columns": "Kolom",
"OPDS Catalogs": "Katalog OPDS",
"Adding LAN addresses is not supported in the web app version.": "Menambahkan alamat LAN tidak didukung di versi web.",
"Invalid OPDS catalog. Please check the URL.": "Katalog OPDS tidak valid. Silakan periksa URL-nya.",
"Browse and download books from online catalogs": "Jelajahi dan unduh buku dari katalog online",
"My Catalogs": "Katalog Saya",
"Add Catalog": "Tambah Katalog",
"No catalogs yet": "Belum ada katalog",
"Add your first OPDS catalog to start browsing books": "Tambahkan katalog OPDS pertama Anda untuk mulai menjelajahi buku",
"Add Your First Catalog": "Tambah Katalog Pertama Anda",
"Browse": "Jelajahi",
"Popular Catalogs": "Katalog Populer",
"Add": "Tambah",
"Add OPDS Catalog": "Tambah Katalog OPDS",
"Catalog Name": "Nama Katalog",
"My Calibre Library": "Perpustakaan Calibre Saya",
"OPDS URL": "URL OPDS",
"Username (optional)": "Username (opsional)",
"Password (optional)": "Password (opsional)",
"Description (optional)": "Deskripsi (opsional)",
"A brief description of this catalog": "Deskripsi singkat untuk katalog ini",
"Validating...": "Memvalidasi...",
"View All": "Lihat Semua",
"Forward": "Maju",
"OPDS Catalog": "Katalog OPDS",
"Home": "Beranda",
"Library": "Perpustakaan",
"{{count}} items_other": "{{count}} item",
"Download completed": "Unduhan selesai",
"Download failed": "Unduhan gagal",
"Open Access": "Akses Terbuka",
"Borrow": "Pinjam",
"Buy": "Beli",
"Subscribe": "Berlangganan",
"Sample": "Contoh",
"Download": "Unduh",
"Open & Read": "Buka & Baca",
"Tags": "Tag",
"Tag": "Tag",
"First": "Pertama",
"Previous": "Sebelumnya",
"Next": "Berikutnya",
"Last": "Terakhir",
"Cannot Load Page": "Tidak dapat memuat halaman",
"An error occurred": "Terjadi kesalahan"
}
@@ -679,5 +679,51 @@
"Download from Cloud": "Scarica dal Cloud",
"Upload to Cloud": "Carica sul Cloud",
"Clear Custom Fonts": "Pulisci Font Personalizzati",
"Columns": "Colonne"
"Columns": "Colonne",
"OPDS Catalogs": "Cataloghi OPDS",
"Adding LAN addresses is not supported in the web app version.": "L'aggiunta di indirizzi LAN non è supportata nella versione web.",
"Invalid OPDS catalog. Please check the URL.": "Catalogo OPDS non valido. Controlla l'URL.",
"Browse and download books from online catalogs": "Sfoglia e scarica libri dai cataloghi online",
"My Catalogs": "I miei cataloghi",
"Add Catalog": "Aggiungi catalogo",
"No catalogs yet": "Nessun catalogo",
"Add your first OPDS catalog to start browsing books": "Aggiungi il tuo primo catalogo OPDS per iniziare a sfogliare i libri",
"Add Your First Catalog": "Aggiungi il tuo primo catalogo",
"Browse": "Sfoglia",
"Popular Catalogs": "Cataloghi popolari",
"Add": "Aggiungi",
"Add OPDS Catalog": "Aggiungi catalogo OPDS",
"Catalog Name": "Nome del catalogo",
"My Calibre Library": "La mia libreria Calibre",
"OPDS URL": "URL OPDS",
"Username (optional)": "Username (opzionale)",
"Password (optional)": "Password (opzionale)",
"Description (optional)": "Descrizione (opzionale)",
"A brief description of this catalog": "Breve descrizione del catalogo",
"Validating...": "Convalida in corso...",
"View All": "Vedi tutto",
"Forward": "Avanti",
"OPDS Catalog": "Catalogo OPDS",
"Home": "Home",
"Library": "Libreria",
"{{count}} items_one": "{{count}} elemento",
"{{count}} items_many": "{{count}} elementi",
"{{count}} items_other": "{{count}} elementi",
"Download completed": "Download completato",
"Download failed": "Download non riuscito",
"Open Access": "Accesso libero",
"Borrow": "Prendi in prestito",
"Buy": "Acquista",
"Subscribe": "Abbonati",
"Sample": "Anteprima",
"Download": "Scarica",
"Open & Read": "Apri e leggi",
"Tags": "Tag",
"Tag": "Tag",
"First": "Primo",
"Previous": "Precedente",
"Next": "Successivo",
"Last": "Ultimo",
"Cannot Load Page": "Impossibile caricare la pagina",
"An error occurred": "Si è verificato un errore"
}
@@ -671,5 +671,49 @@
"Download from Cloud": "クラウドからダウンロード",
"Upload to Cloud": "クラウドにアップロード",
"Clear Custom Fonts": "カスタムフォントをクリア",
"Columns": "列"
"Columns": "列",
"OPDS Catalogs": "OPDSカタログ",
"Adding LAN addresses is not supported in the web app version.": "Webアプリ版ではLANアドレスの追加はサポートされていません。",
"Invalid OPDS catalog. Please check the URL.": "無効なOPDSカタログです。URLを確認してください。",
"Browse and download books from online catalogs": "オンラインカタログから本を閲覧・ダウンロード",
"My Catalogs": "マイカタログ",
"Add Catalog": "カタログを追加",
"No catalogs yet": "まだカタログはありません",
"Add your first OPDS catalog to start browsing books": "本を閲覧するには、最初のOPDSカタログを追加してください。",
"Add Your First Catalog": "最初のカタログを追加",
"Browse": "閲覧",
"Popular Catalogs": "人気カタログ",
"Add": "追加",
"Add OPDS Catalog": "OPDSカタログを追加",
"Catalog Name": "カタログ名",
"My Calibre Library": "私のCalibreライブラリ",
"OPDS URL": "OPDS URL",
"Username (optional)": "ユーザー名(任意)",
"Password (optional)": "パスワード(任意)",
"Description (optional)": "説明(任意)",
"A brief description of this catalog": "このカタログの簡単な説明",
"Validating...": "検証中...",
"View All": "すべて表示",
"Forward": "進む",
"OPDS Catalog": "OPDSカタログ",
"Home": "ホーム",
"Library": "ライブラリ",
"{{count}} items_other": "{{count}} 件",
"Download completed": "ダウンロード完了",
"Download failed": "ダウンロード失敗",
"Open Access": "オープンアクセス",
"Borrow": "借りる",
"Buy": "購入",
"Subscribe": "購読",
"Sample": "サンプル",
"Download": "ダウンロード",
"Open & Read": "開いて読む",
"Tags": "タグ",
"Tag": "タグ",
"First": "最初",
"Previous": "前へ",
"Next": "次へ",
"Last": "最後",
"Cannot Load Page": "ページを読み込めません",
"An error occurred": "エラーが発生しました"
}
@@ -671,5 +671,49 @@
"Download from Cloud": "클라우드에서 다운로드",
"Upload to Cloud": "클라우드에 업로드",
"Clear Custom Fonts": "사용자 정의 글꼴 지우기",
"Columns": "열"
"Columns": "열",
"OPDS Catalogs": "OPDS 카탈로그",
"Adding LAN addresses is not supported in the web app version.": "웹 앱 버전에서는 LAN 주소 추가를 지원하지 않습니다.",
"Invalid OPDS catalog. Please check the URL.": "잘못된 OPDS 카탈로그입니다. URL을 확인해주세요.",
"Browse and download books from online catalogs": "온라인 카탈로그에서 책을 탐색하고 다운로드",
"My Catalogs": "내 카탈로그",
"Add Catalog": "카탈로그 추가",
"No catalogs yet": "아직 카탈로그가 없습니다",
"Add your first OPDS catalog to start browsing books": "책을 탐색하려면 첫 번째 OPDS 카탈로그를 추가하세요.",
"Add Your First Catalog": "첫 번째 카탈로그 추가",
"Browse": "탐색",
"Popular Catalogs": "인기 카탈로그",
"Add": "추가",
"Add OPDS Catalog": "OPDS 카탈로그 추가",
"Catalog Name": "카탈로그 이름",
"My Calibre Library": "내 Calibre 라이브러리",
"OPDS URL": "OPDS URL",
"Username (optional)": "사용자 이름 (선택 사항)",
"Password (optional)": "비밀번호 (선택 사항)",
"Description (optional)": "설명 (선택 사항)",
"A brief description of this catalog": "이 카탈로그의 간단한 설명",
"Validating...": "검증 중...",
"View All": "모두 보기",
"Forward": "다음",
"OPDS Catalog": "OPDS 카탈로그",
"Home": "홈",
"Library": "라이브러리",
"{{count}} items_other": "{{count}}개 항목",
"Download completed": "다운로드 완료",
"Download failed": "다운로드 실패",
"Open Access": "오픈 액세스",
"Borrow": "대출",
"Buy": "구매",
"Subscribe": "구독",
"Sample": "샘플",
"Download": "다운로드",
"Open & Read": "열기 및 읽기",
"Tags": "태그",
"Tag": "태그",
"First": "처음",
"Previous": "이전",
"Next": "다음",
"Last": "마지막",
"Cannot Load Page": "페이지를 불러올 수 없습니다",
"An error occurred": "오류가 발생했습니다"
}
@@ -671,5 +671,49 @@
"Reveal in Finder": "Tunjukkan dalam Finder",
"Reveal in File Explorer": "Tunjukkan dalam File Explorer",
"Reveal in Folder": "Tunjukkan dalam Folder",
"Columns": "Lajur"
"Columns": "Lajur",
"OPDS Catalogs": "Katalog OPDS",
"Adding LAN addresses is not supported in the web app version.": "Menambah alamat LAN tidak disokong dalam versi web.",
"Invalid OPDS catalog. Please check the URL.": "Katalog OPDS tidak sah. Sila semak URL.",
"Browse and download books from online catalogs": "Terokai dan muat turun buku dari katalog dalam talian",
"My Catalogs": "Katalog Saya",
"Add Catalog": "Tambah Katalog",
"No catalogs yet": "Tiada katalog lagi",
"Add your first OPDS catalog to start browsing books": "Tambah katalog OPDS pertama anda untuk mula meneroka buku",
"Add Your First Catalog": "Tambah Katalog Pertama Anda",
"Browse": "Terokai",
"Popular Catalogs": "Katalog Popular",
"Add": "Tambah",
"Add OPDS Catalog": "Tambah Katalog OPDS",
"Catalog Name": "Nama Katalog",
"My Calibre Library": "Perpustakaan Calibre Saya",
"OPDS URL": "URL OPDS",
"Username (optional)": "Nama Pengguna (pilihan)",
"Password (optional)": "Kata Laluan (pilihan)",
"Description (optional)": "Penerangan (pilihan)",
"A brief description of this catalog": "Penerangan ringkas katalog ini",
"Validating...": "Mengesahkan...",
"View All": "Lihat Semua",
"Forward": "Maju",
"OPDS Catalog": "Katalog OPDS",
"Home": "Laman Utama",
"Library": "Perpustakaan",
"{{count}} items_other": "{{count}} item",
"Download completed": "Muat turun selesai",
"Download failed": "Muat turun gagal",
"Open Access": "Akses Terbuka",
"Borrow": "Pinjam",
"Buy": "Beli",
"Subscribe": "Langgan",
"Sample": "Contoh",
"Download": "Muat turun",
"Open & Read": "Buka & Baca",
"Tags": "Tag",
"Tag": "Tag",
"First": "Pertama",
"Previous": "Sebelumnya",
"Next": "Seterusnya",
"Last": "Terakhir",
"Cannot Load Page": "Tidak dapat memuatkan halaman",
"An error occurred": "Ralat telah berlaku"
}
@@ -675,5 +675,50 @@
"Download from Cloud": "Downloaden van Cloud",
"Upload to Cloud": "Uploaden naar Cloud",
"Clear Custom Fonts": "Aangepaste Lettertypen Wissen",
"Columns": "Kolommen"
"Columns": "Kolommen",
"OPDS Catalogs": "OPDS-catalogi",
"Adding LAN addresses is not supported in the web app version.": "Het toevoegen van LAN-adressen wordt niet ondersteund in de webversie.",
"Invalid OPDS catalog. Please check the URL.": "Ongeldige OPDS-catalogus. Controleer de URL.",
"Browse and download books from online catalogs": "Blader door en download boeken uit online catalogi",
"My Catalogs": "Mijn catalogi",
"Add Catalog": "Catalogus toevoegen",
"No catalogs yet": "Nog geen catalogi",
"Add your first OPDS catalog to start browsing books": "Voeg je eerste OPDS-catalogus toe om boeken te bekijken",
"Add Your First Catalog": "Voeg je eerste catalogus toe",
"Browse": "Bladeren",
"Popular Catalogs": "Populaire catalogi",
"Add": "Toevoegen",
"Add OPDS Catalog": "OPDS-catalogus toevoegen",
"Catalog Name": "Catalogusnaam",
"My Calibre Library": "Mijn Calibre-bibliotheek",
"OPDS URL": "OPDS-URL",
"Username (optional)": "Gebruikersnaam (optioneel)",
"Password (optional)": "Wachtwoord (optioneel)",
"Description (optional)": "Beschrijving (optioneel)",
"A brief description of this catalog": "Een korte beschrijving van deze catalogus",
"Validating...": "Valideren...",
"View All": "Alles bekijken",
"Forward": "Verder",
"OPDS Catalog": "OPDS-catalogus",
"Home": "Startpagina",
"Library": "Bibliotheek",
"{{count}} items_one": "{{count}} item",
"{{count}} items_other": "{{count}} items",
"Download completed": "Download voltooid",
"Download failed": "Download mislukt",
"Open Access": "Open toegang",
"Borrow": "Lenen",
"Buy": "Kopen",
"Subscribe": "Abonneren",
"Sample": "Voorbeeld",
"Download": "Downloaden",
"Open & Read": "Openen & Lezen",
"Tags": "Tags",
"Tag": "Tag",
"First": "Eerste",
"Previous": "Vorige",
"Next": "Volgende",
"Last": "Laatste",
"Cannot Load Page": "Pagina kan niet worden geladen",
"An error occurred": "Er is een fout opgetreden"
}
@@ -683,5 +683,52 @@
"Download from Cloud": "Pobierz z chmury",
"Upload to Cloud": "Prześlij do chmury",
"Clear Custom Fonts": "Wyczyść niestandardowe czcionki",
"Columns": "Kolumny"
"Columns": "Kolumny",
"OPDS Catalogs": "Katalogi OPDS",
"Adding LAN addresses is not supported in the web app version.": "Dodawanie adresów LAN nie jest obsługiwane w wersji webowej.",
"Invalid OPDS catalog. Please check the URL.": "Nieprawidłowy katalog OPDS. Sprawdź URL.",
"Browse and download books from online catalogs": "Przeglądaj i pobieraj książki z katalogów online",
"My Catalogs": "Moje katalogi",
"Add Catalog": "Dodaj katalog",
"No catalogs yet": "Brak katalogów",
"Add your first OPDS catalog to start browsing books": "Dodaj pierwszy katalog OPDS, aby rozpocząć przeglądanie książek",
"Add Your First Catalog": "Dodaj pierwszy katalog",
"Browse": "Przeglądaj",
"Popular Catalogs": "Popularne katalogi",
"Add": "Dodaj",
"Add OPDS Catalog": "Dodaj katalog OPDS",
"Catalog Name": "Nazwa katalogu",
"My Calibre Library": "Moja biblioteka Calibre",
"OPDS URL": "URL OPDS",
"Username (optional)": "Nazwa użytkownika (opcjonalnie)",
"Password (optional)": "Hasło (opcjonalnie)",
"Description (optional)": "Opis (opcjonalnie)",
"A brief description of this catalog": "Krótki opis katalogu",
"Validating...": "Weryfikacja...",
"View All": "Pokaż wszystkie",
"Forward": "Dalej",
"OPDS Catalog": "Katalog OPDS",
"Home": "Strona główna",
"Library": "Biblioteka",
"{{count}} items_one": "{{count}} element",
"{{count}} items_few": "{{count}} elementy",
"{{count}} items_many": "{{count}} elementów",
"{{count}} items_other": "{{count}} elementu",
"Download completed": "Pobieranie zakończone",
"Download failed": "Pobieranie nie powiodło się",
"Open Access": "Dostęp otwarty",
"Borrow": "Wypożycz",
"Buy": "Kup",
"Subscribe": "Subskrybuj",
"Sample": "Próbka",
"Download": "Pobierz",
"Open & Read": "Otwórz i czytaj",
"Tags": "Tagi",
"Tag": "Tag",
"First": "Pierwsza",
"Previous": "Poprzednia",
"Next": "Następna",
"Last": "Ostatnia",
"Cannot Load Page": "Nie można załadować strony",
"An error occurred": "Wystąpił błąd"
}
@@ -679,5 +679,51 @@
"Download from Cloud": "Baixar da Nuvem",
"Upload to Cloud": "Enviar para a Nuvem",
"Clear Custom Fonts": "Limpar Fontes Personalizadas",
"Columns": "Colunas"
"Columns": "Colunas",
"OPDS Catalogs": "Catálogos OPDS",
"Adding LAN addresses is not supported in the web app version.": "Adicionar endereços LAN não é suportado na versão web.",
"Invalid OPDS catalog. Please check the URL.": "Catálogo OPDS inválido. Verifique o URL.",
"Browse and download books from online catalogs": "Navegar e baixar livros de catálogos online",
"My Catalogs": "Meus catálogos",
"Add Catalog": "Adicionar catálogo",
"No catalogs yet": "Nenhum catálogo ainda",
"Add your first OPDS catalog to start browsing books": "Adicione seu primeiro catálogo OPDS para começar a navegar pelos livros",
"Add Your First Catalog": "Adicione seu primeiro catálogo",
"Browse": "Navegar",
"Popular Catalogs": "Catálogos populares",
"Add": "Adicionar",
"Add OPDS Catalog": "Adicionar catálogo OPDS",
"Catalog Name": "Nome do catálogo",
"My Calibre Library": "Minha biblioteca Calibre",
"OPDS URL": "URL do OPDS",
"Username (optional)": "Nome de usuário (opcional)",
"Password (optional)": "Senha (opcional)",
"Description (optional)": "Descrição (opcional)",
"A brief description of this catalog": "Uma breve descrição deste catálogo",
"Validating...": "Validando...",
"View All": "Ver todos",
"Forward": "Avançar",
"OPDS Catalog": "Catálogo OPDS",
"Home": "Início",
"Library": "Biblioteca",
"{{count}} items_one": "{{count}} item",
"{{count}} items_many": "{{count}} itens",
"{{count}} items_other": "{{count}} item",
"Download completed": "Download concluído",
"Download failed": "Falha no download",
"Open Access": "Acesso aberto",
"Borrow": "Emprestar",
"Buy": "Comprar",
"Subscribe": "Assinar",
"Sample": "Amostra",
"Download": "Baixar",
"Open & Read": "Abrir e ler",
"Tags": "Etiquetas",
"Tag": "Etiqueta",
"First": "Pierwsza",
"Previous": "Poprzednia",
"Next": "Następna",
"Last": "Ostatnia",
"Cannot Load Page": "Nie można załadować strony",
"An error occurred": "Wystąpił błąd"
}
@@ -683,5 +683,52 @@
"Download from Cloud": "Скачать из облака",
"Upload to Cloud": "Загрузить в облако",
"Clear Custom Fonts": "Очистить пользовательские шрифты",
"Columns": "Колонки"
"Columns": "Колонки",
"OPDS Catalogs": "Каталоги OPDS",
"Adding LAN addresses is not supported in the web app version.": "Добавление LAN-адресов не поддерживается в веб-версии.",
"Invalid OPDS catalog. Please check the URL.": "Недействительный каталог OPDS. Пожалуйста, проверьте URL.",
"Browse and download books from online catalogs": "Просматривать и скачивать книги из онлайн-каталогов",
"My Catalogs": "Мои каталоги",
"Add Catalog": "Добавить каталог",
"No catalogs yet": "Каталоги отсутствуют",
"Add your first OPDS catalog to start browsing books": "Добавьте первый каталог OPDS, чтобы начать просмотр книг",
"Add Your First Catalog": "Добавьте первый каталог",
"Browse": "Просмотр",
"Popular Catalogs": "Популярные каталоги",
"Add": "Добавить",
"Add OPDS Catalog": "Добавить каталог OPDS",
"Catalog Name": "Название каталога",
"My Calibre Library": "Моя библиотека Calibre",
"OPDS URL": "URL OPDS",
"Username (optional)": "Имя пользователя (необязательно)",
"Password (optional)": "Пароль (необязательно)",
"Description (optional)": "Описание (необязательно)",
"A brief description of this catalog": "Краткое описание каталога",
"Validating...": "Проверка...",
"View All": "Посмотреть все",
"Forward": "Вперёд",
"OPDS Catalog": "Каталог OPDS",
"Home": "Главная",
"Library": "Библиотека",
"{{count}} items_one": "{{count}} элемент",
"{{count}} items_few": "{{count}} элемента",
"{{count}} items_many": "{{count}} элементов",
"{{count}} items_other": "{{count}} элемента",
"Download completed": "Загрузка завершена",
"Download failed": "Загрузка не удалась",
"Open Access": "Открытый доступ",
"Borrow": "Взять в аренду",
"Buy": "Купить",
"Subscribe": "Подписаться",
"Sample": "Образец",
"Download": "Скачать",
"Open & Read": "Открыть и читать",
"Tags": "Теги",
"Tag": "Тег",
"First": "Первая",
"Previous": "Предыдущая",
"Next": "Следующая",
"Last": "Последняя",
"Cannot Load Page": "Не удалось загрузить страницу",
"An error occurred": "Произошла ошибка"
}
@@ -675,5 +675,50 @@
"Download from Cloud": "කලාඬයෙන් බාගත කරන්න",
"Upload to Cloud": "කලාඬයට උඩුගත කරන්න",
"Clear Custom Fonts": "අභිරුචි අකුරු පැහැදිලි කරන්න",
"Columns": "තීරු"
"Columns": "තීරු",
"OPDS Catalogs": "OPDS දත්තසමුදා",
"Adding LAN addresses is not supported in the web app version.": "වෙබ් යෙදුම් අනුවාදයේ LAN ලිපින එක් කිරීම සහය නොදක්වයි.",
"Invalid OPDS catalog. Please check the URL.": "අවලංගු OPDS දත්තසමුදායකි. කරුණාකර URL පරීක්ෂා කරන්න.",
"Browse and download books from online catalogs": "ඔන්ලයින් දත්තසමුදා වලින් පොත් බ්‍රවුස් සහ බාගන්න",
"My Catalogs": "මගේ දත්තසමුදා",
"Add Catalog": "දත්තසමුදා එක් කරන්න",
"No catalogs yet": "තවම දත්තසමුදා නැත",
"Add your first OPDS catalog to start browsing books": "පොත් බ්‍රවුස් කිරීමට ඔබේ පළමු OPDS දත්තසමුදා එක් කරන්න",
"Add Your First Catalog": "ඔබේ පළමු දත්තසමුදා එක් කරන්න",
"Browse": "බ්‍රවුස් කරන්න",
"Popular Catalogs": "ප්‍රසිද්ධ දත්තසමුදා",
"Add": "එකතු කරන්න",
"Add OPDS Catalog": "OPDS දත්තසමුදා එක් කරන්න",
"Catalog Name": "දත්තසමුදා නාමය",
"My Calibre Library": "මගේ Calibre පුස්තකාලය",
"OPDS URL": "OPDS URL",
"Username (optional)": "පරිශීලක නාමය (අත්‍යාවශ්‍ය නොවේ)",
"Password (optional)": "මුරපදය (අත්‍යාවශ්‍ය නොවේ)",
"Description (optional)": "විස්තරය (අත්‍යාවශ්‍ය නොවේ)",
"A brief description of this catalog": "මෙම දත්තසමුදායේ කෙටි විස්තරයක්",
"Validating...": "තහවුරු කරමින්...",
"View All": "සියල්ල බලන්න",
"Forward": "ඉදිරියට",
"OPDS Catalog": "OPDS දත්තසමුදා",
"Home": "මුල් පිටුව",
"Library": "පුස්තකාලය",
"{{count}} items_one": "{{count}} අයිතමය",
"{{count}} items_other": "{{count}} අයිතම",
"Download completed": "බාගත කිරීම සම්පූර්ණයි",
"Download failed": "බාගත කිරීම අසාර්ථකයි",
"Open Access": "විවෘත ප්‍රවේශය",
"Borrow": "උधාර ගන්න",
"Buy": "ගන්න",
"Subscribe": "දායක වන්න",
"Sample": "නම්නය",
"Download": "බාගන්න",
"Open & Read": "විවෘත කර කියවන්න",
"Tags": "ටැග්",
"Tag": "ටැග්",
"First": "පළමුවා",
"Previous": "පෙර",
"Next": "ඊළඟ",
"Last": "අවසාන",
"Cannot Load Page": "පිටුවට ප්‍රවේශ විය නොහැක",
"An error occurred": "දෝෂයක් සිදු විය"
}
@@ -675,5 +675,50 @@
"Download from Cloud": "Ladda ner från molnet",
"Upload to Cloud": "Ladda upp till molnet",
"Clear Custom Fonts": "Rensa anpassade typsnitt",
"Columns": "Kolumner"
"Columns": "Kolumner",
"OPDS Catalogs": "OPDS-kataloger",
"Adding LAN addresses is not supported in the web app version.": "Att lägga till LAN-adresser stöds inte i webbappen.",
"Invalid OPDS catalog. Please check the URL.": "Ogiltig OPDS-katalog. Kontrollera URL:en.",
"Browse and download books from online catalogs": "Bläddra och ladda ner böcker från onlinekataloger",
"My Catalogs": "Mina kataloger",
"Add Catalog": "Lägg till katalog",
"No catalogs yet": "Inga kataloger än",
"Add your first OPDS catalog to start browsing books": "Lägg till din första OPDS-katalog för att börja bläddra i böcker",
"Add Your First Catalog": "Lägg till din första katalog",
"Browse": "Bläddra",
"Popular Catalogs": "Populära kataloger",
"Add": "Lägg till",
"Add OPDS Catalog": "Lägg till OPDS-katalog",
"Catalog Name": "Katalognamn",
"My Calibre Library": "Mitt Calibre-bibliotek",
"OPDS URL": "OPDS-URL",
"Username (optional)": "Användarnamn (valfritt)",
"Password (optional)": "Lösenord (valfritt)",
"Description (optional)": "Beskrivning (valfritt)",
"A brief description of this catalog": "En kort beskrivning av denna katalog",
"Validating...": "Verifierar...",
"View All": "Visa alla",
"Forward": "Framåt",
"OPDS Catalog": "OPDS-katalog",
"Home": "Start",
"Library": "Bibliotek",
"{{count}} items_one": "{{count}} objekt",
"{{count}} items_other": "{{count}} objekt",
"Download completed": "Nedladdning klar",
"Download failed": "Nedladdning misslyckades",
"Open Access": "Öppen åtkomst",
"Borrow": "Låna",
"Buy": "Köp",
"Subscribe": "Prenumerera",
"Sample": "Prov",
"Download": "Ladda ner",
"Open & Read": "Öppna & Läs",
"Tags": "Taggar",
"Tag": "Tagg",
"First": "Första",
"Previous": "Föregående",
"Next": "Nästa",
"Last": "Sista",
"Cannot Load Page": "Kan inte ladda sidan",
"An error occurred": "Ett fel uppstod"
}
@@ -675,5 +675,50 @@
"Download from Cloud": "மேகத்திலிருந்து பதிவிறக்கு செய்யவும்",
"Upload to Cloud": "மேகத்திற்கு பதிவேற்றவும்",
"Clear Custom Fonts": "தனிப்பயன் எழுத்துருக்களை அழிக்கவும்",
"Columns": "நெடுவரிசைகள்"
"Columns": "நெடுவரிசைகள்",
"OPDS Catalogs": "OPDS பட்டியல்கள்",
"Adding LAN addresses is not supported in the web app version.": "LAN முகவரிகளைச் சேர்ப்பது வலை பயன்பாட்டில் ஆதரிக்கப்படவில்லை.",
"Invalid OPDS catalog. Please check the URL.": "தவறான OPDS பட்டியல். URL ஐச் சரிபார்க்கவும்.",
"Browse and download books from online catalogs": "ஆன்லைன் பட்டியல்களில் இருந்து புத்தகங்களை உலாவவும் மற்றும் பதிவிறக்கவும்",
"My Catalogs": "என் பட்டியல்கள்",
"Add Catalog": "பட்டியலைச் சேர்க்கவும்",
"No catalogs yet": "இன்னும் எந்த பட்டியலும் இல்லை",
"Add your first OPDS catalog to start browsing books": "புத்தகங்களை உலாவ தொடங்க உங்கள் முதல் OPDS பட்டியலைச் சேர்க்கவும்",
"Add Your First Catalog": "உங்கள் முதல் பட்டியலைச் சேர்க்கவும்",
"Browse": "உலாவவும்",
"Popular Catalogs": "பிரபல பட்டியல்கள்",
"Add": "சேர்க்கவும்",
"Add OPDS Catalog": "OPDS பட்டியலைச் சேர்க்கவும்",
"Catalog Name": "பட்டியல் பெயர்",
"My Calibre Library": "என் Calibre நூலகம்",
"OPDS URL": "OPDS URL",
"Username (optional)": "பயனர் பெயர் (விருப்பம்)",
"Password (optional)": "கடவுச்சொல் (விருப்பம்)",
"Description (optional)": "விவரிப்பு (விருப்பம்)",
"A brief description of this catalog": "இந்த பட்டியலின் சுருக்கமான விளக்கம்",
"Validating...": "சரிபார்க்கப்படுகிறது...",
"View All": "அனைத்தையும் பார்க்கவும்",
"Forward": "முன்னேற்று",
"OPDS Catalog": "OPDS பட்டியல்",
"Home": "முகப்பு",
"Library": "நூலகம்",
"{{count}} items_one": "{{count}} பொருள்",
"{{count}} items_other": "{{count}} பொருட்கள்",
"Download completed": "பதிவிறக்கம் முடிந்தது",
"Download failed": "பதிவிறக்கம் தோல்வியடைந்தது",
"Open Access": "திறந்த அணுகல்",
"Borrow": "கடன் எடுக்கவும்",
"Buy": "வாங்கவும்",
"Subscribe": "சந்தா செய்யவும்",
"Sample": "மாதிரிப் புத்தகம்",
"Download": "பதிவிறக்கம் செய்யவும்",
"Open & Read": "திறந்து வாசிக்கவும்",
"Tags": "குறிச்சொற்கள்",
"Tag": "குறிச்சொல்",
"First": "முதல்",
"Previous": "முந்தையது",
"Next": "அடுத்தது",
"Last": "இறுதி",
"Cannot Load Page": "பக்கம் ஏற்ற முடியவில்லை",
"An error occurred": "ஒரு பிழை ஏற்பட்டது"
}
@@ -671,5 +671,49 @@
"Download from Cloud": "ดาวน์โหลดจากคลาวด์",
"Upload to Cloud": "อัปโหลดไปยังคลาวด์",
"Clear Custom Fonts": "ล้างฟอนต์กำหนดเอง",
"Columns": "คอลัมน์"
"Columns": "คอลัมน์",
"OPDS Catalogs": "แคตตาล็อก OPDS",
"Adding LAN addresses is not supported in the web app version.": "ไม่รองรับการเพิ่มที่อยู่ LAN ในเวอร์ชันเว็บแอป",
"Invalid OPDS catalog. Please check the URL.": "แคตตาล็อก OPDS ไม่ถูกต้อง กรุณาตรวจสอบ URL",
"Browse and download books from online catalogs": "เรียกดูและดาวน์โหลดหนังสือจากแคตตาล็อกออนไลน์",
"My Catalogs": "แคตตาล็อกของฉัน",
"Add Catalog": "เพิ่มแคตตาล็อก",
"No catalogs yet": "ยังไม่มีแคตตาล็อก",
"Add your first OPDS catalog to start browsing books": "เพิ่มแคตตาล็อก OPDS แรกของคุณเพื่อเริ่มเรียกดูหนังสือ",
"Add Your First Catalog": "เพิ่มแคตตาล็อกแรกของคุณ",
"Browse": "เรียกดู",
"Popular Catalogs": "แคตตาล็อกยอดนิยม",
"Add": "เพิ่ม",
"Add OPDS Catalog": "เพิ่มแคตตาล็อก OPDS",
"Catalog Name": "ชื่อแคตตาล็อก",
"My Calibre Library": "ห้องสมุด Calibre ของฉัน",
"OPDS URL": "URL OPDS",
"Username (optional)": "ชื่อผู้ใช้ (ไม่บังคับ)",
"Password (optional)": "รหัสผ่าน (ไม่บังคับ)",
"Description (optional)": "คำอธิบาย (ไม่บังคับ)",
"A brief description of this catalog": "คำอธิบายสั้น ๆ ของแคตตาล็อกนี้",
"Validating...": "กำลังตรวจสอบ...",
"View All": "ดูทั้งหมด",
"Forward": "ไปข้างหน้า",
"OPDS Catalog": "แคตตาล็อก OPDS",
"Home": "หน้าแรก",
"Library": "ห้องสมุด",
"{{count}} items_other": "{{count}} รายการ",
"Download completed": "ดาวน์โหลดเสร็จสิ้น",
"Download failed": "ดาวน์โหลดล้มเหลว",
"Open Access": "เข้าถึงได้ทันที",
"Borrow": "ยืม",
"Buy": "ซื้อ",
"Subscribe": "สมัครสมาชิก",
"Sample": "ตัวอย่าง",
"Download": "ดาวน์โหลด",
"Open & Read": "เปิดและอ่าน",
"Tags": "แท็ก",
"Tag": "แท็ก",
"First": "แรก",
"Previous": "ก่อนหน้า",
"Next": "ถัดไป",
"Last": "สุดท้าย",
"Cannot Load Page": "ไม่สามารถโหลดหน้าหน้าได้",
"An error occurred": "เกิดข้อผิดพลาด"
}
@@ -675,5 +675,50 @@
"Download from Cloud": "Buluttan İndir",
"Upload to Cloud": "Buluta Yükle",
"Clear Custom Fonts": "Özel Yazı Tiplerini Temizle",
"Columns": "Sütunlar"
"Columns": "Sütunlar",
"OPDS Catalogs": "OPDS Katalogları",
"Adding LAN addresses is not supported in the web app version.": "Web uygulaması sürümünde LAN adresi ekleme desteklenmiyor.",
"Invalid OPDS catalog. Please check the URL.": "Geçersiz OPDS kataloğu. Lütfen URL'yi kontrol edin.",
"Browse and download books from online catalogs": "Çevrimiçi kataloglardan kitapları görüntüleyin ve indirin",
"My Catalogs": "Kataloglarım",
"Add Catalog": "Katalog Ekle",
"No catalogs yet": "Henüz katalog yok",
"Add your first OPDS catalog to start browsing books": "Kitapları görüntülemeye başlamak için ilk OPDS kataloğunuzu ekleyin",
"Add Your First Catalog": "İlk Kataloğunuzu Ekleyin",
"Browse": "Gözat",
"Popular Catalogs": "Popüler Kataloglar",
"Add": "Ekle",
"Add OPDS Catalog": "OPDS Kataloğu Ekle",
"Catalog Name": "Katalog Adı",
"My Calibre Library": "Calibre Kütüphanem",
"OPDS URL": "OPDS URL",
"Username (optional)": "Kullanıcı Adı (opsiyonel)",
"Password (optional)": "Şifre (opsiyonel)",
"Description (optional)": "Açıklama (opsiyonel)",
"A brief description of this catalog": "Bu katalog hakkında kısa açıklama",
"Validating...": "Doğrulanıyor...",
"View All": "Tümünü Görüntüle",
"Forward": "İleri",
"OPDS Catalog": "OPDS Kataloğu",
"Home": "Ana Sayfa",
"Library": "Kütüphane",
"{{count}} items_one": "{{count}} öğe",
"{{count}} items_other": "{{count}} öğe",
"Download completed": "İndirme tamamlandı",
"Download failed": "İndirme başarısız",
"Open Access": "Açık Erişim",
"Borrow": "Ödünç Al",
"Buy": "Satın Al",
"Subscribe": "Abone Ol",
"Sample": "Örnek",
"Download": "İndir",
"Open & Read": "Aç ve Oku",
"Tags": "Etiketler",
"Tag": "Etiket",
"First": "İlk",
"Previous": "Önceki",
"Next": "Sonraki",
"Last": "Son",
"Cannot Load Page": "Sayfa yüklenemiyor",
"An error occurred": "Bir hata oluştu"
}
@@ -683,5 +683,52 @@
"Download from Cloud": "Завантажити з хмари",
"Upload to Cloud": "Завантажити в хмару",
"Clear Custom Fonts": "Очистити користувацькі шрифти",
"Columns": "Стовпці"
"Columns": "Стовпці",
"OPDS Catalogs": "Каталоги OPDS",
"Adding LAN addresses is not supported in the web app version.": "Додавання LAN-адрес не підтримується у веб-версії.",
"Invalid OPDS catalog. Please check the URL.": "Недійсний каталог OPDS. Будь ласка, перевірте URL.",
"Browse and download books from online catalogs": "Переглядайте та завантажуйте книги з онлайн-каталогів",
"My Catalogs": "Мої каталоги",
"Add Catalog": "Додати каталог",
"No catalogs yet": "Каталогів поки немає",
"Add your first OPDS catalog to start browsing books": "Додайте перший каталог OPDS, щоб почати перегляд книг",
"Add Your First Catalog": "Додайте свій перший каталог",
"Browse": "Переглянути",
"Popular Catalogs": "Популярні каталоги",
"Add": "Додати",
"Add OPDS Catalog": "Додати каталог OPDS",
"Catalog Name": "Назва каталогу",
"My Calibre Library": "Моя бібліотека Calibre",
"OPDS URL": "OPDS URL",
"Username (optional)": "Ім’я користувача (необов’язково)",
"Password (optional)": "Пароль (необов’язково)",
"Description (optional)": "Опис (необов’язково)",
"A brief description of this catalog": "Короткий опис цього каталогу",
"Validating...": "Перевірка...",
"View All": "Переглянути все",
"Forward": "Вперед",
"OPDS Catalog": "Каталог OPDS",
"Home": "Головна",
"Library": "Бібліотека",
"{{count}} items_one": "{{count}} елемент",
"{{count}} items_few": "{{count}} елементи",
"{{count}} items_many": "{{count}} елементів",
"{{count}} items_other": "{{count}} елементів",
"Download completed": "Завантаження завершено",
"Download failed": "Завантаження не вдалося",
"Open Access": "Вільний доступ",
"Borrow": "Позичити",
"Buy": "Купити",
"Subscribe": "Підписатися",
"Sample": "Зразок",
"Download": "Завантажити",
"Open & Read": "Відкрити та читати",
"Tags": "Теги",
"Tag": "Тег",
"First": "Перша",
"Previous": "Попередня",
"Next": "Наступна",
"Last": "Остання",
"Cannot Load Page": "Не вдалося завантажити сторінку",
"An error occurred": "Сталася помилка"
}
@@ -671,5 +671,49 @@
"Download from Cloud": "Tải xuống từ đám mây",
"Upload to Cloud": "Tải lên đám mây",
"Clear Custom Fonts": "Xóa phông chữ tùy chỉnh",
"Columns": "Cột"
"Columns": "Cột",
"OPDS Catalogs": "Danh mục OPDS",
"Adding LAN addresses is not supported in the web app version.": "Việc thêm địa chỉ LAN không được hỗ trợ trong phiên bản web.",
"Invalid OPDS catalog. Please check the URL.": "Danh mục OPDS không hợp lệ. Vui lòng kiểm tra URL.",
"Browse and download books from online catalogs": "Duyệt và tải sách từ các danh mục trực tuyến",
"My Catalogs": "Danh mục của tôi",
"Add Catalog": "Thêm danh mục",
"No catalogs yet": "Chưa có danh mục nào",
"Add your first OPDS catalog to start browsing books": "Thêm danh mục OPDS đầu tiên để bắt đầu duyệt sách",
"Add Your First Catalog": "Thêm danh mục đầu tiên",
"Browse": "Duyệt",
"Popular Catalogs": "Danh mục phổ biến",
"Add": "Thêm",
"Add OPDS Catalog": "Thêm danh mục OPDS",
"Catalog Name": "Tên danh mục",
"My Calibre Library": "Thư viện Calibre của tôi",
"OPDS URL": "URL OPDS",
"Username (optional)": "Tên đăng nhập (tùy chọn)",
"Password (optional)": "Mật khẩu (tùy chọn)",
"Description (optional)": "Mô tả (tùy chọn)",
"A brief description of this catalog": "Mô tả ngắn gọn về danh mục này",
"Validating...": "Đang xác thực...",
"View All": "Xem tất cả",
"Forward": "Tiếp",
"OPDS Catalog": "Danh mục OPDS",
"Home": "Trang chủ",
"Library": "Thư viện",
"{{count}} items_other": "{{count}} mục",
"Download completed": "Tải xuống hoàn tất",
"Download failed": "Tải xuống thất bại",
"Open Access": "Truy cập mở",
"Borrow": "Mượn",
"Buy": "Mua",
"Subscribe": "Đăng ký",
"Sample": "Mẫu",
"Download": "Tải xuống",
"Open & Read": "Mở & Đọc",
"Tags": "Thẻ",
"Tag": "Thẻ",
"First": "Đầu tiên",
"Previous": "Trước",
"Next": "Tiếp theo",
"Last": "Cuối cùng",
"Cannot Load Page": "Không thể tải trang",
"An error occurred": "Đã xảy ra lỗi"
}
@@ -671,5 +671,49 @@
"Download from Cloud": "从云端下载",
"Upload to Cloud": "上传到云端",
"Clear Custom Fonts": "清除自定义字体",
"Columns": "列数"
"Columns": "列数",
"OPDS Catalogs": "OPDS 目录",
"Adding LAN addresses is not supported in the web app version.": "Web 版不支持添加局域网地址。",
"Invalid OPDS catalog. Please check the URL.": "无效的 OPDS 目录。请检查 URL。",
"Browse and download books from online catalogs": "浏览并下载在线目录中的书籍",
"My Catalogs": "我的目录",
"Add Catalog": "添加目录",
"No catalogs yet": "尚无目录",
"Add your first OPDS catalog to start browsing books": "添加您的第一个 OPDS 目录以开始浏览书籍",
"Add Your First Catalog": "添加第一个目录",
"Browse": "浏览",
"Popular Catalogs": "热门目录",
"Add": "添加",
"Add OPDS Catalog": "添加 OPDS 目录",
"Catalog Name": "目录名称",
"My Calibre Library": "我的 Calibre 图书馆",
"OPDS URL": "OPDS URL",
"Username (optional)": "用户名(可选)",
"Password (optional)": "密码(可选)",
"Description (optional)": "描述(可选)",
"A brief description of this catalog": "该目录的简要描述",
"Validating...": "验证中...",
"View All": "查看全部",
"Forward": "前进",
"OPDS Catalog": "OPDS 目录",
"Home": "首页",
"Library": "图书馆",
"{{count}} items_other": "{{count}} 项",
"Download completed": "下载完成",
"Download failed": "下载失败",
"Open Access": "开放访问",
"Borrow": "借阅",
"Buy": "购买",
"Subscribe": "订阅",
"Sample": "样章",
"Download": "下载",
"Open & Read": "打开并阅读",
"Tags": "标签",
"Tag": "标签",
"First": "首页",
"Previous": "上一页",
"Next": "下一页",
"Last": "末页",
"Cannot Load Page": "无法加载页面",
"An error occurred": "发生错误"
}
@@ -671,5 +671,49 @@
"Download from Cloud": "從雲端下載",
"Upload to Cloud": "上傳到雲端",
"Clear Custom Fonts": "清除自訂字型",
"Columns": "欄數"
"Columns": "欄數",
"OPDS Catalogs": "OPDS 目錄",
"Adding LAN addresses is not supported in the web app version.": "網頁版不支援新增區域網路位址。",
"Invalid OPDS catalog. Please check the URL.": "無效的 OPDS 目錄。請檢查 URL。",
"Browse and download books from online catalogs": "瀏覽並下載線上目錄的書籍",
"My Catalogs": "我的目錄",
"Add Catalog": "新增目錄",
"No catalogs yet": "尚無目錄",
"Add your first OPDS catalog to start browsing books": "新增第一個 OPDS 目錄以開始瀏覽書籍",
"Add Your First Catalog": "新增第一個目錄",
"Browse": "瀏覽",
"Popular Catalogs": "熱門目錄",
"Add": "新增",
"Add OPDS Catalog": "新增 OPDS 目錄",
"Catalog Name": "目錄名稱",
"My Calibre Library": "我的 Calibre 圖書館",
"OPDS URL": "OPDS URL",
"Username (optional)": "使用者名稱(可選)",
"Password (optional)": "密碼(可選)",
"Description (optional)": "描述(可選)",
"A brief description of this catalog": "此目錄的簡短描述",
"Validating...": "驗證中...",
"View All": "檢視全部",
"Forward": "前往",
"OPDS Catalog": "OPDS 目錄",
"Home": "首頁",
"Library": "圖書館",
"{{count}} items_other": "{{count}} 項",
"Download completed": "下載完成",
"Download failed": "下載失敗",
"Open Access": "開放存取",
"Borrow": "借閱",
"Buy": "購買",
"Subscribe": "訂閱",
"Sample": "試閱",
"Download": "下載",
"Open & Read": "開啟並閱讀",
"Tags": "標籤",
"Tag": "標籤",
"First": "第一頁",
"Previous": "上一頁",
"Next": "下一頁",
"Last": "最後一頁",
"Cannot Load Page": "無法載入頁面",
"An error occurred": "發生錯誤"
}
+44 -21
View File
@@ -107,6 +107,7 @@ pub async fn download_file(
file_path: &str,
headers: HashMap<String, String>,
body: Option<String>,
single_threaded: Option<bool>,
on_progress: Channel<ProgressPayload>,
) -> Result<()> {
use futures::stream::{self, StreamExt};
@@ -116,32 +117,23 @@ pub async fn download_file(
const PART_SIZE: u64 = 1024 * 1024;
let client = reqwest::Client::new();
let force_single = single_threaded.unwrap_or(false);
// Check if server supports range requests
let range_resp = client.get(url).header("Range", "bytes=0-0").send().await?;
let accept_ranges = range_resp
.headers()
.get("accept-ranges")
.map(|v| v.to_str().unwrap_or(""))
.unwrap_or("")
.eq_ignore_ascii_case("bytes");
let total = range_resp
.headers()
.get("content-range")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split('/').nth(1))
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
if !accept_ranges || total == 0 {
// Fallback to original single-threaded logic
async fn single_threaded_download(
client: &reqwest::Client,
url: &str,
file_path: &str,
headers: &HashMap<String, String>,
body: &Option<String>,
on_progress: Channel<ProgressPayload>,
) -> Result<()> {
let mut request = if let Some(body) = body {
client.post(url).body(body)
client.post(url).body(body.clone())
} else {
client.get(url)
};
for (key, value) in headers.iter() {
for (key, value) in headers {
request = request.header(key, value);
}
@@ -168,7 +160,38 @@ pub async fn download_file(
});
}
file.flush().await?;
return Ok(());
Ok(())
}
if force_single {
return single_threaded_download(&client, url, file_path, &headers, &body, on_progress)
.await;
}
// Check if server supports range requests
let mut range_req = client.get(url).header("Range", "bytes=0-0");
for (key, value) in headers.iter() {
range_req = range_req.header(key, value);
}
let range_resp = range_req.send().await?;
let accept_ranges = range_resp
.headers()
.get("accept-ranges")
.map(|v| v.to_str().unwrap_or(""))
.unwrap_or("")
.eq_ignore_ascii_case("bytes");
let total = range_resp
.headers()
.get("content-range")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split('/').nth(1))
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
if !accept_ranges || total == 0 {
return single_threaded_download(&client, url, file_path, &headers, &body, on_progress)
.await;
}
// Multi-part download with range access
@@ -45,6 +45,7 @@ export const setupSupabaseMocks = async (
eq: vi.fn().mockResolvedValue(customResponses.update || { data: {}, error: null }),
})),
insert: vi.fn().mockResolvedValue(customResponses.insert || { data: {}, error: null }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
vi.mocked(createSupabaseAdminClient).mockReturnValue({
@@ -101,5 +102,6 @@ export const setupSupabaseMocks = async (
match: vi.fn().mockResolvedValue(customResponses.adminDelete || { data: {}, error: null }),
})),
})),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
};
@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from 'next/server';
async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
const url = request.nextUrl.searchParams.get('url');
const auth = request.nextUrl.searchParams.get('auth');
const stream = request.nextUrl.searchParams.get('stream');
if (!url) {
return NextResponse.json(
{ error: 'Missing URL parameter. Usage: /api/opds/proxy?url=YOUR_OPDS_URL' },
{ status: 400 },
);
}
try {
new URL(url);
} catch {
return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 });
}
try {
console.log(`[OPDS Proxy] ${method}: ${url}`);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const headers: HeadersInit = {
'User-Agent': 'Readest/1.0 (OPDS Browser)',
Accept: 'application/atom+xml, application/xml, text/xml, application/json, */*',
};
if (auth) {
headers['Authorization'] = auth;
}
const response = await fetch(url, {
method,
headers,
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
console.error(`[OPDS Proxy] HTTP ${response.status} for ${url}`);
if (method === 'HEAD') {
console.log(`[OPDS Proxy] Response headers:`, response.headers);
if (response.status === 401) {
return new NextResponse(null, {
status: 403,
headers: {
...Object.fromEntries(response.headers.entries()),
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.text();
if (response.status === 401) {
return new NextResponse(data, {
status: 403,
headers: {
...Object.fromEntries(response.headers.entries()),
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const contentType = response.headers.get('Content-Type') || 'text/xml';
const contentLength = response.headers.get('Content-Length');
if (method === 'HEAD') {
console.log(`[OPDS Proxy] HEAD Success: ${url}`);
return new NextResponse(null, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Length': contentLength || '',
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
if (stream === 'true' || (contentLength && parseInt(contentLength) > 1024 * 1024)) {
console.log(`[OPDS Proxy] Streaming: ${url}`);
return new NextResponse(response.body, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Length': contentLength || '',
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
} else {
const data = await response.text();
console.log(`[OPDS Proxy] Success: ${url} (${data.length} bytes)`);
return new NextResponse(data, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
} catch (error) {
console.error('[OPDS Proxy] Error:', error);
if (error instanceof Error) {
if (error.name === 'AbortError') {
return NextResponse.json(
{ error: 'Request timeout - the OPDS server took too long to respond' },
{ status: 504 },
);
}
return NextResponse.json(
{
error: error.message,
url: url,
hint: 'Check if the OPDS URL is accessible and returns valid OPDS/Atom/JSON content',
},
{ status: 500 },
);
}
return NextResponse.json({ error: 'Failed to fetch OPDS feed', url: url }, { status: 500 });
}
}
export async function GET(request: NextRequest) {
return handleRequest(request, 'GET');
}
export async function HEAD(request: NextRequest) {
return handleRequest(request, 'HEAD');
}
export async function OPTIONS(_: NextRequest) {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
@@ -42,8 +42,8 @@ export default function UpdateEmailPage() {
),
);
setEmail('');
} catch (err: any) {
setError(err.message || _('Failed to update email'));
} catch (err) {
setError(err instanceof Error ? err.message : _('Failed to update email'));
} finally {
setLoading(false);
}
@@ -49,7 +49,7 @@ export const generateBookshelfItems = (
booksGroup.books.push(book);
booksGroup.updatedAt = Math.max(booksGroup.updatedAt, book.updatedAt);
} else {
let groupName = fullGroupName;
const groupName = fullGroupName;
acc.push({
id: groupName === parentGroupName ? BOOK_UNGROUPED_ID : md5Fingerprint(groupName),
name: groupName === parentGroupName ? BOOK_UNGROUPED_NAME : groupName,
@@ -1,15 +1,23 @@
import clsx from 'clsx';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { IoFileTray } from 'react-icons/io5';
import { MdRssFeed } from 'react-icons/md';
import MenuItem from '@/components/MenuItem';
import Menu from '@/components/Menu';
interface ImportMenuProps {
setIsDropdownOpen?: (open: boolean) => void;
onImportBooks: () => void;
onOpenCatalogManager: () => void;
}
const ImportMenu: React.FC<ImportMenuProps> = ({ setIsDropdownOpen, onImportBooks }) => {
const ImportMenu: React.FC<ImportMenuProps> = ({
setIsDropdownOpen,
onImportBooks,
onOpenCatalogManager,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
@@ -18,6 +26,11 @@ const ImportMenu: React.FC<ImportMenuProps> = ({ setIsDropdownOpen, onImportBook
setIsDropdownOpen?.(false);
};
const handleOpenCatalogManager = () => {
onOpenCatalogManager();
setIsDropdownOpen?.(false);
};
return (
<Menu
className={clsx(
@@ -26,7 +39,16 @@ const ImportMenu: React.FC<ImportMenuProps> = ({ setIsDropdownOpen, onImportBook
)}
onCancel={() => setIsDropdownOpen?.(false)}
>
<MenuItem label={_('From Local File')} onClick={handleImportBooks} />
<MenuItem
label={_('From Local File')}
Icon={<IoFileTray className='h-5 w-5' />}
onClick={handleImportBooks}
/>
<MenuItem
label={_('OPDS Catalogs')}
Icon={<MdRssFeed className='h-5 w-5' />}
onClick={handleOpenCatalogManager}
/>
</Menu>
);
};
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { FaSearch } from 'react-icons/fa';
import { PiPlus } from 'react-icons/pi';
@@ -13,8 +13,8 @@ import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTrafficLight } from '@/hooks/useTrafficLight';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { debounce } from '@/utils/debounce';
import useShortcuts from '@/hooks/useShortcuts';
import WindowButtons from '@/components/WindowButtons';
@@ -27,6 +27,7 @@ interface LibraryHeaderProps {
isSelectMode: boolean;
isSelectAll: boolean;
onImportBooks: () => void;
onOpenCatalogManager: () => void;
onToggleSelectMode: () => void;
onSelectAll: () => void;
onDeselectAll: () => void;
@@ -36,6 +37,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
isSelectMode,
isSelectAll,
onImportBooks,
onOpenCatalogManager,
onToggleSelectMode,
onSelectAll,
onDeselectAll,
@@ -47,13 +49,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const { settings } = useSettingsStore();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const { currentBookshelf } = useLibraryStore();
const {
isTrafficLightVisible,
initializeTrafficLightStore,
initializeTrafficLightListeners,
setTrafficLightVisibility,
cleanupTrafficLightListeners,
} = useTrafficLightStore();
const { isTrafficLightVisible } = useTrafficLight();
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
const viewSettings = settings.globalViewSettings;
@@ -85,18 +81,6 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
debouncedUpdateQueryParam(newQuery);
};
useEffect(() => {
if (!appService?.hasTrafficLight) return;
initializeTrafficLightStore(appService);
initializeTrafficLightListeners();
setTrafficLightVisibility(true);
return () => {
cleanupTrafficLightListeners();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService?.hasTrafficLight]);
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
const currentBooksCount = currentBookshelf.reduce(
(acc, item) => acc + ('books' in item ? item.books.length : 1),
@@ -171,10 +155,13 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
'exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center',
isMobile ? 'dropdown-end' : 'dropdown-center',
)}
buttonClassName='p-0 h-6 min-h-6 w-6 flex items-center justify-center'
buttonClassName='p-0 h-6 min-h-6 w-6 flex items-center justify-center !bg-transparent'
toggleButton={<PiPlus role='none' className='m-0.5 h-5 w-5' />}
>
<ImportMenu onImportBooks={onImportBooks} />
<ImportMenu
onImportBooks={onImportBooks}
onOpenCatalogManager={onOpenCatalogManager}
/>
</Dropdown>
{isMobile ? null : (
<button
@@ -0,0 +1,28 @@
import { clsx } from 'clsx';
import { CatalogManager } from '@/app/opds/CatelogManager';
import { useTranslation } from '@/hooks/useTranslation';
import Dialog from '@/components/Dialog';
interface CatalogDialogProps {
onClose: () => void;
}
export function CatalogDialog({ onClose }: CatalogDialogProps) {
const _ = useTranslation();
return (
<Dialog
isOpen={true}
title={_('OPDS Catalog')}
onClose={onClose}
bgClassName={'sm:!bg-black/75'}
boxClassName='sm:min-w-[520px] sm:w-3/4 sm:h-[85%] sm:!max-w-screen-sm'
>
<div className={clsx('bg-base-100 relative flex flex-col overflow-y-auto')}>
<CatalogManager />
</div>
<form method='dialog' className='modal-backdrop'>
<button onClick={onClose}>{_('Close')}</button>
</form>
</Dialog>
);
}
@@ -49,6 +49,7 @@ import { BookMetadata } from '@/libs/document';
import { AboutWindow } from '@/components/AboutWindow';
import { BookDetailModal } from '@/components/metadata';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { CatalogDialog } from './components/OPDSDialog';
import { MigrateDataWindow } from './components/MigrateDataWindow';
import { useDragDropImport } from './hooks/useDragDropImport';
import { Toast } from '@/components/Toast';
@@ -88,6 +89,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const { safeAreaInsets: insets, isRoundedWindow } = useThemeStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore();
const [showCatalogManager, setShowCatalogManager] = useState(false);
const [loading, setLoading] = useState(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
const [isSelectMode, setIsSelectMode] = useState(false);
@@ -652,6 +654,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
onImportBooks={handleImportBooks}
onOpenCatalogManager={() => setShowCatalogManager(true)}
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
@@ -782,6 +785,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<UpdaterWindow />
<MigrateDataWindow />
{isSettingsDialogOpen && <SettingsDialog bookKey={''} />}
{showCatalogManager && <CatalogDialog onClose={() => setShowCatalogManager(false)} />}
<Toast />
</div>
);
@@ -0,0 +1,398 @@
'use client';
import { useState } from 'react';
import { IoAdd, IoTrash, IoOpenOutline, IoBook, IoEyeOff, IoEye } from 'react-icons/io5';
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { isWebAppPlatform } from '@/services/environment';
import { saveSysSettings } from '@/helpers/settings';
import { OPDSCatalog } from '@/types/opds';
import { isLanAddress } from '@/utils/network';
import { validateOPDSURL } from './utils/opdsUtils';
import ModalPortal from '@/components/ModalPortal';
const POPULAR_CATALOGS: OPDSCatalog[] = [
{
id: 'gutenberg',
name: 'Project Gutenberg',
url: 'https://m.gutenberg.org/ebooks.opds/',
description: "World's largest collection of free ebooks",
icon: '🏛️',
},
{
id: 'manybooks',
name: 'ManyBooks',
url: 'https://manybooks.net/opds/index.php',
description: 'Over 50,000 free ebooks',
icon: '📖',
},
];
async function validateOPDSCatalog(
url: string,
username?: string,
password?: string,
): Promise<{ valid: boolean; error?: string }> {
const result = await validateOPDSURL(url, username, password, isWebAppPlatform());
return { valid: result.isValid, error: result.error };
}
export function CatalogManager() {
const _ = useTranslation();
const router = useRouter();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const [catalogs, setCatalogs] = useState<OPDSCatalog[]>(() => settings.opdsCatalogs || []);
const [showAddDialog, setShowAddDialog] = useState(false);
const [newCatalog, setNewCatalog] = useState({
name: '',
url: '',
description: '',
username: '',
password: '',
});
const [showPassword, setShowPassword] = useState(false);
const [urlError, setUrlError] = useState('');
const [isValidating, setIsValidating] = useState(false);
const saveCatalogs = (updatedCatalogs: OPDSCatalog[]) => {
setCatalogs(updatedCatalogs);
saveSysSettings(envConfig, 'opdsCatalogs', updatedCatalogs);
};
const handleAddCatalog = async () => {
if (!newCatalog.name || !newCatalog.url) return;
if (
process.env['NODE_ENV'] === 'production' &&
isWebAppPlatform() &&
isLanAddress(newCatalog.url)
) {
setUrlError(_('Adding LAN addresses is not supported in the web app version.'));
return;
}
setIsValidating(true);
setUrlError('');
const validation = await validateOPDSCatalog(
newCatalog.url,
newCatalog.username || undefined,
newCatalog.password || undefined,
);
if (!validation.valid) {
setUrlError(validation.error || _('Invalid OPDS catalog. Please check the URL.'));
setIsValidating(false);
return;
}
const catalog: OPDSCatalog = {
id: Date.now().toString(),
name: newCatalog.name,
url: newCatalog.url,
description: newCatalog.description,
username: newCatalog.username || undefined,
password: newCatalog.password || undefined,
};
saveCatalogs([catalog, ...catalogs]);
setNewCatalog({ name: '', url: '', description: '', username: '', password: '' });
setUrlError('');
setIsValidating(false);
setShowAddDialog(false);
};
const handleAddPopularCatalog = (popularCatalog: OPDSCatalog) => {
if (catalogs.some((c) => c.url === popularCatalog.url)) {
return;
}
saveCatalogs([...catalogs, { ...popularCatalog }]);
};
const handleRemoveCatalog = (id: string) => {
saveCatalogs(catalogs.filter((c) => c.id !== id));
};
const handleOpenCatalog = (catalog: OPDSCatalog) => {
const params = new URLSearchParams({ url: catalog.url });
if (catalog.username) params.set('username', catalog.username);
if (catalog.password) params.set('password', catalog.password);
router.push(`/opds?${params.toString()}`);
};
const handleCloseDialog = () => {
setShowAddDialog(false);
setNewCatalog({ name: '', url: '', description: '', username: '', password: '' });
setUrlError('');
setShowPassword(false);
};
return (
<div className='container max-w-2xl'>
<div className='mb-8'>
<h1 className='mb-2 text-lg font-bold'>{_('OPDS Catalogs')}</h1>
<p className='text-base-content/70 text-sm'>
{_('Browse and download books from online catalogs')}
</p>
</div>
{/* My Catalogs */}
<section className='mb-12 text-base'>
<div className='mb-4 flex items-center justify-between'>
<h2 className='font-semibold'>{_('My Catalogs')}</h2>
<button onClick={() => setShowAddDialog(true)} className='btn btn-primary btn-sm'>
<IoAdd className='h-4 w-4' />
{_('Add Catalog')}
</button>
</div>
{catalogs.length === 0 ? (
<div className='border-base-300 rounded-lg border-2 border-dashed p-12 text-center'>
<IoBook className='text-base-content/30 mx-auto mb-4 h-12 w-12' />
<h3 className='mb-2 font-semibold'>{_('No catalogs yet')}</h3>
<p className='text-base-content/70 mb-4 text-sm'>
{_('Add your first OPDS catalog to start browsing books')}
</p>
<button onClick={() => setShowAddDialog(true)} className='btn btn-primary btn-sm'>
{_('Add Your First Catalog')}
</button>
</div>
) : (
<div className='grid gap-4 sm:grid-cols-2'>
{catalogs.map((catalog) => (
<div
key={catalog.id}
className='card bg-base-100 border-base-300 h-full border shadow-sm transition-shadow hover:shadow-md'
>
<div className='card-body h-full justify-between p-4'>
<div className='flex items-center justify-between'>
<div className='min-w-0 flex-1'>
<div className='mb-1 flex items-center justify-between'>
<h3 className='card-title truncate text-sm'>
{catalog.icon && <span className='mr-2'>{catalog.icon}</span>}
{catalog.name}
</h3>
<button
onClick={() => handleRemoveCatalog(catalog.id)}
className='btn btn-ghost btn-xs btn-square'
title='Remove'
>
<IoTrash className='h-4 w-4' />
</button>
</div>
{catalog.description && (
<p className='text-base-content/70 mb-2 line-clamp-1 h-6 text-sm sm:line-clamp-2 sm:h-10'>
{catalog.description}
</p>
)}
<p className='text-base-content/50 truncate text-xs'>{catalog.url}</p>
{catalog.username && (
<p className='text-base-content/50 mt-1 text-xs'>
{_('Username')}: {catalog.username}
</p>
)}
</div>
</div>
<div className='card-actions mt-4 justify-end'>
<button
onClick={() => handleOpenCatalog(catalog)}
className='btn btn-sm btn-primary'
>
<IoOpenOutline className='h-4 w-4' />
{_('Browse')}
</button>
</div>
</div>
</div>
))}
</div>
)}
</section>
{/* Popular Catalogs */}
<section className='text-base'>
<h2 className='mb-4 font-semibold'>{_('Popular Catalogs')}</h2>
<div className='grid gap-4 sm:grid-cols-2'>
{POPULAR_CATALOGS.map((catalog) => {
const isAdded = catalogs.some((c) => c.url === catalog.url);
return (
<div
key={catalog.id}
className='card bg-base-100 border-base-300 border shadow-sm transition-shadow hover:shadow-md'
>
<div className='card-body p-4'>
<h3 className='card-title mb-1 text-sm'>
{catalog.icon && <span className='mr-2'>{catalog.icon}</span>}
{catalog.name}
</h3>
{catalog.description && (
<p className='text-base-content/70 line-clamp-2 text-sm'>
{catalog.description}
</p>
)}
<div className='card-actions mt-4 justify-end gap-2'>
{!isAdded && (
<button
onClick={() => handleAddPopularCatalog(catalog)}
className='btn btn-sm'
>
<IoAdd className='h-4 w-4' />
{_('Add')}
</button>
)}
<button
onClick={() => handleOpenCatalog(catalog)}
className='btn btn-sm btn-primary'
>
<IoOpenOutline className='h-4 w-4' />
{_('Browse')}
</button>
</div>
</div>
</div>
);
})}
</div>
</section>
{/* Add Catalog Dialog */}
{showAddDialog && (
<ModalPortal>
<dialog className='modal modal-open'>
<div className='modal-box'>
<h3 className='mb-4 text-lg font-bold'>{_('Add OPDS Catalog')}</h3>
<form
onSubmit={(e) => {
e.preventDefault();
handleAddCatalog();
}}
className='space-y-4'
>
<div className='form-control'>
<div className='label'>
<span className='label-text'>{_('Catalog Name')} *</span>
</div>
<input
type='text'
value={newCatalog.name}
onChange={(e) => setNewCatalog({ ...newCatalog, name: e.target.value })}
placeholder={_('My Calibre Library')}
className='input input-bordered placeholder:text-sm'
disabled={isValidating}
required
/>
</div>
<div className='form-control'>
<div className='label'>
<span className='label-text'>{_('OPDS URL')} *</span>
</div>
<input
type='url'
value={newCatalog.url}
onChange={(e) => setNewCatalog({ ...newCatalog, url: e.target.value })}
placeholder='https://example.com/opds'
className='input input-bordered placeholder:text-sm'
disabled={isValidating}
required
/>
{urlError && (
<div className='label'>
<span className='label-text-alt text-error'>{urlError}</span>
</div>
)}
</div>
<div className='form-control'>
<div className='label'>
<span className='label-text'>{_('Username (optional)')}</span>
</div>
<input
type='text'
value={newCatalog.username}
onChange={(e) => setNewCatalog({ ...newCatalog, username: e.target.value })}
placeholder={_('Username')}
className='input input-bordered placeholder:text-sm'
disabled={isValidating}
autoComplete='username'
/>
</div>
<div className='form-control'>
<div className='label'>
<span className='label-text'>{_('Password (optional)')}</span>
</div>
<div className='relative'>
<input
type={showPassword ? 'text' : 'password'}
value={newCatalog.password}
onChange={(e) => setNewCatalog({ ...newCatalog, password: e.target.value })}
placeholder={_('Password')}
className='input input-bordered w-full pr-10 placeholder:text-sm'
disabled={isValidating}
autoComplete='current-password'
/>
<button
type='button'
onClick={() => setShowPassword(!showPassword)}
className='btn btn-ghost btn-sm btn-square absolute right-1 top-1/2 -translate-y-1/2'
tabIndex={-1}
>
{showPassword ? (
<IoEyeOff className='h-4 w-4' />
) : (
<IoEye className='h-4 w-4' />
)}
</button>
</div>
</div>
<div className='form-control'>
<div className='label'>
<span className='label-text'>{_('Description (optional)')}</span>
</div>
<textarea
value={newCatalog.description}
onChange={(e) => setNewCatalog({ ...newCatalog, description: e.target.value })}
placeholder={_('A brief description of this catalog')}
className='textarea textarea-bordered text-sm placeholder:text-sm'
rows={2}
disabled={isValidating}
/>
</div>
<div className='modal-action'>
<button
type='button'
onClick={handleCloseDialog}
className='btn'
disabled={isValidating}
>
{_('Cancel')}
</button>
<button type='submit' className='btn btn-primary' disabled={isValidating}>
{isValidating ? (
<>
<span className='loading loading-spinner loading-sm'></span>
{_('Validating...')}
</>
) : (
_('Add Catalog')
)}
</button>
</div>
</form>
</div>
<form method='dialog' className='modal-backdrop'>
<button onClick={() => setShowAddDialog(false)}>{_('Close')}</button>
</form>
</dialog>
</ModalPortal>
)}
</div>
);
}
+249
View File
@@ -0,0 +1,249 @@
'use client';
import { useMemo } from 'react';
import { IoChevronBack, IoChevronForward, IoFilter } from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import { OPDSFeed, OPDSLink } from '@/types/opds';
import { PublicationCard } from './PublicationCard';
import { NavigationCard } from './NavigationCard';
import { groupByArray } from './utils/opdsUtils';
interface FeedViewProps {
feed: OPDSFeed;
baseURL: string;
resolveURL: (url: string, base: string) => string;
onNavigate: (url: string) => void;
onPublicationSelect: (groupIndex: number, itemIndex: number) => void;
onGenerateCachedImageUrl: (url: string) => Promise<string>;
isOPDSCatalog: (type?: string) => boolean;
}
const gridClassName = 'grid grid-cols-3 gap-4 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6';
const navigationClassName =
'grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 max-[450px]:grid-cols-1';
export function FeedView({
feed,
baseURL,
resolveURL,
onNavigate,
onPublicationSelect,
onGenerateCachedImageUrl,
}: FeedViewProps) {
const _ = useTranslation();
const linksByRel = useMemo(() => groupByArray(feed.links, (link) => link.rel), [feed.links]);
const pagination = useMemo(() => {
return ['first', 'previous', 'next', 'last'].map((rel) => linksByRel.get(rel));
}, [linksByRel]);
const hasFacets = feed.facets && feed.facets.length > 0;
const handlePaginationClick = (links?: OPDSLink[]) => {
if (links && links.length > 0) {
const url = resolveURL(links[0]?.href || '', baseURL);
onNavigate(url);
}
};
const handleNavigationClick = (href: string) => {
const url = resolveURL(href, baseURL);
onNavigate(url);
};
return (
<div className='container mx-auto max-w-7xl px-4 py-6'>
{/* Header */}
<div className='opds-header mb-6'>
{feed.metadata?.title && <h1 className='mb-2 text-xl font-bold'>{feed.metadata.title}</h1>}
{feed.metadata?.subtitle && (
<p className='text-base-content/70 text-sm'>{feed.metadata.subtitle}</p>
)}
</div>
<div className='flex gap-6'>
{/* Facets Sidebar */}
{hasFacets && (
<aside className='hidden w-64 flex-shrink-0 lg:block'>
<div className='sticky top-6'>
<div className='mb-4 flex items-center gap-2'>
<IoFilter className='h-5 w-5' />
<h2 className='text-lg font-semibold'>Filters</h2>
</div>
<div className='space-y-6'>
{feed.facets?.map((facet, index: number) => (
<section key={index}>
{facet.metadata?.title && (
<h3 className='text-base-content/70 mb-2 text-sm font-medium'>
{facet.metadata.title}
</h3>
)}
<ul className='space-y-1'>
{facet.links.map((link, linkIndex: number) => {
const isActive = link.rel === 'self' || link.rel?.includes('self');
const href = resolveURL(link.href, baseURL);
return (
<li key={linkIndex}>
<button
onClick={() => handleNavigationClick(href)}
className={`w-full rounded px-3 py-1.5 text-left text-sm transition-colors ${
isActive
? 'bg-primary text-primary-content font-medium'
: 'hover:bg-base-200'
}`}
>
<div className='flex items-center justify-between'>
<span className='truncate'>{link.title || 'Untitled'}</span>
{link.properties?.numberOfItems && (
<span className='ml-2 text-xs opacity-70'>
{link.properties.numberOfItems}
</span>
)}
</div>
</button>
</li>
);
})}
</ul>
</section>
))}
</div>
</div>
</aside>
)}
{/* Main Content */}
<div className='min-w-0 flex-1'>
{/* Navigation Items */}
{feed.navigation && feed.navigation.length > 0 && (
<section className='opds-navigation mb-8'>
<div className={navigationClassName}>
{feed.navigation.map((item, index: number) => (
<NavigationCard
key={index}
item={item}
baseURL={baseURL}
onClick={handleNavigationClick}
resolveURL={resolveURL}
/>
))}
</div>
</section>
)}
{/* Publications */}
{feed.publications && feed.publications.length > 0 && (
<section className='opds-publications mb-8'>
<div className={gridClassName}>
{feed.publications.map((pub, index: number) => (
<PublicationCard
key={index}
publication={pub}
baseURL={baseURL}
onClick={() => onPublicationSelect(-1, index)}
resolveURL={resolveURL}
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
/>
))}
</div>
</section>
)}
{/* Groups */}
{feed.groups?.map((group, groupIndex: number) => (
<section key={groupIndex} className='mb-12'>
{group.metadata && (
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-2xl font-bold'>{group.metadata.title}</h2>
{group.links && group.links.length > 0 && (
<button
onClick={() => {
const link = group.links[0];
const url = resolveURL(link?.href || '', baseURL);
handleNavigationClick(url);
}}
className='btn btn-sm btn-ghost'
>
{_('View All')}
</button>
)}
</div>
)}
{group.navigation && (
<div className={`opds-navigation ${gridClassName}`}>
{group.navigation.map((item, itemIndex: number) => (
<NavigationCard
key={itemIndex}
item={item}
baseURL={baseURL}
onClick={handleNavigationClick}
resolveURL={resolveURL}
/>
))}
</div>
)}
{group.publications && (
<div className={`opds-publications ${gridClassName}`}>
{group.publications.map((pub, itemIndex: number) => (
<PublicationCard
key={itemIndex}
publication={pub}
baseURL={baseURL}
onClick={() => onPublicationSelect(groupIndex, itemIndex)}
resolveURL={resolveURL}
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
/>
))}
</div>
)}
</section>
))}
{/* Pagination */}
{pagination.some((links) => links && links.length > 0) && (
<nav className='mt-8 flex justify-center gap-2'>
<button
onClick={() => handlePaginationClick(pagination[0])}
disabled={!pagination[0]}
className='btn btn-sm'
title={_('First')}
>
<IoChevronBack className='h-4 w-4' />
<IoChevronBack className='-ml-3 h-4 w-4' />
</button>
<button
onClick={() => handlePaginationClick(pagination[1])}
disabled={!pagination[1]}
className='btn btn-sm'
title={_('Previous')}
>
<IoChevronBack className='h-4 w-4' />
{_('Previous')}
</button>
<button
onClick={() => handlePaginationClick(pagination[2])}
disabled={!pagination[2]}
className='btn btn-sm'
title={_('Next')}
>
{_('Next')}
<IoChevronForward className='h-4 w-4' />
</button>
<button
onClick={() => handlePaginationClick(pagination[3])}
disabled={!pagination[3]}
className='btn btn-sm'
title={_('Last')}
>
<IoChevronForward className='h-4 w-4' />
<IoChevronForward className='-ml-3 h-4 w-4' />
</button>
</nav>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,92 @@
'use client';
import clsx from 'clsx';
import { useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { GiBookshelf } from 'react-icons/gi';
import { IoChevronBack, IoChevronForward, IoHome } from 'react-icons/io5';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useTrafficLight } from '@/hooks/useTrafficLight';
import { navigateToLibrary } from '@/utils/nav';
interface NavigationProps {
currentURL: string;
startURL?: string;
onNavigate: (url: string) => void;
onBack?: () => void;
onForward?: () => void;
canGoBack: boolean;
canGoForward: boolean;
}
export function Navigation({
startURL,
onNavigate,
onBack,
onForward,
canGoBack,
canGoForward,
}: NavigationProps) {
const _ = useTranslation();
const router = useRouter();
const { appService } = useEnv();
const { isTrafficLightVisible } = useTrafficLight();
const handleGoHome = useCallback(() => {
if (startURL) {
onNavigate(startURL);
}
}, [startURL, onNavigate]);
const handleGoLibrary = useCallback(() => {
navigateToLibrary(router, '', {}, true);
}, [router]);
return (
<header
className={clsx(
'navbar min-h-0 px-2',
'flex h-[48px] w-full items-center',
appService?.isMobile ? '' : 'border-base-300 bg-base-200 border-b',
)}
>
<div className={clsx('navbar-start gap-1', isTrafficLightVisible && '!pl-16')}>
{onBack && (
<button
className='btn btn-ghost btn-sm px-3 disabled:bg-transparent'
onClick={onBack}
disabled={!canGoBack}
title={_('Back')}
>
<IoChevronBack className='h-6 w-6' />
</button>
)}
{onForward && (
<button
className='btn btn-ghost btn-sm disabled:bg-transparent'
onClick={onForward}
disabled={!canGoForward}
title={_('Forward')}
>
<IoChevronForward className='h-6 w-6' />
</button>
)}
</div>
<div className='navbar-center'>
<h1 className='max-w-md truncate text-base font-semibold'>{_('OPDS Catalog')}</h1>
</div>
<div className='navbar-end gap-2'>
<button className='btn btn-ghost btn-sm' onClick={handleGoHome} title={_('Home')}>
<IoHome className='h-5 w-5' />
</button>
<button className='btn btn-ghost btn-sm' onClick={handleGoLibrary} title={_('Library')}>
<GiBookshelf className='h-5 w-5' />
</button>
</div>
</header>
);
}
@@ -0,0 +1,43 @@
'use client';
import { IoChevronForward } from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import { OPDSNavigationItem, SYMBOL } from '@/types/opds';
interface NavigationCardProps {
item: OPDSNavigationItem;
baseURL: string;
onClick: (href: string) => void;
resolveURL: (url: string, base: string) => string;
}
export function NavigationCard({ item, baseURL, onClick, resolveURL }: NavigationCardProps) {
const _ = useTranslation();
const href = resolveURL(item.href || '', baseURL);
const summary = item[SYMBOL.SUMMARY];
return (
<div
role='none'
onClick={() => onClick(href)}
className='card bg-base-100 border-base-300 hover:bg-base-200/30 cursor-pointer rounded-lg border shadow-sm transition-shadow'
>
<div className='card-body flex justify-center p-4'>
<div className='flex items-start gap-3'>
<div className='min-w-0 flex-1'>
<h3 className='line-clamp-1 text-sm font-semibold'>{item.title || _('Untitled')}</h3>
{item.properties?.numberOfItems && (
<p className='text-base-content/60 text-xs'>
{_('{{count}} items', { count: item.properties.numberOfItems })}
</p>
)}
{summary && <p className='text-base-content/70 mt-1 line-clamp-1 text-xs'>{summary}</p>}
</div>
<div className='flex h-full items-center justify-center'>
<IoChevronForward className='text-base-content/40 h-5 w-5 flex-shrink-0' />
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,92 @@
'use client';
import { useMemo } from 'react';
import { groupByArray } from './utils/opdsUtils';
import { CachedImage } from '@/components/CachedImage';
import { OPDSPublication, REL } from '@/types/opds';
interface PublicationCardProps {
publication: OPDSPublication;
baseURL: string;
onClick: () => void;
resolveURL: (url: string, base: string) => string;
onGenerateCachedImageUrl: (url: string) => Promise<string>;
}
export function PublicationCard({
publication,
baseURL,
onClick,
resolveURL,
onGenerateCachedImageUrl,
}: PublicationCardProps) {
const linksByRel = useMemo(
() => groupByArray(publication.links, (link) => link.rel),
[publication.links],
);
const thumbnailImage = useMemo(() => {
const thumbnails = publication.images?.filter((img) =>
REL.THUMBNAIL.some((rel: string) => img.rel?.includes(rel)),
);
return thumbnails?.[0] || publication.images?.[0];
}, [publication.images]);
const coverImage = useMemo(() => {
const covers = publication.images?.filter((img) =>
REL.COVER.some((rel: string) => img.rel?.includes(rel)),
);
return covers?.[0];
}, [publication.images]);
const imageLink = coverImage || thumbnailImage;
const imageUrl = imageLink ? resolveURL(imageLink.href, baseURL) : null;
const authors = useMemo(() => {
return publication.metadata?.author
?.map((author) => (typeof author === 'string' ? author : author.name))
.join(', ');
}, [publication.metadata?.author]);
const price = useMemo(() => {
const priceLink = publication.links?.find((link) => link.properties?.price);
if (priceLink?.properties?.price) {
const { currency, value } = priceLink.properties.price;
return `${currency} ${value}`;
}
if (linksByRel.has(REL.ACQ + '/open-access')) {
return 'Open Access';
}
return null;
}, [publication.links, linksByRel]);
return (
<div
role='none'
onClick={onClick}
className='card bg-base-100 cursor-pointer transition-shadow'
>
<figure className='bg-base-200 relative aspect-[28/41] rounded-none shadow-md'>
<CachedImage
src={imageUrl}
alt={publication.metadata?.title || 'Book cover'}
fill
className='object-cover'
sizes='(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw'
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
/>
</figure>
<div className='py-3'>
<h3 className='card-title line-clamp-1 text-sm'>
{publication.metadata?.title || 'Untitled'}
</h3>
{authors && <p className='text-base-content/70 line-clamp-1 text-xs'>{authors}</p>}
{price && (
<div className='card-actions mt-2 justify-end'>
<div className='badge badge-outline badge-sm'>{price}</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,316 @@
'use client';
import clsx from 'clsx';
import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { IoPricetag } from 'react-icons/io5';
import { Book } from '@/types/book';
import { groupByArray } from './utils/opdsUtils';
import { useTranslation } from '@/hooks/useTranslation';
import { formatDate, formatLanguage } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { navigateToReader } from '@/utils/nav';
import { CachedImage } from '@/components/CachedImage';
import { OPDSLink, OPDSPublication, REL, SYMBOL } from '@/types/opds';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/MenuItem';
interface PublicationViewProps {
publication: OPDSPublication;
baseURL: string;
resolveURL: (url: string, base: string) => string;
onDownload: (
href: string,
type?: string,
onProgress?: (progress: { progress: number; total: number }) => void,
) => Promise<Book | null | undefined>;
onGenerateCachedImageUrl: (url: string) => Promise<string>;
}
export function PublicationView({
publication,
baseURL,
resolveURL,
onDownload,
onGenerateCachedImageUrl,
}: PublicationViewProps) {
const _ = useTranslation();
const router = useRouter();
const [downloading, setDownloading] = useState(false);
const [downloaded, setDownloaded] = useState(false);
const [downloadedBook, setDownloadedBook] = useState<Book | null>(null);
const [progress, setProgress] = useState<number | null>(null);
const linksByRel = useMemo(
() => groupByArray(publication.links, (link) => link.rel),
[publication.links],
);
const coverImage = useMemo(() => {
const covers = publication.images?.filter((img) =>
REL.COVER.some((rel: string) => img.rel?.includes(rel)),
);
return covers?.[0] || publication.images?.[0];
}, [publication.images]);
const imageUrl = coverImage ? resolveURL(coverImage.href, baseURL) : null;
const authors = useMemo(() => {
return publication.metadata?.author?.map((author) =>
typeof author === 'string' ? author : author.name,
);
}, [publication.metadata?.author]);
const acquisitionLinks = useMemo(() => {
const links: Array<{ rel: string; links: OPDSLink[] }> = [];
for (const [rel, linkList] of Array.from(linksByRel.entries())) {
if (rel?.startsWith(REL.ACQ)) {
links.push({ rel, links: linkList });
}
}
return links;
}, [linksByRel]);
const handleActionButton = async (href: string, type?: string) => {
if (downloadedBook) {
navigateToReader(router, [downloadedBook.hash]);
return;
}
setDownloading(true);
setDownloaded(false);
setProgress(null);
try {
const book = await onDownload(href, type, (prog) => {
if (prog.total > 0) {
const percentage = Math.floor((prog.progress / prog.total) * 100);
setProgress(percentage);
}
});
if (book) {
setDownloadedBook(book);
}
eventDispatcher.dispatch('toast', { type: 'success', message: _('Download completed') });
} catch (error) {
console.error('Download failed:', error);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Download failed') + `:\n${href}`,
});
} finally {
setDownloading(false);
setProgress(null);
}
};
const getAcquisitionLabel = (rel: string): string => {
if (rel === REL.ACQ + '/open-access') return _('Open Access');
if (rel === REL.ACQ + '/borrow') return _('Borrow');
if (rel === REL.ACQ + '/buy') return _('Buy');
if (rel === REL.ACQ + '/subscribe') return _('Subscribe');
if (rel === REL.ACQ + '/sample') return _('Sample');
return _('Download');
};
const content = publication.metadata?.[SYMBOL.CONTENT];
const description = publication.metadata?.description;
return (
<div className='flex w-full flex-col px-6 py-6'>
<div className='mb-6 flex w-full flex-row items-start gap-6 max-[320px]:flex-col'>
<div className='h-44 flex-shrink-0 sm:h-56 md:h-64'>
<div className='bg-base-200 relative aspect-[28/41] h-full overflow-hidden rounded-none shadow-lg'>
<CachedImage
src={imageUrl}
alt={publication.metadata?.title || 'Book cover'}
fill
className='object-cover'
sizes='(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw'
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
/>
</div>
</div>
<div className='flex h-44 min-w-0 flex-col justify-between max-[320px]:h-32 sm:h-56 md:h-64'>
<div className='flex flex-col'>
{publication.metadata?.subtitle && (
<p className='text-base-content/60 mb-1 text-sm'>{publication.metadata.subtitle}</p>
)}
<h1 className='mb-2 text-base font-bold'>
{publication.metadata?.title || 'Untitled'}
</h1>
{authors && authors.length > 0 && (
<p className='text-base-content/70 text-sm'>{authors.join(', ')}</p>
)}
</div>
{acquisitionLinks.length > 0 && (
<div className='flex flex-wrap gap-2'>
{acquisitionLinks.map(({ rel, links }) => (
<div key={rel} className='flex gap-1'>
{links.length === 1 ? (
<button
onClick={() => handleActionButton(links[0]!.href, links[0]!.type)}
disabled={downloading}
className={clsx(
'btn btn-primary min-w-20 rounded-3xl',
downloaded && 'btn-success',
)}
>
{downloadedBook ? _('Open & Read') : getAcquisitionLabel(rel)}
</button>
) : (
<Dropdown
label={_('Download')}
className='dropdown-bottom flex justify-center'
buttonClassName='btn btn-ghost p-0 hover:bg-transparent'
disabled={downloading}
toggleButton={
<div
role='button'
tabIndex={0}
className={clsx(
`btn btn-primary min-w-20 rounded-3xl ${downloading ? 'btn-disabled' : ''}`,
downloaded && 'btn-success',
)}
>
{downloadedBook ? _('Open') : getAcquisitionLabel(rel)}
</div>
}
>
<div
className={clsx(
'delete-menu dropdown-content dropdown-center no-triangle',
'border-base-300 !bg-base-200 z-20 mt-2 max-w-[80vw] shadow-2xl',
)}
>
{links.map((link, idx: number) => (
<MenuItem
key={idx}
noIcon
transient
label={link.title || link.type || ''}
onClick={() => handleActionButton(link.href, link.type)}
/>
))}
</div>
</Dropdown>
)}
</div>
))}
<div className='flex h-12 w-12 items-center justify-center'>
{downloading && progress && progress > 0 && (
<div
className='radial-progress flex items-center justify-center text-xs'
style={{ '--value': progress, '--size': '2.5rem' } as React.CSSProperties}
aria-valuenow={progress || 0}
role='progressbar'
>
{progress}%
</div>
)}
</div>
</div>
)}
</div>
</div>
<div className='max-w-xl items-start space-y-6'>
{/* Description */}
{(content || description) && (
<div className='prose prose-sm max-w-none'>
{content ? (
<div
dangerouslySetInnerHTML={{
__html:
content.type === 'html' || content.type === 'xhtml'
? content.value
: content.value,
}}
/>
) : (
<p>{description}</p>
)}
</div>
)}
{/* Metadata Table */}
<div>
<style>
{`
.table :where(th, td) {
padding: 10px 0px;
}
`}
</style>
<table className='table text-sm'>
<tbody>
{publication.metadata?.publisher && (
<tr>
<th className='w-32'>{_('Publisher')}</th>
<td>
{typeof publication.metadata.publisher === 'string'
? publication.metadata.publisher
: Array.isArray(publication.metadata.publisher)
? publication.metadata.publisher
.map((p) => (typeof p === 'string' ? p : p.name))
.join(', ')
: publication.metadata.publisher.name}
</td>
</tr>
)}
{publication.metadata?.published && (
<tr>
<th>{_('Published')}</th>
<td>{formatDate(publication.metadata.published, true)}</td>
</tr>
)}
{publication.metadata?.language && (
<tr>
<th>{_('Language')}</th>
<td>
{Array.isArray(publication.metadata.language)
? publication.metadata.language
.map((lang: string) => formatLanguage(lang))
.join(', ')
: formatLanguage(publication.metadata.language)}
</td>
</tr>
)}
{publication.metadata?.identifier && (
<tr>
<th>{_('Identifier')}</th>
<td>
<code className='text-xs'>{publication.metadata.identifier}</code>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Tags/Subjects */}
{publication.metadata?.subject && publication.metadata.subject.length > 0 && (
<div>
<h2 className='mb-3 text-sm font-semibold'>{_('Tags')}</h2>
<div className='flex flex-wrap gap-2'>
{publication.metadata.subject.map((subject, index: number) => {
const tag =
typeof subject === 'string' ? subject : subject.name || subject.code || _('Tag');
return (
<div key={index} className='badge badge-outline max-w-full gap-1'>
<IoPricetag className='h-3 min-h-3 w-3 min-w-3' />
<div className='truncate' title={tag}>
{tag}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,105 @@
'use client';
import { useState, FormEvent } from 'react';
import { IoSearch } from 'react-icons/io5';
import { OPDSSearch } from '@/types/opds';
interface SearchViewProps {
search: OPDSSearch;
baseURL: string;
onNavigate: (url: string) => void;
resolveURL: (url: string, base: string) => string;
}
export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchViewProps) {
const [formData, setFormData] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
search.params?.forEach((param) => {
initial[param.name] = param.value || '';
});
return initial;
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const map = new Map<string | null, Map<string | null, string>>();
for (const param of search.params || []) {
const value = formData[param.name] || '';
const ns = param.ns ?? null;
if (map.has(ns)) {
map.get(ns)!.set(param.name, value);
} else {
map.set(ns, new Map([[param.name, value]]));
}
}
const searchURL = search.search(map);
const resolvedURL = resolveURL(searchURL, baseURL);
onNavigate(resolvedURL);
};
const handleInputChange = (name: string, value: string) => {
setFormData((prev) => ({ ...prev, [name]: value }));
};
const getParamLabel = (name: string): string => {
const labels: Record<string, string> = {
searchTerms: 'Search',
query: 'Query',
title: 'Title',
author: 'Author',
publisher: 'Publisher',
language: 'Language',
subject: 'Subject',
};
return labels[name] || name;
};
return (
<div className='container mx-auto max-w-2xl px-4 py-12'>
<div className='mb-8 text-center'>
<h1 className='mb-2 text-3xl font-bold'>{search.metadata?.title || 'Search'}</h1>
{search.metadata?.description && (
<p className='text-base-content/70'>{search.metadata.description}</p>
)}
</div>
<form onSubmit={handleSubmit} className='space-y-4'>
{(search.params || []).map((param) => (
<div key={`${param.ns || 'default'}-${param.name}`} className='form-control'>
<label className='label'>
<span className='label-text font-medium'>
{getParamLabel(param.name)}
{param.required && <span className='text-error ml-1'>*</span>}
</span>
</label>
<input
type={param.name === 'searchTerms' || param.name === 'query' ? 'search' : 'text'}
value={formData[param.name] || ''}
onChange={(e) => handleInputChange(param.name, e.target.value)}
required={param.required}
placeholder={`Enter ${getParamLabel(param.name).toLowerCase()}`}
className='input input-bordered w-full'
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={
param.name === 'searchTerms' ||
param.name === 'query' ||
search.params[0]?.name === param.name
}
/>
</div>
))}
<div className='pt-4'>
<button type='submit' className='btn btn-primary w-full'>
<IoSearch className='h-5 w-5' />
Search
</button>
</div>
</form>
</div>
);
}
+516
View File
@@ -0,0 +1,516 @@
'use client';
import { useEffect, useState, useCallback, useRef } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { isOPDSCatalog, getPublication, getFeed, getOpenSearch } from 'foliate-js/opds.js';
import { md5 } from 'js-md5';
import { openUrl } from '@tauri-apps/plugin-opener';
import { useEnv } from '@/context/EnvContext';
import { isWebAppPlatform } from '@/services/environment';
import { FeedView } from './FeedView';
import { PublicationView } from './PublicationView';
import { SearchView } from './SearchView';
import { Navigation } from './Navigation';
import { getBaseFilename } from '@/utils/path';
import { downloadFile } from '@/libs/storage';
import { Toast } from '@/components/Toast';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useLibrary } from '@/hooks/useLibrary';
import { eventDispatcher } from '@/utils/event';
import { getFileExtFromMimeType } from '@/libs/document';
import { OPDSFeed, OPDSPublication, OPDSSearch } from '@/types/opds';
import { MIME, parseMediaType, resolveURL } from './utils/opdsUtils';
import { getProxiedURL, fetchWithAuth, probeAuth, needsProxy } from './utils/opdsReq';
import clsx from 'clsx';
import { useThemeStore } from '@/store/themeStore';
import { useTheme } from '@/hooks/useTheme';
type ViewMode = 'feed' | 'publication' | 'search' | 'loading' | 'error';
interface OPDSState {
feed?: OPDSFeed;
publication?: OPDSPublication;
search?: OPDSSearch;
baseURL: string;
currentURL: string;
startURL?: string;
}
interface HistoryEntry {
url: string;
state: OPDSState;
viewMode: ViewMode;
selectedPublication: { groupIndex: number; itemIndex: number } | null;
}
export default function BrowserPage() {
const _ = useTranslation();
const router = useRouter();
const { appService } = useEnv();
const { libraryLoaded } = useLibrary();
const { safeAreaInsets, isRoundedWindow } = useThemeStore();
const [viewMode, setViewMode] = useState<ViewMode>('loading');
const [state, setState] = useState<OPDSState>({
baseURL: '',
currentURL: '',
});
const [selectedPublication, setSelectedPublication] = useState<{
groupIndex: number;
itemIndex: number;
} | null>(null);
const [error, setError] = useState<Error | null>(null);
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const searchParams = useSearchParams();
const usernameRef = useRef(searchParams?.get('username'));
const passwordRef = useRef(searchParams?.get('password'));
const loadingOPDSRef = useRef(false);
const startURLRef = useRef<string | undefined>(undefined);
const historyIndexRef = useRef(-1);
const isNavigatingHistoryRef = useRef(false);
useTheme({ systemUIVisible: false });
// Keep refs in sync with state
useEffect(() => {
startURLRef.current = state.startURL;
}, [state.startURL]);
useEffect(() => {
historyIndexRef.current = historyIndex;
}, [historyIndex]);
const addToHistory = useCallback(
(
url: string,
newState: OPDSState,
viewMode: ViewMode,
selectedPub: { groupIndex: number; itemIndex: number } | null = null,
) => {
const newEntry: HistoryEntry = {
url,
state: newState,
viewMode,
selectedPublication: selectedPub,
};
setHistory((prev) => [...prev.slice(0, historyIndexRef.current + 1), newEntry]);
setHistoryIndex((prev) => prev + 1);
},
[],
);
const loadOPDS = useCallback(
async (url: string, skipHistory = false) => {
if (loadingOPDSRef.current) return;
loadingOPDSRef.current = true;
setViewMode('loading');
setError(null);
try {
const useProxy = isWebAppPlatform();
const username = usernameRef.current || '';
const password = passwordRef.current || '';
const res = await fetchWithAuth(url, username, password, useProxy);
if (!res.ok) {
eventDispatcher.dispatch('toast', {
message: `Failed to load OPDS feed: ${res.status} ${res.statusText}`,
timeout: 5000,
type: 'error',
});
setTimeout(() => {
router.back();
}, 5000);
throw new Error(`Failed to load OPDS feed: ${res.status} ${res.statusText}`);
}
const currentStartURL = startURLRef.current || url;
const responseURL = res.url;
const text = await res.text();
if (text.startsWith('<')) {
const doc = new DOMParser().parseFromString(text, MIME.XML as DOMParserSupportedType);
const {
documentElement: { localName },
} = doc;
if (localName === 'feed') {
const feed = getFeed(doc) as OPDSFeed;
const newState = {
feed,
baseURL: responseURL,
currentURL: url,
startURL: currentStartURL || responseURL,
};
setState(newState);
setViewMode('feed');
setSelectedPublication(null);
if (!skipHistory) {
addToHistory(url, newState, 'feed', null);
}
} else if (localName === 'entry') {
const publication = getPublication(doc.documentElement);
const newState = {
publication,
baseURL: responseURL,
currentURL: url,
startURL: currentStartURL || responseURL,
};
setState(newState);
setViewMode('publication');
setSelectedPublication(null);
if (!skipHistory) {
addToHistory(url, newState, 'publication', null);
}
} else if (localName === 'OpenSearchDescription') {
const search = getOpenSearch(doc);
const newState = {
search,
baseURL: responseURL,
currentURL: url,
startURL: currentStartURL || responseURL,
};
setState(newState);
setViewMode('search');
setSelectedPublication(null);
if (!skipHistory) {
addToHistory(url, newState, 'search', null);
}
} else {
const contentType = res.headers.get('Content-Type') ?? MIME.HTML;
const type = parseMediaType(contentType)?.mediaType ?? MIME.HTML;
const htmlDoc = new DOMParser().parseFromString(text, type as DOMParserSupportedType);
if (!htmlDoc.head) {
router.back();
throw new Error(`Failed to load OPDS feed: ${res.status} ${res.statusText}`);
}
const link = Array.from(htmlDoc.head.querySelectorAll('link')).find((link) =>
isOPDSCatalog(link.getAttribute('type') ?? ''),
);
if (!link) {
router.back();
throw new Error('Document has no link to OPDS feeds');
}
const href = link.getAttribute('href');
if (href) {
const resolvedURL = resolveURL(href, responseURL);
loadOPDS(resolvedURL);
}
}
} else {
const feed = JSON.parse(text);
const newState = {
feed,
baseURL: responseURL,
currentURL: url,
startURL: currentStartURL || responseURL,
};
setState(newState);
setViewMode('feed');
setSelectedPublication(null);
if (!skipHistory) {
addToHistory(url, newState, 'feed', null);
}
}
} catch (e) {
console.error(e);
setError(e as Error);
setViewMode('error');
} finally {
loadingOPDSRef.current = false;
}
},
[router, addToHistory],
);
useEffect(() => {
const url = searchParams?.get('url');
const username = searchParams?.get('username');
const password = searchParams?.get('password');
if (url && !isNavigatingHistoryRef.current) {
if (username || password) {
usernameRef.current = username;
passwordRef.current = password;
} else {
usernameRef.current = null;
passwordRef.current = null;
}
loadOPDS(url);
} else if (isNavigatingHistoryRef.current) {
isNavigatingHistoryRef.current = false;
} else {
setViewMode('error');
setError(new Error('No OPDS URL provided'));
}
}, [searchParams, loadOPDS]);
const handleNavigate = useCallback(
(url: string) => {
const newURL = new URL(window.location.href);
newURL.searchParams.set('url', url);
window.history.pushState({}, '', newURL.toString());
loadOPDS(url);
},
[loadOPDS],
);
const handleDownload = useCallback(
async (
href: string,
type?: string,
onProgress?: (progress: { progress: number; total: number }) => void,
) => {
if (!appService || !libraryLoaded) return;
try {
const url = resolveURL(href, state.baseURL);
const parsed = parseMediaType(type);
if (parsed?.mediaType === MIME.HTML) {
if (isWebAppPlatform()) {
window.open(url, '_blank');
} else {
await openUrl(url);
}
return;
} else {
const ext = parsed?.mediaType ? getFileExtFromMimeType(parsed.mediaType) : '';
const basename = getBaseFilename(url);
const filename = ext ? `${basename}.${ext}` : basename;
const dstFilePath = await appService?.resolveFilePath(filename, 'Cache');
if (dstFilePath) {
const username = usernameRef.current || '';
const password = passwordRef.current || '';
const useProxy = needsProxy(url);
let downloadUrl = useProxy ? getProxiedURL(url, '', true) : url;
const headers: Record<string, string> = {};
if (username || password) {
const authHeader = await probeAuth(url, username, password, useProxy);
if (authHeader) {
headers['Authorization'] = authHeader;
downloadUrl = useProxy ? getProxiedURL(url, authHeader, true) : url;
}
}
await downloadFile({
appService,
dst: dstFilePath,
cfp: '',
url: downloadUrl,
headers,
singleThreaded: true,
onProgress,
});
const { library, setLibrary } = useLibraryStore.getState();
const book = await appService.importBook(dstFilePath, library);
setLibrary(library);
appService.saveLibraryBooks(library);
return book;
}
}
} catch (e) {
console.error('Download error:', e);
throw e;
}
return;
},
[state.baseURL, appService, libraryLoaded],
);
const handleGenerateCachedImageUrl = useCallback(
async (url: string) => {
if (!appService) return url;
const username = usernameRef.current || '';
const password = passwordRef.current || '';
if (!username && !password) {
return url;
}
const cachedKey = `img_${md5(url)}.png`;
const cachePrefix = await appService.resolveFilePath('', 'Cache');
const cachedPath = `${cachePrefix}/${cachedKey}`;
if (await appService.exists(cachedPath, 'None')) {
return await appService.getImageURL(cachedPath);
} else {
const useProxy = needsProxy(url);
let downloadUrl = useProxy ? getProxiedURL(url, '', true) : url;
const headers: Record<string, string> = {};
if (username || password) {
const authHeader = await probeAuth(url, username, password, useProxy);
if (authHeader) {
headers['Authorization'] = authHeader;
downloadUrl = useProxy ? getProxiedURL(url, authHeader, true) : url;
}
}
await downloadFile({
appService,
dst: cachedPath,
cfp: '',
url: downloadUrl,
singleThreaded: true,
headers,
});
return await appService.getImageURL(cachedPath);
}
},
[appService],
);
const handleBack = useCallback(() => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
const entry = history[newIndex];
if (!entry) return;
isNavigatingHistoryRef.current = true;
setHistoryIndex(newIndex);
setState(entry.state);
setViewMode(entry.viewMode);
setSelectedPublication(entry.selectedPublication);
const newURL = new URL(window.location.href);
newURL.searchParams.set('url', entry.url);
window.history.replaceState({}, '', newURL.toString());
}
}, [history, historyIndex]);
const handleForward = useCallback(() => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
const entry = history[newIndex];
if (!entry) return;
isNavigatingHistoryRef.current = true;
setHistoryIndex(newIndex);
setState(entry.state);
setViewMode(entry.viewMode);
setSelectedPublication(entry.selectedPublication);
const newURL = new URL(window.location.href);
newURL.searchParams.set('url', entry.url);
window.history.replaceState({}, '', newURL.toString());
}
}, [history, historyIndex]);
const handlePublicationSelect = useCallback((groupIndex: number, itemIndex: number) => {
setSelectedPublication({ groupIndex, itemIndex });
setViewMode('publication');
// Add this publication view to history
setHistory((prev) => {
const currentEntry = prev[historyIndexRef.current];
if (!currentEntry) return prev;
const newEntry: HistoryEntry = {
url: currentEntry.url,
state: currentEntry.state,
viewMode: 'publication',
selectedPublication: { groupIndex, itemIndex },
};
return [...prev.slice(0, historyIndexRef.current + 1), newEntry];
});
setHistoryIndex((prev) => prev + 1);
}, []);
const canGoBack = historyIndex > 0;
const canGoForward = historyIndex < history.length - 1;
const publication =
selectedPublication && state.feed
? state.feed.groups?.[selectedPublication.groupIndex]?.publications?.[
selectedPublication.itemIndex
] || state.feed.publications?.[selectedPublication.itemIndex]
: state.publication;
return (
<div
className={clsx(
'bg-base-100 flex h-screen select-none flex-col',
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
)}
>
<div
className='relative top-0 z-40 w-full'
style={{
paddingTop: `${safeAreaInsets?.top || 0}px`,
}}
>
<Navigation
currentURL={state.currentURL}
startURL={state.startURL}
onNavigate={handleNavigate}
onBack={handleBack}
onForward={handleForward}
canGoBack={canGoBack}
canGoForward={canGoForward}
/>
</div>
<main className='flex-1 overflow-auto'>
{viewMode === 'loading' && (
<div className='flex h-full items-center justify-center'>
<div className='text-center'>
<div className='loading loading-spinner loading-lg mb-4'></div>
<h1 className='text-base font-semibold'>{_('Loading...')}</h1>
</div>
</div>
)}
{viewMode === 'error' && (
<div className='flex h-full items-center justify-center'>
<div className='max-w-md text-center'>
<h1 className='text-error mb-4 text-xl font-bold'>{_('Cannot Load Page')}</h1>
<p className='text-base-content/70 mb-4'>
{error?.message || _('An error occurred')}
</p>
<button className='btn btn-primary' onClick={() => window.location.reload()}>
{_('Reload Page')}
</button>
</div>
</div>
)}
{viewMode === 'feed' && state.feed && (
<FeedView
feed={state.feed}
baseURL={state.baseURL}
onNavigate={handleNavigate}
onPublicationSelect={handlePublicationSelect}
resolveURL={resolveURL}
onGenerateCachedImageUrl={handleGenerateCachedImageUrl}
isOPDSCatalog={isOPDSCatalog}
/>
)}
{viewMode === 'publication' && publication && (
<PublicationView
publication={publication}
baseURL={state.baseURL}
onDownload={handleDownload}
resolveURL={resolveURL}
onGenerateCachedImageUrl={handleGenerateCachedImageUrl}
/>
)}
{viewMode === 'search' && state.search && (
<SearchView
search={state.search}
baseURL={state.baseURL}
onNavigate={handleNavigate}
resolveURL={resolveURL}
/>
)}
</main>
<Toast />
</div>
);
}
@@ -0,0 +1,282 @@
import { md5 } from 'js-md5';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
/**
* Extract username and password from URL credentials
*/
const extractCredentialsFromURL = (
url: string,
): { url: string; username?: string; password?: string } => {
try {
const urlObj = new URL(url);
const username = decodeURIComponent(urlObj.username) || undefined;
const password = decodeURIComponent(urlObj.password) || undefined;
if (username || password) {
urlObj.username = '';
urlObj.password = '';
return {
url: urlObj.toString(),
username,
password,
};
}
} catch (e) {
console.warn('Failed to parse URL:', e);
}
return { url };
};
export const needsProxy = (url: string): boolean => {
return isWebAppPlatform() && url.startsWith('http');
};
/**
* Generate proxied URL for OPDS requests
*/
export const getProxiedURL = (url: string, auth: string = '', stream = false): string => {
if (url.startsWith('http')) {
const { url: cleanUrl } = extractCredentialsFromURL(url);
const params = new URLSearchParams();
params.append('url', cleanUrl);
params.append('stream', `${stream}`);
if (auth) {
params.append('auth', auth);
}
const proxyUrl = `/api/opds/proxy?${params.toString()}`;
return proxyUrl;
}
return url;
};
/**
* Parse Digest authentication challenge from WWW-Authenticate header
*/
const parseDigestChallenge = (challenge: string): Record<string, string> => {
const params: Record<string, string> = {};
const regex = /(\w+)=["']?([^"',]+)["']?/g;
let match;
while ((match = regex.exec(challenge)) !== null) {
params[match[1]!] = match[2]!;
}
return params;
};
/**
* Generate Digest authentication response hash
*/
const generateDigestResponse = (
username: string,
password: string,
params: Record<string, string>,
method: string,
uri: string,
nc: string,
cnonce: string,
) => {
const realm = params['realm'];
const nonce = params['nonce'];
const qop = params['qop'];
const algorithm = params['algorithm'];
let ha1 = md5(`${username}:${realm}:${password}`);
if (algorithm && algorithm.toLowerCase() === 'md5-sess') {
ha1 = md5(`${ha1}:${nonce}:${cnonce}`);
}
const ha2 = md5(`${method}:${uri}`);
let response: string;
if (qop) {
response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`);
} else {
response = md5(`${ha1}:${nonce}:${ha2}`);
}
return response;
};
/**
* Create Digest Authorization header
*/
export const createDigestAuth = async (
username: string,
password: string,
wwwAuthenticate: string,
method: string,
uri: string,
): Promise<string> => {
const params = parseDigestChallenge(wwwAuthenticate);
const cnonce = Math.random().toString(36).slice(2);
const nc = '00000001';
const response = await generateDigestResponse(
username,
password,
params,
method,
uri,
nc,
cnonce,
);
const parts = [
`username="${username}"`,
`realm="${params['realm']}"`,
`nonce="${params['nonce']}"`,
`uri="${uri}"`,
`response="${response}"`,
];
if (params['algorithm']) {
parts.push(`algorithm="${params['algorithm']}"`);
}
if (params['opaque']) {
parts.push(`opaque="${params['opaque']}"`);
}
if (params['qop']) {
parts.push(`qop="auth"`);
parts.push(`nc=${nc}`);
parts.push(`cnonce="${cnonce}"`);
}
return `Digest ${parts.join(', ')}`;
};
/**
* Create Basic Authorization header
*/
export const createBasicAuth = (username: string, password: string): string => {
const credentials = btoa(`${username}:${password}`);
return `Basic ${credentials}`;
};
/**
* Probe URL for authentication requirements using HEAD request
* Returns auth header if authentication is needed, null otherwise
*/
export const probeAuth = async (
url: string,
username?: string,
password?: string,
useProxy = false,
): Promise<string | null> => {
const {
url: cleanUrl,
username: urlUsername,
password: urlPassword,
} = extractCredentialsFromURL(url);
const finalUsername = username || urlUsername;
const finalPassword = password || urlPassword;
// No credentials provided, can't generate auth header
if (!finalUsername || !finalPassword) {
return null;
}
const fetchURL = useProxy ? getProxiedURL(cleanUrl) : cleanUrl;
const headers: Record<string, string> = {
Accept: 'application/atom+xml, application/xml, text/xml',
};
// Probe with HEAD request
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
const res = await fetch(fetchURL, {
method: 'HEAD',
headers,
});
// Check if authentication is required
if (res.status === 401 || res.status === 403) {
const wwwAuthenticate = res.headers.get('WWW-Authenticate');
if (wwwAuthenticate) {
if (wwwAuthenticate.toLowerCase().startsWith('digest')) {
const urlObj = new URL(cleanUrl);
return await createDigestAuth(
finalUsername,
finalPassword,
wwwAuthenticate,
'GET',
urlObj.pathname + urlObj.search,
);
} else if (wwwAuthenticate.toLowerCase().startsWith('basic')) {
return createBasicAuth(finalUsername, finalPassword);
}
}
}
return null;
};
/**
* Perform authenticated HTTP request with retry logic for Digest/Basic auth
*/
export const fetchWithAuth = async (
url: string,
username?: string,
password?: string,
useProxy = false,
options: RequestInit = {},
): Promise<Response> => {
const {
url: cleanUrl,
username: urlUsername,
password: urlPassword,
} = extractCredentialsFromURL(url);
const finalUsername = username || urlUsername;
const finalPassword = password || urlPassword;
const fetchURL = useProxy ? getProxiedURL(cleanUrl) : cleanUrl;
const headers: Record<string, string> = {
Accept: 'application/atom+xml, application/xml, text/xml',
...(options.headers as Record<string, string>),
};
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
let res = await fetch(fetchURL, {
...options,
method: options.method || 'GET',
headers,
});
// Handle authentication if needed
if (!res.ok && (res.status === 401 || res.status === 403) && finalUsername && finalPassword) {
const wwwAuthenticate = res.headers.get('WWW-Authenticate');
if (wwwAuthenticate) {
let authHeader: string | null = null;
if (wwwAuthenticate.toLowerCase().startsWith('digest')) {
const urlObj = new URL(cleanUrl);
authHeader = await createDigestAuth(
finalUsername,
finalPassword,
wwwAuthenticate,
options.method || 'GET',
urlObj.pathname + urlObj.search,
);
} else if (wwwAuthenticate.toLowerCase().startsWith('basic')) {
authHeader = createBasicAuth(finalUsername, finalPassword);
}
if (authHeader) {
const finalUrl = useProxy ? `${fetchURL}&auth=${encodeURIComponent(authHeader)}` : fetchURL;
res = await fetch(finalUrl, {
...options,
method: options.method || 'GET',
headers: useProxy ? headers : { ...headers, Authorization: authHeader },
});
}
}
}
return res;
};
@@ -0,0 +1,211 @@
import { isOPDSCatalog } from 'foliate-js/opds.js';
import { fetchWithAuth } from './opdsReq';
export const groupByArray = <T, K>(arr: T[] | undefined, f: (el: T) => K | K[]): Map<K, T[]> => {
const map = new Map<K, T[]>();
if (arr) {
for (const el of arr) {
const keys = f(el);
for (const key of [keys].flat()) {
const group = map.get(key as K);
if (group) group.push(el);
else map.set(key as K, [el]);
}
}
}
return map;
};
export const MIME = {
XML: 'application/xml',
ATOM: 'application/atom+xml',
XHTML: 'application/xhtml+xml',
HTML: 'text/html',
EPUB: 'application/epub+zip',
PDF: 'application/pdf',
OPENSEARCH: 'application/opensearchdescription+xml',
};
export const enum VALIDATION_ERROR {
INVALID_URL = 'Invalid URL format',
LOAD_FAILED = 'Failed to load OPDS feed',
NOT_OPDS = 'Invalid OPDS feed URL',
NO_OPDS_LINK = 'Document has no link to OPDS feeds',
NO_HREF = 'OPDS link has no href attribute',
INVALID_HTML = 'Invalid HTML document',
INVALID_CONTENT = 'Content is neither valid XML nor JSON',
}
interface ValidationResult {
isValid: boolean;
error?: VALIDATION_ERROR | string;
data?: {
type: 'feed' | 'entry' | 'opensearch' | 'html';
doc: Document;
text: string;
responseURL: string;
};
}
export const parseMediaType = (str?: string) => {
if (!str) return null;
const [mediaType, ...ps] = str.split(/ *; */);
if (!mediaType) return null;
return {
mediaType: mediaType.toLowerCase(),
parameters: Object.fromEntries(
ps
.map((p) => {
const [name, val] = p.split('=');
if (!name) return null;
return [name.toLowerCase(), val?.replace(/(^"|"$)/g, '')];
})
.filter((entry): entry is [string, string] => entry !== null),
),
};
};
export const resolveURL = (url: string, relativeTo: string): string => {
if (!url) return '';
if (relativeTo.includes('/api/opds/proxy?url=')) {
const params = new URLSearchParams(relativeTo.split('?')[1]);
const proxiedURL = params.get('url') || '';
return resolveURL(url, proxiedURL);
}
try {
if (relativeTo.includes(':')) return new URL(url, relativeTo).toString();
const root = 'https://invalid.invalid/';
const obj = new URL(url, root + relativeTo);
obj.search = '';
return decodeURI(obj.href.replace(root, ''));
} catch (e) {
console.warn(e);
return url;
}
};
export const validateOPDSURL = async (
url: string,
username?: string,
password?: string,
useProxy = false,
): Promise<ValidationResult> => {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetchWithAuth(url, username, password, useProxy, {
signal: controller.signal,
});
clearTimeout(timeout);
if (!res.ok) {
if (res.status === 401) {
return {
isValid: false,
error: 'Authentication required. Please check your username and password.',
};
}
return {
isValid: false,
error: `Failed to load OPDS feed: ${res.status} ${res.statusText}`,
};
}
const responseURL = res.url;
const text = await res.text();
// Check if it's XML-based OPDS
if (text.startsWith('<')) {
const doc = new DOMParser().parseFromString(text, MIME.XML as DOMParserSupportedType);
const {
documentElement: { localName },
} = doc;
if (localName === 'feed') {
return {
isValid: true,
data: { type: 'feed', doc, text, responseURL },
};
} else if (localName === 'entry') {
return {
isValid: true,
data: { type: 'entry', doc, text, responseURL },
};
} else if (localName === 'OpenSearchDescription') {
return {
isValid: true,
data: { type: 'opensearch', doc, text, responseURL },
};
} else {
// Check for HTML with OPDS link
const contentType = res.headers.get('Content-Type') ?? MIME.HTML;
const type = parseMediaType(contentType)?.mediaType ?? MIME.HTML;
const htmlDoc = new DOMParser().parseFromString(text, type as DOMParserSupportedType);
if (!htmlDoc.head) {
return {
isValid: false,
error: VALIDATION_ERROR.NOT_OPDS,
};
}
const link = Array.from(htmlDoc.head.querySelectorAll('link')).find((link) =>
isOPDSCatalog(link.getAttribute('type') ?? ''),
);
if (!link) {
return {
isValid: false,
error: VALIDATION_ERROR.NOT_OPDS,
};
}
const href = link.getAttribute('href');
if (!href) {
return {
isValid: false,
error: 'OPDS link has no href attribute',
};
}
return {
isValid: true,
data: { type: 'html', doc: htmlDoc, text, responseURL },
};
}
} else {
// Check if it's JSON-based OPDS
try {
const feed = JSON.parse(text);
// Basic validation for OPDS JSON feed
if (!feed.metadata && !feed.links && !feed.publications && !feed.navigation) {
return {
isValid: false,
error: VALIDATION_ERROR.NOT_OPDS,
};
}
return {
isValid: true,
data: {
type: 'feed',
doc: new Document(),
text,
responseURL,
},
};
} catch {
return {
isValid: false,
error: VALIDATION_ERROR.NOT_OPDS,
};
}
}
} catch (e) {
console.error('OPDS validation error:', e);
return {
isValid: false,
error: e instanceof Error ? e.message : VALIDATION_ERROR.NOT_OPDS,
};
}
};
@@ -9,6 +9,7 @@ import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { useTrafficLight } from '@/hooks/useTrafficLight';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
@@ -39,14 +40,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
const _ = useTranslation();
const { appService } = useEnv();
const headerRef = useRef<HTMLDivElement>(null);
const {
isTrafficLightVisible,
trafficLightInFullscreen,
setTrafficLightVisibility,
initializeTrafficLightStore,
initializeTrafficLightListeners,
cleanupTrafficLightListeners,
} = useTrafficLightStore();
const { isTrafficLightVisible } = useTrafficLight();
const { trafficLightInFullscreen, setTrafficLightVisibility } = useTrafficLightStore();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { bookKeys, hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { systemUIVisible, statusBarHeight } = useThemeStore();
@@ -60,17 +55,6 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
if (!isOpen) setHoveredBookKey('');
};
useEffect(() => {
if (!appService?.hasTrafficLight) return;
initializeTrafficLightStore(appService);
initializeTrafficLightListeners();
return () => {
cleanupTrafficLightListeners();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService]);
useEffect(() => {
if (!appService?.hasTrafficLight) return;
if (isSideBarVisible) return;
@@ -2,14 +2,14 @@
import clsx from 'clsx';
import * as React from 'react';
import { useEffect, Suspense, useRef, useState } from 'react';
import { useEffect, Suspense } from 'react';
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
import { useLibrary } from '@/hooks/useLibrary';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useSettingsStore } from '@/store/settingsStore';
@@ -51,18 +51,16 @@ Z-Index Layering Guide:
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const router = useRouter();
const { envConfig, appService } = useEnv();
const { setLibrary } = useLibraryStore();
const { appService } = useEnv();
const { hoveredBookKey, getView } = useReaderStore();
const { settings, setSettings } = useSettingsStore();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
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, setLibraryLoaded] = useState(false);
const isInitiating = useRef(false);
const { libraryLoaded } = useLibrary();
useTheme({ systemUIVisible: settings.alwaysShowStatusBar, appThemeColor: 'base-100' });
useScreenWakeLock(settings.screenWakeLock);
@@ -115,21 +113,6 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService?.isAndroidApp, sideBarBookKey, isSideBarVisible, isNotebookVisible]);
useEffect(() => {
if (isInitiating.current) return;
isInitiating.current = true;
const initLibrary = async () => {
const appService = await envConfig.getAppService();
const settings = await appService.loadSettings();
setSettings(settings);
setLibrary(await appService.loadLibraryBooks());
setLibraryLoaded(true);
};
initLibrary();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!appService?.isMobileApp) return;
const systemUIVisible = !!hoveredBookKey || settings.alwaysShowStatusBar;
@@ -1,13 +1,12 @@
import clsx from 'clsx';
import React, { useEffect } from 'react';
import React from 'react';
import { GiBookshelf } from 'react-icons/gi';
import { FiSearch } from 'react-icons/fi';
import { MdOutlineMenu, MdOutlinePushPin, MdPushPin } from 'react-icons/md';
import { MdArrowBackIosNew } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useTrafficLight } from '@/hooks/useTrafficLight';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import Dropdown from '@/components/Dropdown';
import BookMenu from './BookMenu';
@@ -20,30 +19,11 @@ const SidebarHeader: React.FC<{
onToggleSearchBar: () => void;
}> = ({ isPinned, isSearchBarVisible, onGoToLibrary, onClose, onTogglePin, onToggleSearchBar }) => {
const _ = useTranslation();
const { appService } = useEnv();
const {
isTrafficLightVisible,
initializeTrafficLightStore,
initializeTrafficLightListeners,
setTrafficLightVisibility,
cleanupTrafficLightListeners,
} = useTrafficLightStore();
const { isTrafficLightVisible } = useTrafficLight();
const iconSize14 = useResponsiveSize(14);
const iconSize18 = useResponsiveSize(18);
const iconSize22 = useResponsiveSize(22);
useEffect(() => {
if (!appService?.hasTrafficLight) return;
initializeTrafficLightStore(appService);
initializeTrafficLightListeners();
setTrafficLightVisibility(true);
return () => {
cleanupTrafficLightListeners();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService?.hasTrafficLight]);
return (
<div
className={clsx(
@@ -0,0 +1,112 @@
'use client';
import Image from 'next/image';
import { useState, useEffect } from 'react';
interface CachedImageProps {
src: string | null;
alt: string;
fill?: boolean;
className?: string;
sizes?: string;
width?: number;
height?: number;
onGenerateCachedImageUrl: (url: string) => Promise<string>;
fallback?: React.ReactNode;
}
export function CachedImage({
src,
alt,
fill,
className,
sizes,
width,
height,
onGenerateCachedImageUrl,
fallback,
}: CachedImageProps) {
const [cachedUrl, setCachedUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!src) {
setTimeout(() => {
setLoading(false);
}, 0);
return;
}
let cancelled = false;
const loadImage = async () => {
try {
setLoading(true);
setError(null);
const url = await onGenerateCachedImageUrl(src);
if (!cancelled) {
setCachedUrl(url);
setLoading(false);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err : new Error('Failed to load image'));
setLoading(false);
}
}
};
loadImage();
return () => {
cancelled = true;
};
}, [src, onGenerateCachedImageUrl]);
if (loading) {
return (
<div className={className}>
<div className='bg-base-200 h-full w-full animate-pulse' />
</div>
);
}
if (error || !cachedUrl) {
if (fallback) {
return <>{fallback}</>;
}
return (
<div className={`flex h-full w-full items-center justify-center ${className || ''}`}>
<div className='text-base-content/30'>
<svg className='h-16 w-16' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253'
/>
</svg>
</div>
</div>
);
}
if (fill) {
return <Image src={cachedUrl} alt={alt} fill className={className} sizes={sizes} />;
}
return (
<Image
src={cachedUrl}
alt={alt}
width={width}
height={height}
className={className}
sizes={sizes}
/>
);
}
@@ -14,6 +14,7 @@ interface DropdownProps {
menuClassName?: string;
children: ReactNode;
}>;
disabled?: boolean;
onToggle?: (isOpen: boolean) => void;
}
@@ -58,6 +59,7 @@ const Dropdown: React.FC<DropdownProps> = ({
buttonClassName,
toggleButton,
children,
disabled,
onToggle,
}) => {
const [isOpen, setIsOpen] = useState(false);
@@ -66,6 +68,7 @@ const Dropdown: React.FC<DropdownProps> = ({
const containerRef = useRef<HTMLDivElement>(null);
const setIsDropdownOpen = (open: boolean) => {
if (disabled) return;
setIsOpen(open);
onToggle?.(open);
};
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useRef, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
export const useLibrary = () => {
const { envConfig } = useEnv();
const { setLibrary } = useLibraryStore();
const { setSettings } = useSettingsStore();
const [libraryLoaded, setLibraryLoaded] = useState(false);
const isInitiating = useRef(false);
useEffect(() => {
if (isInitiating.current) return;
isInitiating.current = true;
const initLibrary = async () => {
const appService = await envConfig.getAppService();
const settings = await appService.loadSettings();
setSettings(settings);
setLibrary(await appService.loadLibraryBooks());
setLibraryLoaded(true);
};
initLibrary();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { libraryLoaded };
};
@@ -0,0 +1,29 @@
import { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTrafficLightStore } from '@/store/trafficLightStore';
export const useTrafficLight = () => {
const { appService } = useEnv();
const {
isTrafficLightVisible,
initializeTrafficLightStore,
initializeTrafficLightListeners,
setTrafficLightVisibility,
cleanupTrafficLightListeners,
} = useTrafficLightStore();
useEffect(() => {
if (!appService?.hasTrafficLight) return;
initializeTrafficLightStore(appService);
initializeTrafficLightListeners();
setTrafficLightVisibility(true);
return () => {
cleanupTrafficLightListeners();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService?.hasTrafficLight]);
return { isTrafficLightVisible };
};
+21
View File
@@ -81,6 +81,17 @@ export const EXTS: Record<BookFormat, string> = {
FBZ: 'fbz',
};
export const MIMETYPES: Record<BookFormat, string[]> = {
EPUB: ['application/epub+zip'],
PDF: ['application/pdf'],
MOBI: ['application/x-mobipocket-ebook'],
AZW: ['application/vnd.amazon.ebook'],
AZW3: ['application/vnd.amazon.mobi8-ebook', 'application/x-mobi8-ebook'],
CBZ: ['application/vnd.comicbook+zip', 'application/zip'],
FB2: ['application/x-fictionbook+xml', 'text/xml', 'application/xml'],
FBZ: ['application/x-zip-compressed-fb2', 'application/zip'],
};
export class DocumentLoader {
private file: File;
@@ -231,3 +242,13 @@ export const getDirection = (doc: Document) => {
const rtl = doc.body.dir === 'rtl' || direction === 'rtl' || doc.documentElement.dir === 'rtl';
return { vertical, rtl };
};
export const getFileExtFromMimeType = (mimeType: string): string => {
for (const format in MIMETYPES) {
const list = MIMETYPES[format as BookFormat];
if (list.includes(mimeType)) {
return EXTS[format as BookFormat];
}
}
return '';
};
+11 -8
View File
@@ -106,6 +106,8 @@ type DownloadFileParams = {
dst: string;
cfp: string;
url?: string;
headers?: Record<string, string>;
singleThreaded?: boolean;
onProgress?: ProgressHandler;
};
@@ -114,16 +116,17 @@ export const downloadFile = async ({
dst,
cfp,
url,
headers,
singleThreaded,
onProgress,
}: DownloadFileParams) => {
try {
const userId = await getUserID();
if (!userId) {
throw new Error('Not authenticated');
}
let downloadUrl = url;
if (!downloadUrl) {
const userId = await getUserID();
if (!userId) {
throw new Error('Not authenticated');
}
const fileKey = `${userId}/${cfp}`;
const response = await fetchWithAuth(
`${API_ENDPOINTS.download}?fileKey=${encodeURIComponent(fileKey)}`,
@@ -141,14 +144,14 @@ export const downloadFile = async ({
}
if (isWebAppPlatform()) {
const file = await webDownload(downloadUrl, onProgress);
const file = await webDownload(downloadUrl, onProgress, headers);
await appService.writeFile(dst, 'None', await file.arrayBuffer());
} else {
await tauriDownload(downloadUrl, dst, onProgress);
await tauriDownload(downloadUrl, dst, onProgress, headers, undefined, singleThreaded);
}
} catch (error) {
console.error(`File '${dst}' download failed:`, error);
throw new Error('File download failed');
throw error;
}
};
+1 -1
View File
@@ -182,7 +182,7 @@ export async function POST(req: NextRequest) {
}
// Create lookup map
const serverRecordsMap = new Map<string, any>();
const serverRecordsMap = new Map<string, BookDataRecord>();
(serverRecords || []).forEach((record) => {
const key = primaryKeys.map((pk) => record[pk]).join('|');
serverRecordsMap.set(key, record);
+11 -2
View File
@@ -157,6 +157,14 @@ export abstract class BaseAppService implements AppService {
return await this.fs.readDir(path, base);
}
async exists(path: string, base: BaseDir): Promise<boolean> {
return await this.fs.exists(path, base);
}
async getImageURL(path: string): Promise<string> {
return await this.fs.getImageURL(path);
}
getCoverImageUrl = (book: Book): string => {
return this.fs.getURL(`${this.localBooksDir}/${getCoverFilename(book)}`);
};
@@ -170,11 +178,11 @@ export abstract class BaseAppService implements AppService {
const cachePrefix = await this.fs.getPrefix('Cache');
const cachedPath = `${cachePrefix}/${cachedKey}`;
if (await this.fs.exists(cachedPath, 'None')) {
return this.fs.getURL(cachedPath);
return await this.fs.getImageURL(cachedPath);
} else {
const file = await this.fs.openFile(pathOrUrl, 'None');
await this.fs.writeFile(cachedKey, 'Cache', await file.arrayBuffer());
return this.fs.getURL(cachedPath);
return await this.fs.getImageURL(cachedPath);
}
}
@@ -759,6 +767,7 @@ export abstract class BaseAppService implements AppService {
}
async saveLibraryBooks(books: Book[]): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const libraryBooks = books.map(({ coverImageUrl, ...rest }) => rest);
await this.safeSaveJSON(getLibraryFilename(), 'Books', libraryBooks);
}
@@ -186,6 +186,9 @@ export const nativeFileSystem: FileSystem = {
const content = await this.readFile(path, base, 'binary');
return URL.createObjectURL(new Blob([content]));
},
async getImageURL(path: string) {
return this.getURL(path);
},
async openFile(path: string, base: BaseDir, name?: string) {
const { fp, baseDir } = this.resolvePath(path, base);
let fname = name || getFilename(fp);
@@ -280,7 +283,7 @@ export const nativeFileSystem: FileSystem = {
async removeFile(path: string, base: BaseDir) {
const { fp, baseDir } = this.resolvePath(path, base);
return remove(fp, baseDir ? { baseDir } : undefined);
await remove(fp, baseDir ? { baseDir } : undefined);
},
async createDir(path: string, base: BaseDir, recursive = false) {
const { fp, baseDir } = this.resolvePath(path, base);
@@ -8,9 +8,9 @@ export const sanitizerTransformer: Transformer = {
const allowScript = ctx.viewSettings.allowScript;
if (allowScript) return ctx.content;
let result = ctx.content;
const result = ctx.content;
let sanitized = DOMPurify.sanitize(result, {
const sanitized = DOMPurify.sanitize(result, {
WHOLE_DOCUMENT: true,
FORBID_TAGS: ['script'],
ALLOWED_URI_REGEXP:
@@ -57,7 +57,7 @@ export class EdgeTTSClient implements TTSClient {
if (preferredVoice) return preferredVoice.id;
const availableVoices = (await this.getVoices(lang))[0]?.voices || [];
let defaultVoice: TTSVoice | null = availableVoices[0] || null;
const defaultVoice: TTSVoice | null = availableVoices[0] || null;
if (defaultVoice?.id === 'en-US-AnaNeural') return 'en-US-AriaNeural'; // avoid using AnaNeural as default
return defaultVoice?.id || this.#currentVoiceId || 'en-US-AriaNeural';
};
@@ -71,6 +71,9 @@ const indexedDBFileSystem: FileSystem = {
return path;
}
},
async getImageURL(path: string) {
return await this.getBlobURL(path, 'None');
},
async openFile(path: string, base: BaseDir, filename?: string) {
if (isValidURL(path)) {
return await new RemoteFile(path, filename).open();
@@ -31,6 +31,7 @@ interface FontStoreState {
}
function toSettingsFont(font: CustomFont): CustomFont {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { blobUrl, loaded, error, ...settingsFont } = font;
return settingsFont;
}
@@ -37,6 +37,7 @@ interface TextureStoreState {
}
function toSettingsTexture(texture: CustomTexture): CustomTexture {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { blobUrl, loaded, error, ...settingsTexture } = texture;
return settingsTexture;
}
+125
View File
@@ -0,0 +1,125 @@
export const REL = {
ACQ: 'http://opds-spec.org/acquisition',
FACET: 'http://opds-spec.org/facet',
GROUP: 'http://opds-spec.org/group',
COVER: ['http://opds-spec.org/image', 'http://opds-spec.org/cover'],
THUMBNAIL: ['http://opds-spec.org/image/thumbnail', 'http://opds-spec.org/thumbnail'],
};
const SUMMARY = Symbol('summary');
const CONTENT = Symbol('content');
export const SYMBOL = {
SUMMARY,
CONTENT,
} as const;
export interface OPDSCatalog {
id: string;
name: string;
url: string;
description?: string;
icon?: string;
username?: string;
password?: string;
}
export interface OPDSFeed {
metadata: {
title?: string;
subtitle?: string;
};
links: OPDSLink[];
navigation?: OPDSNavigationItem[];
publications?: OPDSPublication[];
groups?: OPDSGroup[];
facets?: OPDSFacet[];
}
export interface OPDSPublication {
metadata: {
title: string;
subtitle?: string;
author?: OPDSPerson[];
description?: string;
contributor?: OPDSPerson[];
publisher?: string | OPDSPerson;
published?: string;
language?: string;
identifier?: string;
subject?: OPDSSubject[];
rights?: string;
[SYMBOL.CONTENT]?: OPDSContent;
};
links: OPDSLink[];
images: OPDSLink[];
}
export interface OPDSSearch {
metadata: {
title?: string;
description?: string;
};
search: (map: Map<string | null, Map<string | null, string>>) => string;
params: OPDSSearchParam[];
}
export interface OPDSLink {
rel?: string;
href: string;
type?: string;
title?: string;
properties: {
price?: {
currency: string;
value: string;
} | null;
indirectAcquisition?: Array<{ type: string }>;
numberOfItems?: string;
};
}
interface OPDSPerson {
name: string;
links: Array<{ href: string }>;
}
interface OPDSSubject {
name?: string;
code?: string;
scheme?: string;
}
interface OPDSContent {
value: string;
type: 'text' | 'html' | 'xhtml';
}
export interface OPDSNavigationItem extends Partial<OPDSLink> {
title?: string;
[SYMBOL.SUMMARY]?: string;
}
interface OPDSGroup {
metadata: {
title?: string;
numberOfItems?: string;
};
links: Array<{ rel: string; href: string; type?: string }>;
publications?: OPDSPublication[];
navigation?: OPDSNavigationItem[];
}
export interface OPDSFacet {
metadata: {
title?: string;
};
links: OPDSLink[];
}
interface OPDSSearchParam {
ns?: string | null;
name: string;
required?: boolean;
value?: string;
}
+2
View File
@@ -2,6 +2,7 @@ import { CustomTheme } from '@/styles/themes';
import { CustomFont } from '@/styles/fonts';
import { CustomTexture } from '@/styles/textures';
import { HighlightColor, HighlightStyle, ViewSettings } from './book';
import { OPDSCatalog } from './opds';
export type ThemeType = 'light' | 'dark' | 'auto';
export type LibraryViewModeType = 'grid' | 'list';
@@ -67,6 +68,7 @@ export interface SystemSettings {
libraryColumns: number;
customFonts: CustomFont[];
customTextures: CustomTexture[];
opdsCatalogs: OPDSCatalog[];
kosync: KOSyncSettings;
+3
View File
@@ -29,6 +29,7 @@ export interface FileSystem {
resolvePath(path: string, base: BaseDir): ResolvedPath;
getURL(path: string): string;
getBlobURL(path: string, base: BaseDir): Promise<string>;
getImageURL(path: string): Promise<string>;
openFile(path: string, base: BaseDir, filename?: string): Promise<File>;
copyFile(srcPath: string, dstPath: string, base: BaseDir): Promise<void>;
readFile(path: string, base: BaseDir, mode: 'text' | 'binary'): Promise<string | ArrayBuffer>;
@@ -74,6 +75,8 @@ export interface AppService {
createDir(path: string, base: BaseDir, recursive?: boolean): Promise<void>;
deleteFile(path: string, base: BaseDir): Promise<void>;
deleteDir(path: string, base: BaseDir, recursive?: boolean): Promise<void>;
exists(path: string, base: BaseDir): Promise<boolean>;
getImageURL(path: string): Promise<string>;
setCustomRootDir(customRootDir: string): Promise<void>;
resolveFilePath(path: string, base: BaseDir): Promise<string>;
+1
View File
@@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from 'next';
// Helper method to wait for a middleware to execute before continuing
// And to throw an error when an error happens in a middleware
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export const runMiddleware = (req: NextApiRequest, res: NextApiResponse, fn: Function) => {
return new Promise((resolve, reject) => {
fn(req, res, (result: unknown) => {
+2 -2
View File
@@ -23,8 +23,8 @@ export function diff(str1: string, str2: string) {
j++;
k++;
} else {
let delStart = i;
let addStart = j;
const delStart = i;
const addStart = j;
while (i < lines1.length && (k >= lcs.length || !trimCompare(lines1[i], lcs[k]))) i++;
while (j < lines2.length && (k >= lcs.length || !trimCompare(lines2[j], lcs[k]))) j++;
+1 -1
View File
@@ -4,7 +4,7 @@ export const copyFiles = async (appService: AppService, srcDir: string, dstDir:
let filesToCopy: FileItem[] = [];
try {
filesToCopy = await appService.readDirectory(srcDir, 'None');
} catch (error) {
} catch {
throw new Error(`Dir ${srcDir} failed to read.`);
}
+8 -2
View File
@@ -12,8 +12,14 @@ export const getFilename = (fileOrUri: string) => {
export const getBaseFilename = (filename: string) => {
const normalizedPath = filename.replace(/\\/g, '/');
const baseName = normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
return baseName;
const name = normalizedPath.split('/').pop() || '';
const parts = name.split('.');
if (parts.length <= 1) {
return name;
}
return parts.slice(0, -1).join('.');
};
export const getDirPath = (filePath: string) => {
+23 -4
View File
@@ -2,6 +2,11 @@ import { invoke, Channel } from '@tauri-apps/api/core';
export type UploadMethod = 'POST' | 'PUT';
export const enum UploadFileError {
Unauthorized = 'Unauthorized access',
DownloadFailed = 'File download failed',
}
export interface ProgressPayload {
progress: number;
total: number;
@@ -40,9 +45,21 @@ export const webUpload = (file: File, uploadUrl: string, onProgress?: ProgressHa
});
};
export const webDownload = async (downloadUrl: string, onProgress?: ProgressHandler) => {
const response = await fetch(downloadUrl);
if (!response.ok) throw new Error('File download failed');
export const webDownload = async (
downloadUrl: string,
onProgress?: ProgressHandler,
headers?: Record<string, string>,
) => {
const response = await fetch(downloadUrl, {
method: 'GET',
headers: headers ? headers : undefined,
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new Error(UploadFileError.Unauthorized);
}
throw new Error(UploadFileError.DownloadFailed);
}
const contentLength = response.headers.get('Content-Length');
if (!contentLength) throw new Error('Cannot track progress: Content-Length missing');
@@ -102,8 +119,9 @@ export const tauriDownload = async (
url: string,
filePath: string,
progressHandler?: ProgressHandler,
headers?: Map<string, string>,
headers?: Record<string, string>,
body?: string,
singleThreaded?: boolean,
): Promise<void> => {
const ids = new Uint32Array(1);
window.crypto.getRandomValues(ids);
@@ -121,5 +139,6 @@ export const tauriDownload = async (
headers: headers ?? {},
onProgress,
body,
singleThreaded,
});
};
+195 -16
View File
@@ -270,6 +270,12 @@ importers:
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
'@typescript-eslint/eslint-plugin':
specifier: ^8.48.0
version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
'@typescript-eslint/parser':
specifier: ^8.48.0
version: 8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
'@vitejs/plugin-react':
specifier: ^4.7.0
version: 4.7.0(vite@7.0.6(@types/node@22.15.31)(jiti@1.21.6)(terser@5.44.0)(yaml@2.8.1))
@@ -293,7 +299,7 @@ importers:
version: 9.28.0(jiti@1.21.6)
eslint-config-next:
specifier: 16.0.0
version: 16.0.0(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
version: 16.0.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
eslint-plugin-jsx-a11y:
specifier: ^6.10.2
version: 6.10.2(eslint@9.28.0(jiti@1.21.6))
@@ -3223,6 +3229,14 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/eslint-plugin@8.48.0':
resolution: {integrity: sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.48.0
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.46.2':
resolution: {integrity: sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3230,22 +3244,45 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.48.0':
resolution: {integrity: sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.46.2':
resolution: {integrity: sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.48.0':
resolution: {integrity: sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.46.2':
resolution: {integrity: sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/scope-manager@8.48.0':
resolution: {integrity: sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.46.2':
resolution: {integrity: sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/tsconfig-utils@8.48.0':
resolution: {integrity: sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.46.2':
resolution: {integrity: sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3253,16 +3290,33 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.48.0':
resolution: {integrity: sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.46.2':
resolution: {integrity: sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/types@8.48.0':
resolution: {integrity: sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.46.2':
resolution: {integrity: sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/typescript-estree@8.48.0':
resolution: {integrity: sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.46.2':
resolution: {integrity: sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3270,10 +3324,21 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.48.0':
resolution: {integrity: sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.46.2':
resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/visitor-keys@8.48.0':
resolution: {integrity: sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -4452,6 +4517,15 @@ packages:
picomatch:
optional: true
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fetch-blob@3.2.0:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
@@ -6609,6 +6683,10 @@ packages:
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
engines: {node: '>=12.0.0'}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -11215,6 +11293,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)':
dependencies:
'@eslint-community/regexpp': 4.12.1
'@typescript-eslint/parser': 8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
'@typescript-eslint/scope-manager': 8.48.0
'@typescript-eslint/type-utils': 8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
'@typescript-eslint/utils': 8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
'@typescript-eslint/visitor-keys': 8.48.0
eslint: 9.28.0(jiti@1.21.6)
graphemer: 1.4.0
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.1.0(typescript@5.7.2)
typescript: 5.7.2
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)':
dependencies:
'@typescript-eslint/scope-manager': 8.46.2
@@ -11227,6 +11322,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)':
dependencies:
'@typescript-eslint/scope-manager': 8.48.0
'@typescript-eslint/types': 8.48.0
'@typescript-eslint/typescript-estree': 8.48.0(typescript@5.7.2)
'@typescript-eslint/visitor-keys': 8.48.0
debug: 4.4.1
eslint: 9.28.0(jiti@1.21.6)
typescript: 5.7.2
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.46.2(typescript@5.7.2)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.7.2)
@@ -11236,15 +11343,33 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.48.0(typescript@5.7.2)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.7.2)
'@typescript-eslint/types': 8.48.0
debug: 4.4.1
typescript: 5.7.2
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.46.2':
dependencies:
'@typescript-eslint/types': 8.46.2
'@typescript-eslint/visitor-keys': 8.46.2
'@typescript-eslint/scope-manager@8.48.0':
dependencies:
'@typescript-eslint/types': 8.48.0
'@typescript-eslint/visitor-keys': 8.48.0
'@typescript-eslint/tsconfig-utils@8.46.2(typescript@5.7.2)':
dependencies:
typescript: 5.7.2
'@typescript-eslint/tsconfig-utils@8.48.0(typescript@5.7.2)':
dependencies:
typescript: 5.7.2
'@typescript-eslint/type-utils@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)':
dependencies:
'@typescript-eslint/types': 8.46.2
@@ -11257,8 +11382,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/type-utils@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)':
dependencies:
'@typescript-eslint/types': 8.48.0
'@typescript-eslint/typescript-estree': 8.48.0(typescript@5.7.2)
'@typescript-eslint/utils': 8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
debug: 4.4.1
eslint: 9.28.0(jiti@1.21.6)
ts-api-utils: 2.1.0(typescript@5.7.2)
typescript: 5.7.2
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.46.2': {}
'@typescript-eslint/types@8.48.0': {}
'@typescript-eslint/typescript-estree@8.46.2(typescript@5.7.2)':
dependencies:
'@typescript-eslint/project-service': 8.46.2(typescript@5.7.2)
@@ -11275,6 +11414,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/typescript-estree@8.48.0(typescript@5.7.2)':
dependencies:
'@typescript-eslint/project-service': 8.48.0(typescript@5.7.2)
'@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.7.2)
'@typescript-eslint/types': 8.48.0
'@typescript-eslint/visitor-keys': 8.48.0
debug: 4.4.1
minimatch: 9.0.5
semver: 7.7.3
tinyglobby: 0.2.15
ts-api-utils: 2.1.0(typescript@5.7.2)
typescript: 5.7.2
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.28.0(jiti@1.21.6))
@@ -11286,11 +11440,27 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.28.0(jiti@1.21.6))
'@typescript-eslint/scope-manager': 8.48.0
'@typescript-eslint/types': 8.48.0
'@typescript-eslint/typescript-estree': 8.48.0(typescript@5.7.2)
eslint: 9.28.0(jiti@1.21.6)
typescript: 5.7.2
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.46.2':
dependencies:
'@typescript-eslint/types': 8.46.2
eslint-visitor-keys: 4.2.1
'@typescript-eslint/visitor-keys@8.48.0':
dependencies:
'@typescript-eslint/types': 8.48.0
eslint-visitor-keys: 4.2.1
'@vitejs/plugin-react@4.7.0(vite@7.0.6(@types/node@22.15.31)(jiti@1.21.6)(terser@5.44.0)(yaml@2.8.1))':
dependencies:
'@babel/core': 7.28.0
@@ -12414,13 +12584,13 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-next@16.0.0(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2):
eslint-config-next@16.0.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2):
dependencies:
'@next/eslint-plugin-next': 16.0.0
eslint: 9.28.0(jiti@1.21.6)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.28.0(jiti@1.21.6))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6))
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.28.0(jiti@1.21.6))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.28.0(jiti@1.21.6))
eslint-plugin-react: 7.37.2(eslint@9.28.0(jiti@1.21.6))
eslint-plugin-react-hooks: 7.0.0(eslint@9.28.0(jiti@1.21.6))
@@ -12446,48 +12616,48 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.28.0(jiti@1.21.6)):
eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.28.0(jiti@1.21.6)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.1
enhanced-resolve: 5.18.3
eslint: 9.28.0(jiti@1.21.6)
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6))
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6))
fast-glob: 3.3.2
get-tsconfig: 4.8.1
is-bun-module: 1.3.0
is-glob: 4.0.3
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6))
transitivePeerDependencies:
- '@typescript-eslint/parser'
- eslint-import-resolver-node
- eslint-import-resolver-webpack
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6)):
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
'@typescript-eslint/parser': 8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
eslint: 9.28.0(jiti@1.21.6)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.28.0(jiti@1.21.6))
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.28.0(jiti@1.21.6))
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6)):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
'@typescript-eslint/parser': 8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
eslint: 9.28.0(jiti@1.21.6)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.28.0(jiti@1.21.6))
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.28.0(jiti@1.21.6))
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6)):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -12498,7 +12668,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.28.0(jiti@1.21.6)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6))
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.28.0(jiti@1.21.6))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -12510,7 +12680,7 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 8.46.2(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
'@typescript-eslint/parser': 8.48.0(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@@ -12760,6 +12930,10 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
fetch-blob@3.2.0:
dependencies:
node-domexception: 1.0.0
@@ -15063,6 +15237,11 @@ snapshots:
fdir: 6.4.6(picomatch@4.0.2)
picomatch: 4.0.2
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tinypool@1.1.1: {}
tinyrainbow@2.0.0: {}