feat(opds): add support to search in OPDS, closes #2598 (#2634)

This commit is contained in:
Huang Xin
2025-12-06 19:13:45 +08:00
committed by GitHub
parent b78466ca93
commit 00f36af03a
37 changed files with 404 additions and 64 deletions
+1 -1
View File
@@ -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>
+83 -16
View File
@@ -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=')) {
+2 -1
View File
@@ -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;