@@ -61,6 +61,7 @@
|
||||
| **Translate with DeepL and Yandex** | From a single sentence to the entire book—translate instantly. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
|
||||
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. | ✅ |
|
||||
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
|
||||
|
||||
## Planned Features
|
||||
@@ -72,7 +73,6 @@
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------ |
|
||||
| [**Sync with Koreader**][link-kosync-wiki] | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🛠 |
|
||||
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
|
||||
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
|
||||
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
|
||||
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
|
||||
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
|
||||
|
||||
@@ -741,5 +741,13 @@
|
||||
"Last": "الأخير",
|
||||
"Cannot Load Page": "تعذر تحميل الصفحة",
|
||||
"An error occurred": "حدث خطأ ما",
|
||||
"Online Library": "المكتبة عبر الإنترنت"
|
||||
"Online Library": "المكتبة عبر الإنترنت",
|
||||
"URL must start with http:// or https://": "يجب أن يبدأ عنوان URL بـ http:// أو https://",
|
||||
"Title, Author, Tag, etc...": "العنوان، المؤلف، العلامة، إلخ...",
|
||||
"Query": "استعلام",
|
||||
"Subject": "موضوع",
|
||||
"Enter {{terms}}": "أدخل {{terms}}",
|
||||
"No search results found": "لم يتم العثور على نتائج بحث",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "فشل في تحميل تغذية OPDS: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "البحث في {{title}}"
|
||||
}
|
||||
|
||||
@@ -721,5 +721,13 @@
|
||||
"Last": "শেষ",
|
||||
"Cannot Load Page": "পৃষ্ঠা লোড করা যায়নি",
|
||||
"An error occurred": "একটি ত্রুটি ঘটেছে",
|
||||
"Online Library": "অনলাইন লাইব্রেরি"
|
||||
"Online Library": "অনলাইন লাইব্রেরি",
|
||||
"URL must start with http:// or https://": "URL অবশ্যই http:// বা https:// দিয়ে শুরু হতে হবে",
|
||||
"Title, Author, Tag, etc...": "শিরোনাম, লেখক, ট্যাগ, ইত্যাদি...",
|
||||
"Query": "কোয়েরি",
|
||||
"Subject": "বিষয়",
|
||||
"Enter {{terms}}": "{{terms}} লিখুন",
|
||||
"No search results found": "কোনও অনুসন্ধান ফলাফল পাওয়া যায়নি",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS ফিড লোড করতে ব্যর্থ: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "{{title}} এ অনুসন্ধান করুন"
|
||||
}
|
||||
|
||||
@@ -716,5 +716,13 @@
|
||||
"Last": "མཐའ་མ།",
|
||||
"Cannot Load Page": "ཤོག་ངོས་འགུལ་སྐྱོང་བྱེད་ཐུབ་མེད།",
|
||||
"An error occurred": "ནོར་འཁྲུལ་ཞིག་བྱུང་སོང་།",
|
||||
"Online Library": "དྲ་རྒྱུན་དེབ་མཛོད།"
|
||||
"Online Library": "དྲ་རྒྱུན་དེབ་མཛོད།",
|
||||
"URL must start with http:// or https://": "URL ནི་ http:// ཡང་ https:// ནས་འགོ་བཙུགས་དགོ།",
|
||||
"Title, Author, Tag, etc...": "མིང་།, རྩོམ་པ།, མཚོན་འགྲེལ།, དེ་ལས་སྐུགས་...",
|
||||
"Query": "འཚོལ་ཞིབ་",
|
||||
"Subject": "དོན་ཚན་",
|
||||
"Enter {{terms}}": "{{terms}} ལ་འགྲོ།",
|
||||
"No search results found": "འཚོལ་ཞིབ་རྫོགས་མ་ཐུབ།",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS འཕྲིན་འདེམས་བྱས་མ་ཐུབ།: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "{{title}} ནང་འཚོལ།"
|
||||
}
|
||||
|
||||
@@ -721,5 +721,13 @@
|
||||
"Last": "Letzte",
|
||||
"Cannot Load Page": "Seite kann nicht geladen werden",
|
||||
"An error occurred": "Ein Fehler ist aufgetreten",
|
||||
"Online Library": "Online-Bibliothek"
|
||||
"Online Library": "Online-Bibliothek",
|
||||
"URL must start with http:// or https://": "Die URL muss mit http:// oder https:// beginnen",
|
||||
"Title, Author, Tag, etc...": "Titel, Autor, Tag, etc...",
|
||||
"Query": "Suchbegriff",
|
||||
"Subject": "Thema",
|
||||
"Enter {{terms}}": "Gib {{terms}} ein",
|
||||
"No search results found": "Keine Suchergebnisse gefunden",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS-Feed konnte nicht geladen werden: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "Suche in {{title}}"
|
||||
}
|
||||
|
||||
@@ -721,5 +721,13 @@
|
||||
"Last": "Τελευταίο",
|
||||
"Cannot Load Page": "Δεν είναι δυνατή η φόρτωση της σελίδας",
|
||||
"An error occurred": "Προέκυψε σφάλμα",
|
||||
"Online Library": "Online Βιβλιοθήκη"
|
||||
"Online Library": "Online Βιβλιοθήκη",
|
||||
"URL must start with http:// or https://": "Το URL πρέπει να ξεκινά με http:// ή https://",
|
||||
"Title, Author, Tag, etc...": "Τίτλος, Συγγραφέας, Ετικέτα, κ.λπ...",
|
||||
"Query": "Ερώτημα",
|
||||
"Subject": "Θέμα",
|
||||
"Enter {{terms}}": "Εισαγάγετε {{terms}}",
|
||||
"No search results found": "Δεν βρέθηκαν αποτελέσματα αναζήτησης",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "Αποτυχία φόρτωσης ροής OPDS: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "Αναζήτηση στο {{title}}"
|
||||
}
|
||||
|
||||
@@ -726,5 +726,13 @@
|
||||
"Last": "Último",
|
||||
"Cannot Load Page": "No se puede cargar la página",
|
||||
"An error occurred": "Ocurrió un error",
|
||||
"Online Library": "Biblioteca en línea"
|
||||
"Online Library": "Biblioteca en línea",
|
||||
"URL must start with http:// or https://": "URL debe comenzar con http:// o https://",
|
||||
"Title, Author, Tag, etc...": "Titulo, Autor, Etiqueta, etc...",
|
||||
"Query": "Consulta",
|
||||
"Subject": "Asunto",
|
||||
"Enter {{terms}}": "Ingrese {{terms}}",
|
||||
"No search results found": "No se encontraron resultados de búsqueda",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "Error al cargar el feed OPDS: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "Buscar en {{title}}"
|
||||
}
|
||||
|
||||
@@ -721,5 +721,13 @@
|
||||
"Last": "آخرین",
|
||||
"Cannot Load Page": "بارگذاری صفحه امکانپذیر نیست",
|
||||
"An error occurred": "خطایی رخ داد",
|
||||
"Online Library": "کتابخانه آنلاین"
|
||||
"Online Library": "کتابخانه آنلاین",
|
||||
"URL must start with http:// or https://": "آدرس باید با http:// یا https:// شروع شود",
|
||||
"Title, Author, Tag, etc...": "عنوان، نویسنده، برچسب و غیره...",
|
||||
"Query": "پرسوجو",
|
||||
"Subject": "موضوع",
|
||||
"Enter {{terms}}": "وارد کردن {{terms}}",
|
||||
"No search results found": "هیچ نتیجهای یافت نشد",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "بارگذاری فید OPDS ناموفق بود: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "جستجو در {{title}}"
|
||||
}
|
||||
|
||||
@@ -726,5 +726,13 @@
|
||||
"Last": "Dernier",
|
||||
"Cannot Load Page": "Impossible de charger la page",
|
||||
"An error occurred": "Une erreur est survenue",
|
||||
"Online Library": "Bibliothèque en ligne"
|
||||
"Online Library": "Bibliothèque en ligne",
|
||||
"URL must start with http:// or https://": "URL doit commencer par http:// ou https://",
|
||||
"Title, Author, Tag, etc...": "Titre, Auteur, Tag, etc...",
|
||||
"Query": "Requête",
|
||||
"Subject": "Sujet",
|
||||
"Enter {{terms}}": "Entrez {{terms}}",
|
||||
"No search results found": "Aucun résultat trouvé",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "Échec du chargement du flux OPDS : {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "Rechercher dans {{title}}"
|
||||
}
|
||||
|
||||
@@ -721,5 +721,13 @@
|
||||
"Last": "अंतिम",
|
||||
"Cannot Load Page": "पृष्ठ लोड नहीं किया जा सका",
|
||||
"An error occurred": "एक त्रुटि हुई",
|
||||
"Online Library": "ऑनलाइन लाइब्रेरी"
|
||||
"Online Library": "ऑनलाइन लाइब्रेरी",
|
||||
"URL must start with http:// or https://": "URL http:// या https:// से शुरू होना चाहिए",
|
||||
"Title, Author, Tag, etc...": "शीर्षक, लेखक, टैग, आदि...",
|
||||
"Query": "प्रश्न",
|
||||
"Subject": "विषय",
|
||||
"Enter {{terms}}": "{{terms}} दर्ज करें",
|
||||
"No search results found": "कोई खोज परिणाम नहीं मिला",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS फ़ीड लोड करने में विफल: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "{{title}} में खोजें"
|
||||
}
|
||||
|
||||
@@ -716,5 +716,13 @@
|
||||
"Last": "Terakhir",
|
||||
"Cannot Load Page": "Tidak dapat memuat halaman",
|
||||
"An error occurred": "Terjadi kesalahan",
|
||||
"Online Library": "Perpustakaan Online"
|
||||
"Online Library": "Perpustakaan Online",
|
||||
"URL must start with http:// or https://": "URL harus diawali dengan http:// atau https://",
|
||||
"Title, Author, Tag, etc...": "Judul, Penulis, Tag, dll...",
|
||||
"Query": "Kueri",
|
||||
"Subject": "Subjek",
|
||||
"Enter {{terms}}": "Masukkan {{terms}}",
|
||||
"No search results found": "Tidak ada hasil pencarian ditemukan",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "Gagal memuat feed OPDS: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "Cari di {{title}}"
|
||||
}
|
||||
|
||||
@@ -726,5 +726,13 @@
|
||||
"Last": "Ultimo",
|
||||
"Cannot Load Page": "Impossibile caricare la pagina",
|
||||
"An error occurred": "Si è verificato un errore",
|
||||
"Online Library": "Libreria online"
|
||||
"Online Library": "Libreria online",
|
||||
"URL must start with http:// or https://": "URL deve iniziare con http:// o https://",
|
||||
"Title, Author, Tag, etc...": "Titolo, Autore, Tag, ecc...",
|
||||
"Query": "Query",
|
||||
"Subject": "Soggetto",
|
||||
"Enter {{terms}}": "Inserisci {{terms}}",
|
||||
"No search results found": "Nessun risultato di ricerca trovato",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "Impossibile caricare il feed OPDS: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "Cerca in {{title}}"
|
||||
}
|
||||
|
||||
@@ -716,5 +716,13 @@
|
||||
"Last": "最後",
|
||||
"Cannot Load Page": "ページを読み込めません",
|
||||
"An error occurred": "エラーが発生しました",
|
||||
"Online Library": "オンラインライブラリ"
|
||||
"Online Library": "オンラインライブラリ",
|
||||
"URL must start with http:// or https://": "URLはhttp://またはhttps://で始まる必要があります",
|
||||
"Title, Author, Tag, etc...": "タイトル、著者、タグなど...",
|
||||
"Query": "クエリ",
|
||||
"Subject": "件名",
|
||||
"Enter {{terms}}": "{{terms}}を入力してください",
|
||||
"No search results found": "検索結果が見つかりません",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDSフィードの読み込みに失敗しました: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "{{title}}内を検索"
|
||||
}
|
||||
|
||||
@@ -716,5 +716,13 @@
|
||||
"Last": "마지막",
|
||||
"Cannot Load Page": "페이지를 불러올 수 없습니다",
|
||||
"An error occurred": "오류가 발생했습니다",
|
||||
"Online Library": "온라인 라이브러리"
|
||||
"Online Library": "온라인 라이브러리",
|
||||
"URL must start with http:// or https://": "URL은 http:// 또는 https://로 시작해야 합니다",
|
||||
"Title, Author, Tag, etc...": "제목, 저자, 태그 등...",
|
||||
"Query": "쿼리",
|
||||
"Subject": "주제",
|
||||
"Enter {{terms}}": "{{terms}} 입력",
|
||||
"No search results found": "검색 결과가 없습니다",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS 피드를 불러오지 못했습니다: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "{{title}}에서 검색"
|
||||
}
|
||||
|
||||
@@ -716,5 +716,13 @@
|
||||
"Last": "Terakhir",
|
||||
"Cannot Load Page": "Tidak dapat memuatkan halaman",
|
||||
"An error occurred": "Ralat telah berlaku",
|
||||
"Online Library": "Perpustakaan Dalam Talian"
|
||||
"Online Library": "Perpustakaan Dalam Talian",
|
||||
"URL must start with http:// or https://": "URL mesti bermula dengan http:// atau https://",
|
||||
"Title, Author, Tag, etc...": "Tajuk, Pengarang, Tag, dll...",
|
||||
"Query": "Pertanyaan",
|
||||
"Subject": "Subjek",
|
||||
"Enter {{terms}}": "Masukkan {{terms}}",
|
||||
"No search results found": "Tiada hasil carian ditemui",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "Gagal memuatkan suapan OPDS: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "Cari dalam {{title}}"
|
||||
}
|
||||
|
||||
@@ -721,5 +721,13 @@
|
||||
"Last": "Laatste",
|
||||
"Cannot Load Page": "Pagina kan niet worden geladen",
|
||||
"An error occurred": "Er is een fout opgetreden",
|
||||
"Online Library": "Online Bibliotheek"
|
||||
"Online Library": "Online Bibliotheek",
|
||||
"URL must start with http:// or https://": "URL moet beginnen met http:// of https://",
|
||||
"Title, Author, Tag, etc...": "Titel, Auteur, Tag, enzovoort...",
|
||||
"Query": "Zoekopdracht",
|
||||
"Subject": "Onderwerp",
|
||||
"Enter {{terms}}": "Voer {{terms}} in",
|
||||
"No search results found": "Geen zoekresultaten gevonden",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "Het laden van de OPDS-feed is mislukt: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "Zoeken in {{title}}"
|
||||
}
|
||||
|
||||
@@ -731,5 +731,13 @@
|
||||
"Last": "Ostatnia",
|
||||
"Cannot Load Page": "Nie można załadować strony",
|
||||
"An error occurred": "Wystąpił błąd",
|
||||
"Online Library": "Biblioteka online"
|
||||
"Online Library": "Biblioteka online",
|
||||
"URL must start with http:// or https://": "URL musi zaczynać się od http:// lub https://",
|
||||
"Title, Author, Tag, etc...": "Tytuł, Autor, Tag, itp...",
|
||||
"Query": "Zapytanie",
|
||||
"Subject": "Temat",
|
||||
"Enter {{terms}}": "Wprowadź {{terms}}",
|
||||
"No search results found": "Nie znaleziono wyników wyszukiwania",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "Nie udało się załadować kanału OPDS: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "Szukaj w {{title}}"
|
||||
}
|
||||
|
||||
@@ -726,5 +726,13 @@
|
||||
"Last": "Ostatnia",
|
||||
"Cannot Load Page": "Nie można załadować strony",
|
||||
"An error occurred": "Wystąpił błąd",
|
||||
"Online Library": "Biblioteka online"
|
||||
"Online Library": "Biblioteca online",
|
||||
"URL must start with http:// or https://": "URL deve começar com http:// ou https://",
|
||||
"Title, Author, Tag, etc...": "Título, Autor, Etiqueta, etc...",
|
||||
"Query": "Consulta",
|
||||
"Subject": "Assunto",
|
||||
"Enter {{terms}}": "Insira {{terms}}",
|
||||
"No search results found": "Nenhum resultado encontrado",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "Falha ao carregar o feed OPDS: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "Pesquisar em {{title}}"
|
||||
}
|
||||
|
||||
@@ -731,5 +731,13 @@
|
||||
"Last": "Последняя",
|
||||
"Cannot Load Page": "Не удалось загрузить страницу",
|
||||
"An error occurred": "Произошла ошибка",
|
||||
"Online Library": "Онлайн библиотека"
|
||||
"Online Library": "Онлайн библиотека",
|
||||
"URL must start with http:// or https://": "URL должен начинаться с http:// или https://",
|
||||
"Title, Author, Tag, etc...": "Название, Автор, Тег и т.д...",
|
||||
"Query": "Запрос",
|
||||
"Subject": "Тема",
|
||||
"Enter {{terms}}": "Введите {{terms}}",
|
||||
"No search results found": "Результаты поиска не найдены",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "Не удалось загрузить ленту OPDS: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "Поиск в {{title}}"
|
||||
}
|
||||
|
||||
@@ -721,5 +721,13 @@
|
||||
"Last": "අවසාන",
|
||||
"Cannot Load Page": "පිටුවට ප්රවේශ විය නොහැක",
|
||||
"An error occurred": "දෝෂයක් සිදු විය",
|
||||
"Online Library": "ඔන්ලයින් පුස්තකාලය"
|
||||
"Online Library": "ඔන්ලයින් පුස්තකාලය",
|
||||
"URL must start with http:// or https://": "URL එක http:// හෝ https:// සමඟ ආරම්භ විය යුතුය",
|
||||
"Title, Author, Tag, etc...": "ශීර්ෂය, කතුවරයා, ටැග්, ආදිය...",
|
||||
"Query": "විමසුම",
|
||||
"Subject": "විෂය",
|
||||
"Enter {{terms}}": "{{terms}} ඇතුළත් කරන්න",
|
||||
"No search results found": "සෙවුම් ප්රතිඵල නොමැත",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS ආහාරය පූරණය කිරීමට අසමත් විය: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "{{title}} තුළ සෙවීම"
|
||||
}
|
||||
|
||||
@@ -721,5 +721,13 @@
|
||||
"Last": "Sista",
|
||||
"Cannot Load Page": "Kan inte ladda sidan",
|
||||
"An error occurred": "Ett fel uppstod",
|
||||
"Online Library": "Onlinebibliotek"
|
||||
"Online Library": "Onlinebibliotek",
|
||||
"URL must start with http:// or https://": "URL måste börja med http:// eller https://",
|
||||
"Title, Author, Tag, etc...": "Titel, författare, tagg, etc...",
|
||||
"Query": "Fråga",
|
||||
"Subject": "Ämne",
|
||||
"Enter {{terms}}": "Ange {{terms}}",
|
||||
"No search results found": "Inga sökresultat hittades",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "Misslyckades med att ladda OPDS-flöde: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "Sök i {{title}}"
|
||||
}
|
||||
|
||||
@@ -721,5 +721,13 @@
|
||||
"Last": "இறுதி",
|
||||
"Cannot Load Page": "பக்கம் ஏற்ற முடியவில்லை",
|
||||
"An error occurred": "ஒரு பிழை ஏற்பட்டது",
|
||||
"Online Library": "ஆன்லைன் நூலகம்"
|
||||
"Online Library": "ஆன்லைன் நூலகம்",
|
||||
"URL must start with http:// or https://": "URL http:// அல்லது https:// கொண்டு தொடங்க வேண்டும்",
|
||||
"Title, Author, Tag, etc...": "தலைப்பு, ஆசிரியர், குறிச்சொல், மற்றும் பல...",
|
||||
"Query": "கேள்வி",
|
||||
"Subject": "பொருள்",
|
||||
"Enter {{terms}}": "{{terms}} உள்ளிடவும்",
|
||||
"No search results found": "தேடல் முடிவுகள் இல்லை",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS ஊட்டத்தை ஏற்ற முடியவில்லை: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "{{title}} இல் தேடவும்"
|
||||
}
|
||||
|
||||
@@ -716,5 +716,13 @@
|
||||
"Last": "สุดท้าย",
|
||||
"Cannot Load Page": "ไม่สามารถโหลดหน้าหน้าได้",
|
||||
"An error occurred": "เกิดข้อผิดพลาด",
|
||||
"Online Library": "ห้องสมุดออนไลน์"
|
||||
"Online Library": "ห้องสมุดออนไลน์",
|
||||
"URL must start with http:// or https://": "URL ต้องเริ่มต้นด้วย http:// หรือ https://",
|
||||
"Title, Author, Tag, etc...": "ชื่อเรื่อง ผู้แต่ง แท็ก ฯลฯ...",
|
||||
"Query": "แบบสอบถาม",
|
||||
"Subject": "หัวข้อ",
|
||||
"Enter {{terms}}": "ป้อน {{terms}}",
|
||||
"No search results found": "ไม่พบผลการค้นหา",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "ไม่สามารถโหลดฟีด OPDS ได้: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "ค้นหาใน {{title}}"
|
||||
}
|
||||
|
||||
@@ -721,5 +721,13 @@
|
||||
"Last": "Son",
|
||||
"Cannot Load Page": "Sayfa yüklenemiyor",
|
||||
"An error occurred": "Bir hata oluştu",
|
||||
"Online Library": "Çevrimiçi Kütüphane"
|
||||
"Online Library": "Çevrimiçi Kütüphane",
|
||||
"URL must start with http:// or https://": "URL http:// veya https:// ile başlamalıdır",
|
||||
"Title, Author, Tag, etc...": "Başlık, Yazar, Etiket, vb...",
|
||||
"Query": "Sorgu",
|
||||
"Subject": "Konu",
|
||||
"Enter {{terms}}": "{{terms}} girin",
|
||||
"No search results found": "Arama sonucu bulunamadı",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS beslemesi yüklenemedi: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "{{title}} içinde ara"
|
||||
}
|
||||
|
||||
@@ -731,5 +731,13 @@
|
||||
"Last": "Остання",
|
||||
"Cannot Load Page": "Не вдалося завантажити сторінку",
|
||||
"An error occurred": "Сталася помилка",
|
||||
"Online Library": "Онлайн бібліотека"
|
||||
"Online Library": "Онлайн бібліотека",
|
||||
"URL must start with http:// or https://": "URL повинен починатися з http:// або https://",
|
||||
"Title, Author, Tag, etc...": "Назва, автор, тег тощо...",
|
||||
"Query": "Запит",
|
||||
"Subject": "Тема",
|
||||
"Enter {{terms}}": "Введіть {{terms}}",
|
||||
"No search results found": "Результатів пошуку не знайдено",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "Не вдалося завантажити стрічку OPDS: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "Пошук у {{title}}"
|
||||
}
|
||||
|
||||
@@ -716,5 +716,13 @@
|
||||
"Last": "Cuối cùng",
|
||||
"Cannot Load Page": "Không thể tải trang",
|
||||
"An error occurred": "Đã xảy ra lỗi",
|
||||
"Online Library": "Thư viện trực tuyến"
|
||||
"Online Library": "Thư viện trực tuyến",
|
||||
"URL must start with http:// or https://": "URL phải bắt đầu bằng http:// hoặc https://",
|
||||
"Title, Author, Tag, etc...": "Tiêu đề, Tác giả, Thẻ, v.v...",
|
||||
"Query": "Truy vấn",
|
||||
"Subject": "Chủ đề",
|
||||
"Enter {{terms}}": "Nhập {{terms}}",
|
||||
"No search results found": "Không tìm thấy kết quả tìm kiếm",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "Không tải được nguồn cấp OPDS: {{status}} {{statusText}}",
|
||||
"Search in {{title}}": "Tìm kiếm trong {{title}}"
|
||||
}
|
||||
|
||||
@@ -716,5 +716,13 @@
|
||||
"Last": "末页",
|
||||
"Cannot Load Page": "无法加载页面",
|
||||
"An error occurred": "发生错误",
|
||||
"Online Library": "在线书库"
|
||||
"Online Library": "在线书库",
|
||||
"URL must start with http:// or https://": "URL 必须以 http:// 或 https:// 开头",
|
||||
"Title, Author, Tag, etc...": "标题,作者,标签等...",
|
||||
"Query": "查询",
|
||||
"Subject": "主题",
|
||||
"Enter {{terms}}": "输入 {{terms}}",
|
||||
"No search results found": "未找到搜索结果",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "加载 OPDS 源失败:{{status}} {{statusText}}",
|
||||
"Search in {{title}}": "在 {{title}} 中搜索"
|
||||
}
|
||||
|
||||
@@ -716,5 +716,13 @@
|
||||
"Last": "最後一頁",
|
||||
"Cannot Load Page": "無法載入頁面",
|
||||
"An error occurred": "發生錯誤",
|
||||
"Online Library": "線上書庫"
|
||||
"Online Library": "線上書庫",
|
||||
"URL must start with http:// or https://": "URL 必須以 http:// 或 https:// 開頭",
|
||||
"Title, Author, Tag, etc...": "標題、作者、標籤等...",
|
||||
"Query": "查詢",
|
||||
"Subject": "主題",
|
||||
"Enter {{terms}}": "輸入 {{terms}}",
|
||||
"No search results found": "未找到搜尋結果",
|
||||
"Failed to load OPDS feed: {{status}} {{statusText}}": "載入 OPDS 資料源失敗:{{status}} {{statusText}}",
|
||||
"Search in {{title}}": "在 {{title}} 中搜尋"
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
|
||||
console.log(`[OPDS Proxy] ${method}: ${url}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
const timeout = setTimeout(() => controller.abort(), 20000);
|
||||
const headers: HeadersInit = {
|
||||
'User-Agent': 'Readest/1.0 (OPDS Browser)',
|
||||
Accept: 'application/atom+xml, application/xml, text/xml, application/json, */*',
|
||||
@@ -82,7 +82,16 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
return new NextResponse(data, {
|
||||
status: response.status,
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type') || 'text/xml';
|
||||
|
||||
@@ -28,6 +28,21 @@ const POPULAR_CATALOGS: OPDSCatalog[] = [
|
||||
description: 'Over 50,000 free ebooks',
|
||||
icon: '📖',
|
||||
},
|
||||
{
|
||||
id: 'standardebooks',
|
||||
name: 'Standard Ebooks',
|
||||
url: 'https://standardebooks.org/feeds/opds',
|
||||
description: 'Carefully formatted and lovingly produced free ebooks',
|
||||
icon: '📚',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'unglue.it',
|
||||
name: 'Unglue.it',
|
||||
url: 'https://unglue.it/api/opds/',
|
||||
description: 'Free ebooks from authors who have "unglued" their books',
|
||||
icon: '📚',
|
||||
},
|
||||
];
|
||||
|
||||
async function validateOPDSCatalog(
|
||||
@@ -65,6 +80,12 @@ export function CatalogManager() {
|
||||
const handleAddCatalog = async () => {
|
||||
if (!newCatalog.name || !newCatalog.url) return;
|
||||
|
||||
const urlLower = newCatalog.url.trim().toLowerCase();
|
||||
if (!urlLower.startsWith('http://') && !urlLower.startsWith('https://')) {
|
||||
setUrlError(_('URL must start with http:// or https://'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
process.env['NODE_ENV'] === 'production' &&
|
||||
isWebAppPlatform() &&
|
||||
@@ -216,7 +237,7 @@ export function CatalogManager() {
|
||||
<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) => {
|
||||
{POPULAR_CATALOGS.filter((catalog) => !catalog.disabled).map((catalog) => {
|
||||
const isAdded = catalogs.some((c) => c.url === catalog.url);
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { IoChevronBack, IoChevronForward, IoHome, IoSearch } from 'react-icons/io5';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTrafficLight } from '@/hooks/useTrafficLight';
|
||||
@@ -16,8 +16,10 @@ interface NavigationProps {
|
||||
onNavigate: (url: string) => void;
|
||||
onBack?: () => void;
|
||||
onForward?: () => void;
|
||||
onSearch: () => void;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
hasSearch: boolean;
|
||||
}
|
||||
|
||||
export function Navigation({
|
||||
@@ -25,8 +27,10 @@ export function Navigation({
|
||||
onNavigate,
|
||||
onBack,
|
||||
onForward,
|
||||
onSearch,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
hasSearch = false,
|
||||
}: NavigationProps) {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -44,6 +48,10 @@ export function Navigation({
|
||||
navigateToLibrary(router, '', {}, true);
|
||||
}, [router]);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
onSearch();
|
||||
}, [onSearch]);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={clsx(
|
||||
@@ -80,6 +88,11 @@ export function Navigation({
|
||||
</div>
|
||||
|
||||
<div className='navbar-end gap-2'>
|
||||
{hasSearch && (
|
||||
<button className='btn btn-ghost btn-sm' onClick={handleSearch} title={_('Search')}>
|
||||
<IoSearch className='h-5 w-5' />
|
||||
</button>
|
||||
)}
|
||||
<button className='btn btn-ghost btn-sm' onClick={handleGoHome} title={_('Home')}>
|
||||
<IoHome className='h-5 w-5' />
|
||||
</button>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IoPricetag } from 'react-icons/io5';
|
||||
import { Book } from '@/types/book';
|
||||
import { OPDSLink, OPDSPublication, REL, SYMBOL } from '@/types/opds';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getFileExtFromMimeType } from '@/libs/document';
|
||||
import { formatDate, formatLanguage } from '@/utils/book';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { navigateToReader } from '@/utils/nav';
|
||||
@@ -190,7 +191,11 @@ export function PublicationView({
|
||||
key={idx}
|
||||
noIcon
|
||||
transient
|
||||
label={link.title || link.type || ''}
|
||||
label={
|
||||
link.title ||
|
||||
getFileExtFromMimeType(link.type || '').toUpperCase() ||
|
||||
idx.toString()
|
||||
}
|
||||
onClick={() => handleActionButton(link.href, link.type)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { IoSearch } from 'react-icons/io5';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { OPDSSearch } from '@/types/opds';
|
||||
|
||||
interface SearchViewProps {
|
||||
search: OPDSSearch;
|
||||
baseURL: string;
|
||||
onNavigate: (url: string) => void;
|
||||
onNavigate: (url: string, isSearch?: boolean) => void;
|
||||
resolveURL: (url: string, base: string) => string;
|
||||
}
|
||||
|
||||
export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchViewProps) {
|
||||
const _ = useTranslation();
|
||||
const [formData, setFormData] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
search.params?.forEach((param) => {
|
||||
@@ -38,7 +40,7 @@ export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchVi
|
||||
|
||||
const searchURL = search.search(map);
|
||||
const resolvedURL = resolveURL(searchURL, baseURL);
|
||||
onNavigate(resolvedURL);
|
||||
onNavigate(resolvedURL, true);
|
||||
};
|
||||
|
||||
const handleInputChange = (name: string, value: string) => {
|
||||
@@ -47,21 +49,21 @@ export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchVi
|
||||
|
||||
const getParamLabel = (name: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
searchTerms: 'Search',
|
||||
query: 'Query',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
publisher: 'Publisher',
|
||||
language: 'Language',
|
||||
subject: 'Subject',
|
||||
searchTerms: _('Title, Author, Tag, etc...'),
|
||||
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='container mx-auto max-w-md px-4 py-12'>
|
||||
<div className='mb-8 text-center'>
|
||||
<h1 className='mb-2 text-3xl font-bold'>{search.metadata?.title || 'Search'}</h1>
|
||||
<h1 className='mb-2 text-xl font-bold'>{search.metadata?.title || _('Search')}</h1>
|
||||
{search.metadata?.description && (
|
||||
<p className='text-base-content/70'>{search.metadata.description}</p>
|
||||
)}
|
||||
@@ -81,7 +83,7 @@ export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchVi
|
||||
value={formData[param.name] || ''}
|
||||
onChange={(e) => handleInputChange(param.name, e.target.value)}
|
||||
required={param.required}
|
||||
placeholder={`Enter ${getParamLabel(param.name).toLowerCase()}`}
|
||||
placeholder={`${_('Enter {{terms}}', { terms: getParamLabel(param.name).toLowerCase() })}`}
|
||||
className='input input-bordered w-full'
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={
|
||||
@@ -96,7 +98,7 @@ export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchVi
|
||||
<div className='pt-4'>
|
||||
<button type='submit' className='btn btn-primary w-full'>
|
||||
<IoSearch className='h-5 w-5' />
|
||||
Search
|
||||
{_('Search')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { md5 } from 'js-md5';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { isOPDSCatalog, getPublication, getFeed, getOpenSearch } from 'foliate-js/opds.js';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
@@ -19,7 +19,7 @@ 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 { isSearchLink, MIME, parseMediaType, resolveURL } from './utils/opdsUtils';
|
||||
import { getProxiedURL, fetchWithAuth, probeAuth, needsProxy } from './utils/opdsReq';
|
||||
import { FeedView } from './components/FeedView';
|
||||
import { PublicationView } from './components/PublicationView';
|
||||
@@ -75,7 +75,6 @@ export default function BrowserPage() {
|
||||
|
||||
useTheme({ systemUIVisible: false });
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
startURLRef.current = state.startURL;
|
||||
}, [state.startURL]);
|
||||
@@ -104,7 +103,9 @@ export default function BrowserPage() {
|
||||
);
|
||||
|
||||
const loadOPDS = useCallback(
|
||||
async (url: string, skipHistory = false) => {
|
||||
async (url: string, options: { skipHistory?: boolean; isSearch?: boolean } = {}) => {
|
||||
const { skipHistory = false, isSearch = false } = options;
|
||||
|
||||
if (loadingOPDSRef.current) return;
|
||||
loadingOPDSRef.current = true;
|
||||
|
||||
@@ -118,15 +119,30 @@ export default function BrowserPage() {
|
||||
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}`);
|
||||
if (isSearch && res.status === 404) {
|
||||
const warnMessage = _('No search results found');
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: warnMessage,
|
||||
timeout: 2000,
|
||||
type: 'warning',
|
||||
});
|
||||
setViewMode('search');
|
||||
return;
|
||||
} else {
|
||||
const errorMessage = _('Failed to load OPDS feed: {{status}} {{statusText}}', {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
});
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: errorMessage,
|
||||
timeout: 5000,
|
||||
type: 'error',
|
||||
});
|
||||
setTimeout(() => {
|
||||
router.back();
|
||||
}, 5000);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
const currentStartURL = startURLRef.current || url;
|
||||
@@ -232,7 +248,7 @@ export default function BrowserPage() {
|
||||
loadingOPDSRef.current = false;
|
||||
}
|
||||
},
|
||||
[router, addToHistory],
|
||||
[_, router, addToHistory],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -260,15 +276,64 @@ export default function BrowserPage() {
|
||||
}, [searchParams, settings, libraryLoaded, loadOPDS]);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(url: string) => {
|
||||
(url: string, isSearch = false) => {
|
||||
const newURL = new URL(window.location.href);
|
||||
newURL.searchParams.set('url', url);
|
||||
window.history.pushState({}, '', newURL.toString());
|
||||
loadOPDS(url);
|
||||
loadOPDS(url, { isSearch });
|
||||
},
|
||||
[loadOPDS],
|
||||
);
|
||||
|
||||
const hasSearch = useMemo(() => {
|
||||
return !!state.feed?.links?.find(isSearchLink);
|
||||
}, [state.feed]);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
if (!state.feed) return;
|
||||
|
||||
const searchLink = state.feed.links?.find(isSearchLink);
|
||||
|
||||
if (searchLink && searchLink.href) {
|
||||
const searchURL = resolveURL(searchLink.href, state.baseURL);
|
||||
if (searchLink.type === MIME.OPENSEARCH) {
|
||||
handleNavigate(searchURL, true);
|
||||
} else if (searchLink.type === MIME.ATOM) {
|
||||
const search: OPDSSearch = {
|
||||
metadata: {
|
||||
title: _('Search'),
|
||||
description: state.feed.metadata?.title
|
||||
? _('Search in {{title}}', { title: state.feed.metadata.title })
|
||||
: undefined,
|
||||
},
|
||||
params: [
|
||||
{
|
||||
name: 'searchTerms',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
search: (map: Map<string | null, Map<string | null, string>>) => {
|
||||
const defaultParams = map.get(null);
|
||||
const searchTerms = defaultParams?.get('searchTerms') || '';
|
||||
const decodedURL = decodeURIComponent(searchURL);
|
||||
return decodedURL.replace('{searchTerms}', encodeURIComponent(searchTerms));
|
||||
},
|
||||
};
|
||||
const newState: OPDSState = {
|
||||
feed: state.feed,
|
||||
search,
|
||||
baseURL: state.baseURL,
|
||||
currentURL: state.currentURL,
|
||||
startURL: state.startURL,
|
||||
};
|
||||
setState(newState);
|
||||
setViewMode('search');
|
||||
setSelectedPublication(null);
|
||||
addToHistory(state.currentURL, newState, 'search', null);
|
||||
}
|
||||
}
|
||||
}, [_, state, handleNavigate, addToHistory]);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (
|
||||
href: string,
|
||||
@@ -457,8 +522,10 @@ export default function BrowserPage() {
|
||||
onNavigate={handleNavigate}
|
||||
onBack={handleBack}
|
||||
onForward={handleForward}
|
||||
onSearch={handleSearch}
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
hasSearch={hasSearch}
|
||||
/>
|
||||
</div>
|
||||
<main className='flex-1 overflow-auto'>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isOPDSCatalog } from 'foliate-js/opds.js';
|
||||
import { OPDSLink } from '@/types/opds';
|
||||
import { fetchWithAuth } from './opdsReq';
|
||||
|
||||
export const groupByArray = <T, K>(arr: T[] | undefined, f: (el: T) => K | K[]): Map<K, T[]> => {
|
||||
@@ -66,6 +67,11 @@ export const parseMediaType = (str?: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const isSearchLink = (link: OPDSLink): boolean => {
|
||||
const rels = Array.isArray(link.rel) ? link.rel : [link.rel || ''];
|
||||
return rels.includes('search') && (link.type === MIME.OPENSEARCH || link.type === MIME.ATOM);
|
||||
};
|
||||
|
||||
export const resolveURL = (url: string, relativeTo: string): string => {
|
||||
if (!url) return '';
|
||||
if (relativeTo.includes('/api/opds/proxy?url=')) {
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface OPDSCatalog {
|
||||
name: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
icon?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
@@ -65,7 +66,7 @@ export interface OPDSSearch {
|
||||
}
|
||||
|
||||
export interface OPDSLink {
|
||||
rel?: string;
|
||||
rel?: string | string[];
|
||||
href: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 7ff1afba8e...771c2bcf16
Reference in New Issue
Block a user