Compare commits

...

26 Commits

Author SHA1 Message Date
Huang Xin 6eb7d91122 release: version 0.9.95 (#2646) 2025-12-08 08:59:21 +01:00
Huang Xin 1fb468b3a6 fix(epub): support SVG cover for ebooks from standardebooks.org (#2645) 2025-12-08 08:52:17 +01:00
Huang Xin 3c7d95cf10 fix(footnote): responsive popup size so that on small screen it won't overflow (#2644) 2025-12-08 07:40:30 +01:00
Huang Xin ba3f060cc4 feat: add support for importing from a directory recursively, closes #179 (#2642) 2025-12-08 07:16:57 +01:00
Huang Xin 11bc7497e8 feat: add support for renaming bookshelf groups (#2639) 2025-12-07 10:04:23 +01:00
Huang Xin fb5d149413 fix: enable shared-intent event listener only on Android for now (#2638) 2025-12-07 06:45:56 +01:00
Huang Xin 42b47d73b7 feat: support cloud storage management (#2636) 2025-12-06 20:25:40 +01:00
Huang Xin 00f36af03a feat(opds): add support to search in OPDS, closes #2598 (#2634) 2025-12-06 12:13:45 +01:00
Huang Xin b78466ca93 fix(opds): relax img-src CSP to support images served from arbitrary HTTP/HTTPS hosts and ports, closes #2631 (#2633) 2025-12-06 04:22:15 +01:00
Huang Xin 4e6f146b8f feat(android): support opening shared files from other apps, closes #2484 (#2628) 2025-12-05 18:13:06 +01:00
Huang Xin cbdd4940d0 fix(android): intercept back button press for Android 15+, closes #2454 (#2626) 2025-12-05 16:27:09 +01:00
Huang Xin d022cb984a chore: bump various dependencies (#2624) 2025-12-05 07:48:03 +01:00
Huang Xin 8de6fa267e fix(cache): invalidate config and doc cache, closes #2595 and closes #2572 (#2623) 2025-12-05 07:03:57 +01:00
Huang Xin b08b7de8e9 fix(tts): fixed highlighting of current sentence for native tts on Android, closes #2620 (#2621) 2025-12-05 04:05:18 +01:00
Huang Xin a232a39f0e fix(pdf): Fixed zoomed layout and hand tool event handling, closes #2596 (#2617) 2025-12-04 19:22:01 +01:00
Huang Xin fad7966fc4 fix(layout): auto two-column layout for unfolded screen, closes #2588 (#2615) 2025-12-04 06:56:23 +01:00
Huang Xin a1487fd60c fix: get rid of the context menu for touch screen or stylus device when selecting text, closes #2579 (#2614) 2025-12-04 06:04:06 +01:00
Huang Xin 9606e315d4 fix(layout): hide overflow of children elements in duokan bleed, closes #2597 (#2613) 2025-12-04 03:40:43 +01:00
Huang Xin 978673268b chore: bump next.js to version 16.0.7 (#2612) 2025-12-04 02:34:10 +01:00
Huang Xin 70158a7f15 refactor(opds): use catalog id instead of credentials in url params, closes #2599 (#2606) 2025-12-03 08:50:55 +01:00
Huang Xin 18d65a2c5b fix(annotator): don't copy selection to notebook with keyboard shortcut by default, closes #2603 (#2605) 2025-12-03 07:08:00 +01:00
Huang Xin 1b0c2afad7 fix(layout): fixed scrollable layout in the about readest window, closes #2593 (#2604) 2025-12-03 06:23:02 +01:00
Huang Xin cef444d374 fix: disable saving last book cover with playstore variant, closes #2600 (#2602) 2025-12-03 05:41:44 +01:00
Huang Xin 75f6efe27a compat(opds): add User-Agent header to fix downloads from Calibre Web OPDS (#2592) 2025-12-02 10:27:33 +01:00
Huang Xin 852f9f40ec chore: fix cross compiling of thumbnail extension (#2587) 2025-12-02 02:27:03 +08:00
Huang Xin b9dadc0f4f chore: update flathub metainfo (#2586) 2025-12-01 18:07:29 +01:00
101 changed files with 4502 additions and 1166 deletions
+1 -1
View File
@@ -321,7 +321,7 @@ jobs:
echo "Building Portable Binaries"
pushd apps/readest-app/
echo "NEXT_PUBLIC_PORTABLE_APP=true" >> .env.local
pnpm tauri build
pnpm tauri build ${{ matrix.config.args }}
popd
echo "Uploading Portable Binaries"
+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. | 🔄 |
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.94",
"version": "0.9.95",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -84,7 +84,7 @@
"dompurify": "^3.3.0",
"foliate-js": "workspace:*",
"franc-min": "^6.2.0",
"google-auth-library": "^10.4.1",
"google-auth-library": "^10.5.0",
"googleapis": "^164.1.0",
"highlight.js": "^11.11.1",
"i18next": "^24.2.0",
@@ -95,7 +95,7 @@
"js-md5": "^0.8.3",
"jwt-decode": "^4.0.0",
"marked": "^15.0.12",
"next": "16.0.3",
"next": "16.0.7",
"overlayscrollbars": "^2.11.4",
"overlayscrollbars-react": "^0.5.6",
"posthog-js": "^1.246.0",
@@ -132,7 +132,7 @@
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.48.0",
"@typescript-eslint/parser": "^8.48.0",
"@vitejs/plugin-react": "^4.7.0",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.20",
"caniuse-lite": "^1.0.30001746",
"cpx2": "^8.0.0",
@@ -149,10 +149,10 @@
"postcss-cli": "^11.0.0",
"postcss-nested": "^7.0.2",
"raw-loader": "^4.0.2",
"tailwindcss": "^3.4.17",
"tailwindcss": "^3.4.18",
"typescript": "^5.7.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"vitest": "^4.0.15",
"wrangler": "^4.50.0"
}
}
@@ -741,5 +741,67 @@
"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}}",
"Manage Storage": "إدارة التخزين",
"Failed to load files": "فشل في تحميل الملفات",
"Deleted {{count}} file(s)_zero": "لم يتم حذف أي ملفات",
"Deleted {{count}} file(s)_one": "تم حذف ملف واحد",
"Deleted {{count}} file(s)_two": "تم حذف ملفين",
"Deleted {{count}} file(s)_few": "تم حذف {{count}} ملفات",
"Deleted {{count}} file(s)_many": "تم حذف {{count}} ملفًا",
"Deleted {{count}} file(s)_other": "تم حذف {{count}} ملف",
"Failed to delete {{count}} file(s)_zero": "فشل حذف أي ملفات",
"Failed to delete {{count}} file(s)_one": "فشل حذف ملف واحد",
"Failed to delete {{count}} file(s)_two": "فشل حذف ملفين",
"Failed to delete {{count}} file(s)_few": "فشل حذف {{count}} ملفات",
"Failed to delete {{count}} file(s)_many": "فشل حذف {{count}} ملفًا",
"Failed to delete {{count}} file(s)_other": "فشل حذف {{count}} ملف",
"Failed to delete files": "فشل حذف الملفات",
"Total Files": "إجمالي الملفات",
"Total Size": "إجمالي الحجم",
"Quota": "الحصة",
"Used": "المستخدم",
"Files": "الملفات",
"Search files...": "ابحث في الملفات...",
"Newest First": "الأحدث أولاً",
"Oldest First": "الأقدم أولاً",
"Largest First": "الأكبر أولاً",
"Smallest First": "الأصغر أولاً",
"Name A-Z": "الاسم من أ إلى ي",
"Name Z-A": "الاسم من ي إلى أ",
"{{count}} selected_zero": "لا يوجد عناصر محددة",
"{{count}} selected_one": "عنصر واحد محدد",
"{{count}} selected_two": "عنصران محددان",
"{{count}} selected_few": "{{count}} عناصر محددة",
"{{count}} selected_many": "{{count}} عنصرًا محددًا",
"{{count}} selected_other": "{{count}} عنصر محدد",
"Delete Selected": "حذف المحدد",
"Created": "تاريخ الإنشاء",
"No files found": "لا توجد ملفات",
"No files uploaded yet": "لم يتم رفع أي ملفات بعد",
"files": "الملفات",
"Page {{current}} of {{total}}": "الصفحة {{current}} من {{total}}",
"Are you sure to delete {{count}} selected file(s)?_zero": "هل أنت متأكد من حذف العناصر المحددة؟ (لا توجد عناصر)",
"Are you sure to delete {{count}} selected file(s)?_one": "هل أنت متأكد من حذف ملف واحد محدد؟",
"Are you sure to delete {{count}} selected file(s)?_two": "هل أنت متأكد من حذف ملفين محددين؟",
"Are you sure to delete {{count}} selected file(s)?_few": "هل أنت متأكد من حذف {{count}} ملفات محددة؟",
"Are you sure to delete {{count}} selected file(s)?_many": "هل أنت متأكد من حذف {{count}} ملفًا محددًا؟",
"Are you sure to delete {{count}} selected file(s)?_other": "هل أنت متأكد من حذف {{count}} ملف محدد؟",
"Cloud Storage Usage": "استخدام التخزين السحابي",
"Rename Group": "إعادة تسمية المجموعة",
"From Directory": "من الدليل",
"Successfully imported {{count}} book(s)_zero": "لم يتم استيراد أي كتب",
"Successfully imported {{count}} book(s)_one": "تم استيراد كتاب واحد",
"Successfully imported {{count}} book(s)_two": "تم استيراد كتابين",
"Successfully imported {{count}} book(s)_few": "تم استيراد {{count}} كتب",
"Successfully imported {{count}} book(s)_many": "تم استيراد {{count}} كتابًا",
"Successfully imported {{count}} book(s)_other": "تم استيراد {{count}} كتاب"
}
@@ -721,5 +721,47 @@
"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}} এ অনুসন্ধান করুন",
"Manage Storage": "স্টোরেজ ম্যানেজমেন্ট",
"Failed to load files": "ফাইল লোড করতে ব্যর্থ",
"Deleted {{count}} file(s)_one": "{{count}}টি ফাইল মুছে ফেলা হয়েছে",
"Deleted {{count}} file(s)_other": "{{count}}টি ফাইল মুছে ফেলা হয়েছে",
"Failed to delete {{count}} file(s)_one": "{{count}}টি ফাইল মুছতে ব্যর্থ",
"Failed to delete {{count}} file(s)_other": "{{count}}টি ফাইল মুছতে ব্যর্থ",
"Failed to delete files": "ফাইল মুছতে ব্যর্থ",
"Total Files": "মোট ফাইল",
"Total Size": "মোট আকার",
"Quota": "কোটা",
"Used": "ব্যবহৃত",
"Files": "ফাইল",
"Search files...": "ফাইল খুঁজুন...",
"Newest First": "নতুন আগে",
"Oldest First": "পুরনো আগে",
"Largest First": "বড় আগে",
"Smallest First": "ছোট আগে",
"Name A-Z": "নাম A-Z",
"Name Z-A": "নাম Z-A",
"{{count}} selected_one": "{{count}}টি নির্বাচিত",
"{{count}} selected_other": "{{count}}টি নির্বাচিত",
"Delete Selected": "নির্বাচিত মুছুন",
"Created": "তৈরি হয়েছে",
"No files found": "কোনো ফাইল পাওয়া যায়নি",
"No files uploaded yet": "এখনও কোনো ফাইল আপলোড করা হয়নি",
"files": "ফাইল",
"Page {{current}} of {{total}}": "{{total}}টির মধ্যে {{current}} পৃষ্ঠা",
"Are you sure to delete {{count}} selected file(s)?_one": "আপনি কি নিশ্চিত যে {{count}}টি নির্বাচিত ফাইল মুছতে চান?",
"Are you sure to delete {{count}} selected file(s)?_other": "আপনি কি নিশ্চিত যে {{count}}টি নির্বাচিত ফাইল মুছতে চান?",
"Cloud Storage Usage": "ক্লাউড স্টোরেজ ব্যবহৃত",
"Rename Group": "গ্রুপের নাম পরিবর্তন করুন",
"From Directory": "ডিরেক্টরি থেকে",
"Successfully imported {{count}} book(s)_one": "সফলভাবে ১টি বই আমদানি করা হয়েছে",
"Successfully imported {{count}} book(s)_other": "সফলভাবে {{count}}টি বই আমদানি করা হয়েছে"
}
@@ -716,5 +716,42 @@
"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}} ནང་འཚོལ།",
"Manage Storage": "སྣོད་གསོག་དོ་དམ་བྱེད་པ",
"Failed to load files": "ཡིག་ཆ་སྣོན་པ་ཕམ་པ",
"Deleted {{count}} file(s)_other": "ཡིག་ཆ་ {{count}} བསུབས་ཟིན་པ",
"Failed to delete {{count}} file(s)_other": "ཡིག་ཆ་ {{count}} བསུབས་པ་ཕམ་པ",
"Failed to delete files": "ཡིག་ཆ་བསུབས་པ་ཕམ་པ",
"Total Files": "ཡིག་ཆ་ཡོངས་བསྡོམས",
"Total Size": "ཆེས་ཆེར་ཆེ་ཆུང",
"Quota": "ཁུལ་ཚད",
"Used": "ལག་ལེན་བྱས་ཟིན་པ",
"Files": "ཡིག་ཆ",
"Search files...": "ཡིག་ཆ་འཚོལ...",
"Newest First": "གསར་ཤོས་སྔོན་དུ",
"Oldest First": "རྙིང་ཤོས་སྔོན་དུ",
"Largest First": "ཆེ་ཤོས་སྔོན་དུ",
"Smallest First": "ཆུང་ཤོས་སྔོན་དུ",
"Name A-Z": "མིང་ A-Z",
"Name Z-A": "མིང་ Z-A",
"{{count}} selected_other": "{{count}} ཡིག་ཆ་འདེམས་ཟིན་པ",
"Delete Selected": "འདེམས་པ་བསུབས་པ",
"Created": "སྤེལ་བྱས་ཟིན་པ",
"No files found": "ཡིག་ཆ་མ་རྙེད་པ",
"No files uploaded yet": "ད་ཚུན་ཡིག་ཆ་སྣོན་མི་འདུག",
"files": "ཡིག་ཆ",
"Page {{current}} of {{total}}": "ཤོག་ངོས་ {{total}} ནས་ {{current}}",
"Are you sure to delete {{count}} selected file(s)?_other": "ཁྱེད་ཀྱིས་འདེམས་པའི་ཡིག་ཆ་ {{count}} བསུབས་དགོས་པ་ངེས་ཡིན་ན?",
"Cloud Storage Usage": "སྤྲིན་གནས་སྣོད་གསོག་ལུས་སྐོར།",
"Rename Group": "ཚོགས་མིང་བསྒྱུར་བ།",
"From Directory": "སྐོར་འདེམས་པ་ནས།",
"Successfully imported {{count}} book(s)_other": "སྤྲིན་ནས་ཕབ་སྟེ་འབེབས་ {{count}} དེབ་འདེམས་སོང་"
}
@@ -721,5 +721,47 @@
"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}}",
"Manage Storage": "Speicher verwalten",
"Failed to load files": "Dateien konnten nicht geladen werden",
"Deleted {{count}} file(s)_one": "{{count}} Datei gelöscht",
"Deleted {{count}} file(s)_other": "{{count}} Dateien gelöscht",
"Failed to delete {{count}} file(s)_one": "Löschen von {{count}} Datei fehlgeschlagen",
"Failed to delete {{count}} file(s)_other": "Löschen von {{count}} Dateien fehlgeschlagen",
"Failed to delete files": "Dateien konnten nicht gelöscht werden",
"Total Files": "Gesamtdateien",
"Total Size": "Gesamtgröße",
"Quota": "Kontingent",
"Used": "Verwendet",
"Files": "Dateien",
"Search files...": "Dateien suchen...",
"Newest First": "Neueste zuerst",
"Oldest First": "Älteste zuerst",
"Largest First": "Größte zuerst",
"Smallest First": "Kleinste zuerst",
"Name A-Z": "Name AZ",
"Name Z-A": "Name ZA",
"{{count}} selected_one": "{{count}} ausgewählt",
"{{count}} selected_other": "{{count}} ausgewählt",
"Delete Selected": "Ausgewählte löschen",
"Created": "Erstellt",
"No files found": "Keine Dateien gefunden",
"No files uploaded yet": "Noch keine Dateien hochgeladen",
"files": "Dateien",
"Page {{current}} of {{total}}": "Seite {{current}} von {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Möchten Sie {{count}} ausgewählte Datei wirklich löschen?",
"Are you sure to delete {{count}} selected file(s)?_other": "Möchten Sie {{count}} ausgewählte Dateien wirklich löschen?",
"Cloud Storage Usage": "Cloud-Speichernutzung",
"Rename Group": "Gruppe umbenennen",
"From Directory": "Aus Verzeichnis",
"Successfully imported {{count}} book(s)_one": "Erfolgreich 1 Buch importiert",
"Successfully imported {{count}} book(s)_other": "Erfolgreich {{count}} Bücher importiert"
}
@@ -721,5 +721,47 @@
"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}}",
"Manage Storage": "Διαχείριση αποθήκευσης",
"Failed to load files": "Αποτυχία φόρτωσης αρχείων",
"Deleted {{count}} file(s)_one": "Διαγράφηκε {{count}} αρχείο",
"Deleted {{count}} file(s)_other": "Διαγράφηκαν {{count}} αρχεία",
"Failed to delete {{count}} file(s)_one": "Αποτυχία διαγραφής {{count}} αρχείου",
"Failed to delete {{count}} file(s)_other": "Αποτυχία διαγραφής {{count}} αρχείων",
"Failed to delete files": "Αποτυχία διαγραφής αρχείων",
"Total Files": "Σύνολο αρχείων",
"Total Size": "Συνολικό μέγεθος",
"Quota": "Όριο",
"Used": "Χρησιμοποιήθηκε",
"Files": "Αρχεία",
"Search files...": "Αναζήτηση αρχείων...",
"Newest First": "Νεότερα πρώτα",
"Oldest First": "Παλαιότερα πρώτα",
"Largest First": "Μεγαλύτερα πρώτα",
"Smallest First": "Μικρότερα πρώτα",
"Name A-Z": "Όνομα AZ",
"Name Z-A": "Όνομα ZA",
"{{count}} selected_one": "{{count}} επιλεγμένο",
"{{count}} selected_other": "{{count}} επιλεγμένα",
"Delete Selected": "Διαγραφή επιλεγμένων",
"Created": "Δημιουργήθηκε",
"No files found": "Δεν βρέθηκαν αρχεία",
"No files uploaded yet": "Δεν έχουν ανέβει αρχεία ακόμη",
"files": "αρχεία",
"Page {{current}} of {{total}}": "Σελίδα {{current}} από {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Σίγουρα θέλετε να διαγράψετε {{count}} επιλεγμένο αρχείο;",
"Are you sure to delete {{count}} selected file(s)?_other": "Σίγουρα θέλετε να διαγράψετε {{count}} επιλεγμένα αρχεία;",
"Cloud Storage Usage": "Χρήση αποθήκευσης cloud",
"Rename Group": "Μετονομασία ομάδας",
"From Directory": "Από κατάλογο",
"Successfully imported {{count}} book(s)_one": "Επιτυχής εισαγωγή 1 βιβλίου",
"Successfully imported {{count}} book(s)_other": "Επιτυχής εισαγωγή {{count}} βιβλίων"
}
@@ -9,5 +9,15 @@
"Search in {{count}} Book(s)..._one": "Search in {{count}} book...",
"Search in {{count}} Book(s)..._other": "Search in {{count}} books...",
"{{count}} pages left in chapter_one": "{{count}} page left in chapter",
"{{count}} pages left in chapter_other": "{{count}} pages left in chapter"
"{{count}} pages left in chapter_other": "{{count}} pages left in chapter",
"Deleted {{count}} file(s)_one": "Deleted {{count}} file",
"Deleted {{count}} file(s)_other": "Deleted {{count}} files",
"Failed to delete {{count}} file(s)_one": "Failed to delete {{count}} file",
"Failed to delete {{count}} file(s)_other": "Failed to delete {{count}} files",
"{{count}} selected_one": "{{count}} selected",
"{{count}} selected_other": "{{count}} selected",
"Are you sure to delete {{count}} selected file(s)?_one": "Are you sure to delete {{count}} selected file?",
"Are you sure to delete {{count}} selected file(s)?_other": "Are you sure to delete {{count}} selected files?",
"Successfully imported {{count}} book(s)_one": "Successfully imported {{count}} book",
"Successfully imported {{count}} book(s)_other": "Successfully imported {{count}} books"
}
@@ -726,5 +726,52 @@
"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}}",
"Manage Storage": "Administrar almacenamiento",
"Failed to load files": "Error al cargar los archivos",
"Deleted {{count}} file(s)_one": "Se eliminó {{count}} archivo",
"Deleted {{count}} file(s)_many": "Se eliminaron {{count}} archivos",
"Deleted {{count}} file(s)_other": "Se eliminaron {{count}} archivos",
"Failed to delete {{count}} file(s)_one": "Error al eliminar {{count}} archivo",
"Failed to delete {{count}} file(s)_many": "Error al eliminar {{count}} archivos",
"Failed to delete {{count}} file(s)_other": "Error al eliminar {{count}} archivos",
"Failed to delete files": "Error al eliminar los archivos",
"Total Files": "Total de archivos",
"Total Size": "Tamaño total",
"Quota": "Cuota",
"Used": "Usado",
"Files": "Archivos",
"Search files...": "Buscar archivos...",
"Newest First": "Más nuevos primero",
"Oldest First": "Más antiguos primero",
"Largest First": "Más grandes primero",
"Smallest First": "Más pequeños primero",
"Name A-Z": "Nombre AZ",
"Name Z-A": "Nombre ZA",
"{{count}} selected_one": "{{count}} seleccionado",
"{{count}} selected_many": "{{count}} seleccionados",
"{{count}} selected_other": "{{count}} seleccionados",
"Delete Selected": "Eliminar seleccionados",
"Created": "Creado",
"No files found": "No se encontraron archivos",
"No files uploaded yet": "Aún no se han subido archivos",
"files": "archivos",
"Page {{current}} of {{total}}": "Página {{current}} de {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "¿Seguro que deseas eliminar {{count}} archivo seleccionado?",
"Are you sure to delete {{count}} selected file(s)?_many": "¿Seguro que deseas eliminar {{count}} archivos seleccionados?",
"Are you sure to delete {{count}} selected file(s)?_other": "¿Seguro que deseas eliminar {{count}} archivos seleccionados?",
"Cloud Storage Usage": "Uso de almacenamiento en la nube",
"Rename Group": "Renombrar grupo",
"From Directory": "Desde el directorio",
"Successfully imported {{count}} book(s)_one": "Se importó correctamente 1 libro",
"Successfully imported {{count}} book(s)_many": "Se importaron correctamente {{count}} libros",
"Successfully imported {{count}} book(s)_other": "Se importaron correctamente {{count}} libros"
}
@@ -721,5 +721,47 @@
"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}}",
"Manage Storage": "مدیریت فضای ذخیره‌سازی",
"Failed to load files": "بارگیری فایل‌ها ناموفق بود",
"Deleted {{count}} file(s)_one": "{{count}} فایل حذف شد",
"Deleted {{count}} file(s)_other": "{{count}} فایل حذف شدند",
"Failed to delete {{count}} file(s)_one": "حذف {{count}} فایل ناموفق بود",
"Failed to delete {{count}} file(s)_other": "حذف {{count}} فایل ناموفق بود",
"Failed to delete files": "حذف فایل‌ها ناموفق بود",
"Total Files": "کل فایل‌ها",
"Total Size": "اندازه کل",
"Quota": "سهمیه",
"Used": "استفاده‌شده",
"Files": "فایل‌ها",
"Search files...": "جستجوی فایل‌ها...",
"Newest First": "جدیدترین‌ها",
"Oldest First": "قدیمی‌ترین‌ها",
"Largest First": "بزرگ‌ترین‌ها",
"Smallest First": "کوچک‌ترین‌ها",
"Name A-Z": "نام AZ",
"Name Z-A": "نام ZA",
"{{count}} selected_one": "{{count}} مورد انتخاب شد",
"{{count}} selected_other": "{{count}} مورد انتخاب شدند",
"Delete Selected": "حذف موارد انتخاب‌شده",
"Created": "ایجاد شده",
"No files found": "هیچ فایلی پیدا نشد",
"No files uploaded yet": "هنوز فایلی بارگذاری نشده است",
"files": "فایل‌ها",
"Page {{current}} of {{total}}": "صفحه {{current}} از {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "آیا از حذف {{count}} فایل انتخاب‌شده مطمئن هستید؟",
"Are you sure to delete {{count}} selected file(s)?_other": "آیا از حذف {{count}} فایل انتخاب‌شده مطمئن هستید؟",
"Cloud Storage Usage": "استفاده از فضای ذخیره‌سازی ابری",
"Rename Group": "تغییر نام گروه",
"From Directory": "از مسیر",
"Successfully imported {{count}} book(s)_one": "با موفقیت 1 کتاب وارد شد",
"Successfully imported {{count}} book(s)_other": "با موفقیت {{count}} کتاب وارد شد"
}
@@ -726,5 +726,52 @@
"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}}",
"Manage Storage": "Gérer le stockage",
"Failed to load files": "Échec du chargement des fichiers",
"Deleted {{count}} file(s)_one": "{{count}} fichier supprimé",
"Deleted {{count}} file(s)_many": "{{count}} fichiers supprimés",
"Deleted {{count}} file(s)_other": "{{count}} fichiers supprimés",
"Failed to delete {{count}} file(s)_one": "Échec de la suppression de {{count}} fichier",
"Failed to delete {{count}} file(s)_many": "Échec de la suppression de {{count}} fichiers",
"Failed to delete {{count}} file(s)_other": "Échec de la suppression de {{count}} fichiers",
"Failed to delete files": "Échec de la suppression des fichiers",
"Total Files": "Nombre total de fichiers",
"Total Size": "Taille totale",
"Quota": "Quota",
"Used": "Utilisé",
"Files": "Fichiers",
"Search files...": "Rechercher des fichiers...",
"Newest First": "Les plus récents",
"Oldest First": "Les plus anciens",
"Largest First": "Les plus volumineux",
"Smallest First": "Les moins volumineux",
"Name A-Z": "Nom A-Z",
"Name Z-A": "Nom Z-A",
"{{count}} selected_one": "{{count}} sélectionné",
"{{count}} selected_many": "{{count}} sélectionnés",
"{{count}} selected_other": "{{count}} sélectionnés",
"Delete Selected": "Supprimer la sélection",
"Created": "Créé",
"No files found": "Aucun fichier trouvé",
"No files uploaded yet": "Aucun fichier téléchargé pour le moment",
"files": "fichiers",
"Page {{current}} of {{total}}": "Page {{current}} sur {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Voulez-vous vraiment supprimer {{count}} fichier sélectionné ?",
"Are you sure to delete {{count}} selected file(s)?_many": "Voulez-vous vraiment supprimer {{count}} fichiers sélectionnés ?",
"Are you sure to delete {{count}} selected file(s)?_other": "Voulez-vous vraiment supprimer {{count}} fichiers sélectionnés ?",
"Cloud Storage Usage": "Utilisation du stockage cloud",
"Rename Group": "Renommer le groupe",
"From Directory": "Depuis le répertoire",
"Successfully imported {{count}} book(s)_one": "Importation réussie de 1 livre",
"Successfully imported {{count}} book(s)_many": "Importation réussie de {{count}} livres",
"Successfully imported {{count}} book(s)_other": "Importation réussie de {{count}} livres"
}
@@ -721,5 +721,47 @@
"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}} में खोजें",
"Manage Storage": "स्टोरेज प्रबंधित करें",
"Failed to load files": "फ़ाइलें लोड करने में विफल",
"Deleted {{count}} file(s)_one": "{{count}} फ़ाइल हटाई गई",
"Deleted {{count}} file(s)_other": "{{count}} फ़ाइलें हटाई गईं",
"Failed to delete {{count}} file(s)_one": "{{count}} फ़ाइल हटाने में विफल",
"Failed to delete {{count}} file(s)_other": "{{count}} फ़ाइलें हटाने में विफल",
"Failed to delete files": "फ़ाइलें हटाने में विफल",
"Total Files": "कुल फ़ाइलें",
"Total Size": "कुल आकार",
"Quota": "कोटा",
"Used": "उपयोग किया गया",
"Files": "फ़ाइलें",
"Search files...": "फ़ाइलें खोजें...",
"Newest First": "नवीनतम पहले",
"Oldest First": "सबसे पुराने पहले",
"Largest First": "सबसे बड़ी पहले",
"Smallest First": "सबसे छोटी पहले",
"Name A-Z": "नाम A-Z",
"Name Z-A": "नाम Z-A",
"{{count}} selected_one": "{{count}} चयनित",
"{{count}} selected_other": "{{count}} चयनित",
"Delete Selected": "चयनित हटाएँ",
"Created": "बनाई गई",
"No files found": "कोई फ़ाइल नहीं मिली",
"No files uploaded yet": "अभी तक कोई फ़ाइल अपलोड नहीं की गई",
"files": "फ़ाइलें",
"Page {{current}} of {{total}}": "पृष्ठ {{current}} / {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "क्या आप वाकई {{count}} चयनित फ़ाइल हटाना चाहते हैं?",
"Are you sure to delete {{count}} selected file(s)?_other": "क्या आप वाकई {{count}} चयनित फ़ाइलें हटाना चाहते हैं?",
"Cloud Storage Usage": "क्लाउड स्टोरेज उपयोग",
"Rename Group": "समूह का नाम बदलें",
"From Directory": "निर्देशिका से",
"Successfully imported {{count}} book(s)_one": "सफलतापूर्वक 1 पुस्तक आयात की गई",
"Successfully imported {{count}} book(s)_other": "सफलतापूर्वक {{count}} पुस्तकों का आयात किया गया"
}
@@ -716,5 +716,42 @@
"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}}",
"Manage Storage": "Kelola Penyimpanan",
"Failed to load files": "Gagal memuat file",
"Deleted {{count}} file(s)_other": "{{count}} file dihapus",
"Failed to delete {{count}} file(s)_other": "Gagal menghapus {{count}} file",
"Failed to delete files": "Gagal menghapus file",
"Total Files": "Total File",
"Total Size": "Total Ukuran",
"Quota": "Kuota",
"Used": "Digunakan",
"Files": "File",
"Search files...": "Cari file...",
"Newest First": "Terbaru",
"Oldest First": "Terlama",
"Largest First": "Terbesar",
"Smallest First": "Terkecil",
"Name A-Z": "Nama A-Z",
"Name Z-A": "Nama Z-A",
"{{count}} selected_other": "{{count}} dipilih",
"Delete Selected": "Hapus yang Dipilih",
"Created": "Dibuat",
"No files found": "Tidak ada file ditemukan",
"No files uploaded yet": "Belum ada file diunggah",
"files": "file",
"Page {{current}} of {{total}}": "Halaman {{current}} dari {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "Yakin ingin menghapus {{count}} file yang dipilih?",
"Cloud Storage Usage": "Penggunaan Penyimpanan Cloud",
"Rename Group": "Ganti Nama Grup",
"From Directory": "Dari Direktori",
"Successfully imported {{count}} book(s)_other": "Berhasil mengimpor {{count}} buku"
}
@@ -726,5 +726,52 @@
"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}}",
"Manage Storage": "Gestisci archiviazione",
"Failed to load files": "Impossibile caricare i file",
"Deleted {{count}} file(s)_one": "È stato eliminato {{count}} file",
"Deleted {{count}} file(s)_many": "Sono stati eliminati {{count}} file",
"Deleted {{count}} file(s)_other": "Sono stati eliminati {{count}} file",
"Failed to delete {{count}} file(s)_one": "Impossibile eliminare {{count}} file",
"Failed to delete {{count}} file(s)_many": "Impossibile eliminare {{count}} file",
"Failed to delete {{count}} file(s)_other": "Impossibile eliminare {{count}} file",
"Failed to delete files": "Impossibile eliminare i file",
"Total Files": "File totali",
"Total Size": "Dimensione totale",
"Quota": "Quota",
"Used": "Utilizzato",
"Files": "File",
"Search files...": "Cerca file...",
"Newest First": "Più recenti",
"Oldest First": "Più vecchi",
"Largest First": "Più grandi",
"Smallest First": "Più piccoli",
"Name A-Z": "Nome A-Z",
"Name Z-A": "Nome Z-A",
"{{count}} selected_one": "{{count}} selezionato",
"{{count}} selected_many": "{{count}} selezionati",
"{{count}} selected_other": "{{count}} selezionati",
"Delete Selected": "Elimina selezionati",
"Created": "Creato",
"No files found": "Nessun file trovato",
"No files uploaded yet": "Nessun file caricato",
"files": "file",
"Page {{current}} of {{total}}": "Pagina {{current}} di {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Sei sicuro di voler eliminare {{count}} file selezionato?",
"Are you sure to delete {{count}} selected file(s)?_many": "Sei sicuro di voler eliminare {{count}} file selezionati?",
"Are you sure to delete {{count}} selected file(s)?_other": "Sei sicuro di voler eliminare {{count}} file selezionati?",
"Cloud Storage Usage": "Utilizzo archiviazione cloud",
"Rename Group": "Rinomina gruppo",
"From Directory": "Da directory",
"Successfully imported {{count}} book(s)_one": "Importato con successo 1 libro",
"Successfully imported {{count}} book(s)_many": "Importati con successo {{count}} libri",
"Successfully imported {{count}} book(s)_other": "Importati con successo {{count}} libri"
}
@@ -716,5 +716,42 @@
"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}}内を検索",
"Manage Storage": "ストレージ管理",
"Failed to load files": "ファイルの読み込みに失敗しました",
"Deleted {{count}} file(s)_other": "{{count}} 件のファイルを削除しました",
"Failed to delete {{count}} file(s)_other": "{{count}} 件のファイルの削除に失敗しました",
"Failed to delete files": "ファイルの削除に失敗しました",
"Total Files": "ファイル合計",
"Total Size": "合計サイズ",
"Quota": "容量制限",
"Used": "使用済み",
"Files": "ファイル",
"Search files...": "ファイルを検索...",
"Newest First": "新しい順",
"Oldest First": "古い順",
"Largest First": "大きい順",
"Smallest First": "小さい順",
"Name A-Z": "名前 A-Z",
"Name Z-A": "名前 Z-A",
"{{count}} selected_other": "{{count}} 件選択済み",
"Delete Selected": "選択した項目を削除",
"Created": "作成日",
"No files found": "ファイルが見つかりません",
"No files uploaded yet": "まだファイルがアップロードされていません",
"files": "ファイル",
"Page {{current}} of {{total}}": "ページ {{current}} / {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "選択した {{count}} 件のファイルを削除してもよろしいですか?",
"Cloud Storage Usage": "クラウドストレージ使用量",
"Rename Group": "グループ名を変更",
"From Directory": "ディレクトリから",
"Successfully imported {{count}} book(s)_other": "成功裏に{{count}}冊の本をインポートしました"
}
@@ -716,5 +716,42 @@
"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}}에서 검색",
"Manage Storage": "저장소 관리",
"Failed to load files": "파일 로드 실패",
"Deleted {{count}} file(s)_other": "{{count}}개의 파일이 삭제되었습니다",
"Failed to delete {{count}} file(s)_other": "{{count}}개의 파일 삭제 실패",
"Failed to delete files": "파일 삭제 실패",
"Total Files": "총 파일",
"Total Size": "총 용량",
"Quota": "쿼터",
"Used": "사용됨",
"Files": "파일",
"Search files...": "파일 검색...",
"Newest First": "최신순",
"Oldest First": "오래된순",
"Largest First": "큰 파일순",
"Smallest First": "작은 파일순",
"Name A-Z": "이름 A-Z",
"Name Z-A": "이름 Z-A",
"{{count}} selected_other": "{{count}}개 선택됨",
"Delete Selected": "선택 삭제",
"Created": "생성됨",
"No files found": "파일이 없습니다",
"No files uploaded yet": "아직 업로드된 파일이 없습니다",
"files": "파일",
"Page {{current}} of {{total}}": "페이지 {{current}} / {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "선택한 {{count}}개의 파일을 삭제하시겠습니까?",
"Cloud Storage Usage": "클라우드 저장소 사용량",
"Rename Group": "그룹 이름 바꾸기",
"From Directory": "디렉토리에서",
"Successfully imported {{count}} book(s)_other": "성공적으로 {{count}} 권의 책이 가져와졌습니다"
}
@@ -716,5 +716,42 @@
"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}}",
"Manage Storage": "Urus Penyimpanan",
"Failed to load files": "Gagal memuatkan fail",
"Deleted {{count}} file(s)_other": "{{count}} fail telah dipadam",
"Failed to delete {{count}} file(s)_other": "Gagal memadam {{count}} fail",
"Failed to delete files": "Gagal memadam fail",
"Total Files": "Jumlah Fail",
"Total Size": "Jumlah Saiz",
"Quota": "Kuota",
"Used": "Digunakan",
"Files": "Fail",
"Search files...": "Cari fail...",
"Newest First": "Terbaru dahulu",
"Oldest First": "Tertua dahulu",
"Largest First": "Terbesar dahulu",
"Smallest First": "Terkecil dahulu",
"Name A-Z": "Nama A-Z",
"Name Z-A": "Nama Z-A",
"{{count}} selected_other": "{{count}} dipilih",
"Delete Selected": "Padam Dipilih",
"Created": "Dicipta",
"No files found": "Tiada fail dijumpai",
"No files uploaded yet": "Belum ada fail dimuat naik",
"files": "fail",
"Page {{current}} of {{total}}": "Halaman {{current}} daripada {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "Adakah anda pasti mahu memadam {{count}} fail yang dipilih?",
"Cloud Storage Usage": "Penggunaan Storan Awan",
"Rename Group": "Namakan Semula Kumpulan",
"From Directory": "Dari Direktori",
"Successfully imported {{count}} book(s)_other": "Berjaya mengimport {{count}} buku"
}
@@ -721,5 +721,47 @@
"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}}",
"Manage Storage": "Opslag beheren",
"Failed to load files": "Bestanden laden mislukt",
"Deleted {{count}} file(s)_one": "{{count}} bestand verwijderd",
"Deleted {{count}} file(s)_other": "{{count}} bestanden verwijderd",
"Failed to delete {{count}} file(s)_one": "Kon {{count}} bestand niet verwijderen",
"Failed to delete {{count}} file(s)_other": "Kon {{count}} bestanden niet verwijderen",
"Failed to delete files": "Bestanden verwijderen mislukt",
"Total Files": "Totaal aantal bestanden",
"Total Size": "Totale grootte",
"Quota": "Quota",
"Used": "Gebruikt",
"Files": "Bestanden",
"Search files...": "Bestanden zoeken...",
"Newest First": "Nieuwste eerst",
"Oldest First": "Oudste eerst",
"Largest First": "Grootste eerst",
"Smallest First": "Kleinste eerst",
"Name A-Z": "Naam A-Z",
"Name Z-A": "Naam Z-A",
"{{count}} selected_one": "{{count}} geselecteerd",
"{{count}} selected_other": "{{count}} geselecteerd",
"Delete Selected": "Verwijder geselecteerde",
"Created": "Gemaakt",
"No files found": "Geen bestanden gevonden",
"No files uploaded yet": "Nog geen bestanden geüpload",
"files": "bestanden",
"Page {{current}} of {{total}}": "Pagina {{current}} van {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Weet je zeker dat je {{count}} geselecteerd bestand wilt verwijderen?",
"Are you sure to delete {{count}} selected file(s)?_other": "Weet je zeker dat je {{count}} geselecteerde bestanden wilt verwijderen?",
"Cloud Storage Usage": "Cloudopslaggebruik",
"Rename Group": "Groep hernoemen",
"From Directory": "Vanuit map",
"Successfully imported {{count}} book(s)_one": "Succesvol 1 boek geïmporteerd",
"Successfully imported {{count}} book(s)_other": "Succesvol {{count}} boeken geïmporteerd"
}
@@ -731,5 +731,57 @@
"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}}",
"Manage Storage": "Zarządzaj pamięcią",
"Failed to load files": "Nie udało się wczytać plików",
"Deleted {{count}} file(s)_one": "Usunięto {{count}} plik",
"Deleted {{count}} file(s)_few": "Usunięto {{count}} pliki",
"Deleted {{count}} file(s)_many": "Usunięto {{count}} plików",
"Deleted {{count}} file(s)_other": "Usunięto {{count}} pliku",
"Failed to delete {{count}} file(s)_one": "Nie udało się usunąć {{count}} pliku",
"Failed to delete {{count}} file(s)_few": "Nie udało się usunąć {{count}} plików",
"Failed to delete {{count}} file(s)_many": "Nie udało się usunąć {{count}} plików",
"Failed to delete {{count}} file(s)_other": "Nie udało się usunąć {{count}} pliku",
"Failed to delete files": "Nie udało się usunąć plików",
"Total Files": "Łączna liczba plików",
"Total Size": "Łączny rozmiar",
"Quota": "Limit",
"Used": "Użyto",
"Files": "Pliki",
"Search files...": "Szukaj plików...",
"Newest First": "Najnowsze najpierw",
"Oldest First": "Najstarsze najpierw",
"Largest First": "Największe najpierw",
"Smallest First": "Najmniejsze najpierw",
"Name A-Z": "Nazwa A-Z",
"Name Z-A": "Nazwa Z-A",
"{{count}} selected_one": "Wybrano {{count}} plik",
"{{count}} selected_few": "Wybrano {{count}} pliki",
"{{count}} selected_many": "Wybrano {{count}} plików",
"{{count}} selected_other": "Wybrano {{count}} pliku",
"Delete Selected": "Usuń wybrane",
"Created": "Utworzono",
"No files found": "Nie znaleziono plików",
"No files uploaded yet": "Nie przesłano jeszcze żadnych plików",
"files": "pliki",
"Page {{current}} of {{total}}": "Strona {{current}} z {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Czy na pewno chcesz usunąć {{count}} wybrany plik?",
"Are you sure to delete {{count}} selected file(s)?_few": "Czy na pewno chcesz usunąć {{count}} wybrane pliki?",
"Are you sure to delete {{count}} selected file(s)?_many": "Czy na pewno chcesz usunąć {{count}} wybranych plików?",
"Are you sure to delete {{count}} selected file(s)?_other": "Czy na pewno chcesz usunąć {{count}} wybrany plik?",
"Cloud Storage Usage": "Użycie pamięci w chmurze",
"Rename Group": "Zmień nazwę grupy",
"From Directory": "Z katalogu",
"Successfully imported {{count}} book(s)_one": "Pomyślnie zaimportowano 1 książkę",
"Successfully imported {{count}} book(s)_few": "Pomyślnie zaimportowano {{count}} książki",
"Successfully imported {{count}} book(s)_many": "Pomyślnie zaimportowano {{count}} książek",
"Successfully imported {{count}} book(s)_other": "Pomyślnie zaimportowano {{count}} książek"
}
@@ -726,5 +726,52 @@
"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}}",
"Manage Storage": "Gerir Armazenamento",
"Failed to load files": "Falha ao carregar arquivos",
"Deleted {{count}} file(s)_one": "Apagado {{count}} ficheiro",
"Deleted {{count}} file(s)_many": "Apagados {{count}} ficheiros",
"Deleted {{count}} file(s)_other": "Apagado {{count}} ficheiro",
"Failed to delete {{count}} file(s)_one": "Falha ao apagar {{count}} ficheiro",
"Failed to delete {{count}} file(s)_many": "Falha ao apagar {{count}} ficheiros",
"Failed to delete {{count}} file(s)_other": "Falha ao apagar {{count}} ficheiro",
"Failed to delete files": "Falha ao apagar arquivos",
"Total Files": "Total de Arquivos",
"Total Size": "Tamanho Total",
"Quota": "Quota",
"Used": "Usado",
"Files": "Arquivos",
"Search files...": "Procurar arquivos...",
"Newest First": "Mais Recentes Primeiro",
"Oldest First": "Mais Antigos Primeiro",
"Largest First": "Maiores Primeiro",
"Smallest First": "Menores Primeiro",
"Name A-Z": "Nome A-Z",
"Name Z-A": "Nome Z-A",
"{{count}} selected_one": "{{count}} selecionado",
"{{count}} selected_many": "{{count}} selecionados",
"{{count}} selected_other": "{{count}} selecionado",
"Delete Selected": "Apagar Selecionados",
"Created": "Criado",
"No files found": "Nenhum arquivo encontrado",
"No files uploaded yet": "Nenhum arquivo enviado ainda",
"files": "arquivos",
"Page {{current}} of {{total}}": "Página {{current}} de {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Tem certeza de que deseja apagar {{count}} ficheiro selecionado?",
"Are you sure to delete {{count}} selected file(s)?_many": "Tem certeza de que deseja apagar {{count}} ficheiros selecionados?",
"Are you sure to delete {{count}} selected file(s)?_other": "Tem certeza de que deseja apagar {{count}} ficheiro selecionado?",
"Cloud Storage Usage": "Uso de Armazenamento na Nuvem",
"Rename Group": "Renomear Grupo",
"From Directory": "Do Diretório",
"Successfully imported {{count}} book(s)_one": "Importado com sucesso 1 livro",
"Successfully imported {{count}} book(s)_many": "Importados com sucesso {{count}} livros",
"Successfully imported {{count}} book(s)_other": "Importados com sucesso {{count}} livros"
}
@@ -731,5 +731,57 @@
"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}}",
"Manage Storage": "Управление хранилищем",
"Failed to load files": "Не удалось загрузить файлы",
"Deleted {{count}} file(s)_one": "Удалён {{count}} файл",
"Deleted {{count}} file(s)_few": "Удалено {{count}} файла",
"Deleted {{count}} file(s)_many": "Удалено {{count}} файлов",
"Deleted {{count}} file(s)_other": "Удалён {{count}} файл",
"Failed to delete {{count}} file(s)_one": "Не удалось удалить {{count}} файл",
"Failed to delete {{count}} file(s)_few": "Не удалось удалить {{count}} файла",
"Failed to delete {{count}} file(s)_many": "Не удалось удалить {{count}} файлов",
"Failed to delete {{count}} file(s)_other": "Не удалось удалить {{count}} файл",
"Failed to delete files": "Не удалось удалить файлы",
"Total Files": "Всего файлов",
"Total Size": "Общий размер",
"Quota": "Квота",
"Used": "Использовано",
"Files": "Файлы",
"Search files...": "Поиск файлов...",
"Newest First": "Сначала новые",
"Oldest First": "Сначала старые",
"Largest First": "Сначала крупные",
"Smallest First": "Сначала мелкие",
"Name A-Z": "Имя A-Z",
"Name Z-A": "Имя Z-A",
"{{count}} selected_one": "{{count}} выбранный",
"{{count}} selected_few": "{{count}} выбранных",
"{{count}} selected_many": "{{count}} выбранных",
"{{count}} selected_other": "{{count}} выбранный",
"Delete Selected": "Удалить выбранные",
"Created": "Создано",
"No files found": "Файлы не найдены",
"No files uploaded yet": "Файлы ещё не загружены",
"files": "файлы",
"Page {{current}} of {{total}}": "Страница {{current}} из {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Вы уверены, что хотите удалить {{count}} выбранный файл?",
"Are you sure to delete {{count}} selected file(s)?_few": "Вы уверены, что хотите удалить {{count}} выбранных файла?",
"Are you sure to delete {{count}} selected file(s)?_many": "Вы уверены, что хотите удалить {{count}} выбранных файлов?",
"Are you sure to delete {{count}} selected file(s)?_other": "Вы уверены, что хотите удалить {{count}} выбранный файл?",
"Cloud Storage Usage": "Использование облачного хранилища",
"Rename Group": "Переименовать группу",
"From Directory": "Из каталога",
"Successfully imported {{count}} book(s)_one": "Успешно импортирован 1 книга",
"Successfully imported {{count}} book(s)_few": "Успешно импортировано {{count}} книги",
"Successfully imported {{count}} book(s)_many": "Успешно импортировано {{count}} книг",
"Successfully imported {{count}} book(s)_other": "Успешно импортировано {{count}} книг"
}
@@ -721,5 +721,47 @@
"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}} තුළ සෙවීම",
"Manage Storage": "ගබඩා කළමනාකරණය කරන්න",
"Failed to load files": "ගොනු උඩුගත කිරීමට නොහැකි විය",
"Deleted {{count}} file(s)_one": "ගොනුව මකා දමා ඇත",
"Deleted {{count}} file(s)_other": "ගොනු මකා දමා ඇත",
"Failed to delete {{count}} file(s)_one": "ගොනුව මකා දැමිය නොහැකි විය",
"Failed to delete {{count}} file(s)_other": "ගොනු මකා දැමිය නොහැකි විය",
"Failed to delete files": "ගොනු මකා දැමිය නොහැකි විය",
"Total Files": "මුළු ගොනු",
"Total Size": "මුළු ප්‍රමාණය",
"Quota": "කොටස",
"Used": "භාවිතා කරන ලදී",
"Files": "ගොනු",
"Search files...": "ගොනු සොයන්න...",
"Newest First": "නවතම පළමුව",
"Oldest First": "පැරණිම පළමුව",
"Largest First": "විශාලතම පළමුව",
"Smallest First": "කුඩාතම පළමුව",
"Name A-Z": "නම A-Z",
"Name Z-A": "නම Z-A",
"{{count}} selected_one": "තෝරාගත් ගොනුව",
"{{count}} selected_other": "තෝරාගත් ගොනු",
"Delete Selected": "තෝරාගත් මකා දමන්න",
"Created": "තනන ලදී",
"No files found": "ගොනු හමු නොවීය",
"No files uploaded yet": "ගොනු තවම උඩුගත කර නැත",
"files": "ගොනු",
"Page {{current}} of {{total}}": "පිටුව {{current}} / {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "තෝරාගත් ගොනුව මකන්නට විශ්වාසද?",
"Are you sure to delete {{count}} selected file(s)?_other": "තෝරාගත් ගොනු මකන්නට විශ්වාසද?",
"Cloud Storage Usage": "කලාප ගබඩා භාවිතය",
"Rename Group": "කණ්ඩායම නැවත නම් කරන්න",
"From Directory": "ෆෝල්ඩරයෙන්",
"Successfully imported {{count}} book(s)_one": "සාර්ථකව ආයාත කළ 1 පොත",
"Successfully imported {{count}} book(s)_other": "සාර්ථකව ආයාත කළ {{count}} පොත්"
}
@@ -721,5 +721,47 @@
"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}}",
"Manage Storage": "Hantera lagring",
"Failed to load files": "Misslyckades att ladda filer",
"Deleted {{count}} file(s)_one": "Raderade filen",
"Deleted {{count}} file(s)_other": "Raderade filerna",
"Failed to delete {{count}} file(s)_one": "Kunde inte radera filen",
"Failed to delete {{count}} file(s)_other": "Kunde inte radera filerna",
"Failed to delete files": "Kunde inte radera filer",
"Total Files": "Totalt antal filer",
"Total Size": "Total storlek",
"Quota": "Kvot",
"Used": "Använd",
"Files": "Filer",
"Search files...": "Sök filer...",
"Newest First": "Nyast först",
"Oldest First": "Äldst först",
"Largest First": "Störst först",
"Smallest First": "Minskst först",
"Name A-Z": "Namn A-Ö",
"Name Z-A": "Namn Ö-A",
"{{count}} selected_one": "Vald fil",
"{{count}} selected_other": "Valda filer",
"Delete Selected": "Radera valda",
"Created": "Skapad",
"No files found": "Inga filer hittades",
"No files uploaded yet": "Inga filer har laddats upp än",
"files": "filer",
"Page {{current}} of {{total}}": "Sida {{current}} av {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Är du säker på att du vill radera filen?",
"Are you sure to delete {{count}} selected file(s)?_other": "Är du säker på att du vill radera filerna?",
"Cloud Storage Usage": "Användning av molnlagring",
"Rename Group": "Byt namn på grupp",
"From Directory": "Från katalog",
"Successfully imported {{count}} book(s)_one": "Importerat 1 bok",
"Successfully imported {{count}} book(s)_other": "Importerat {{count}} böcker"
}
@@ -721,5 +721,47 @@
"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}} இல் தேடவும்",
"Manage Storage": "சேமிப்பை நிர்வகி",
"Failed to load files": "கோப்புகளை ஏற்ற முடியவில்லை",
"Deleted {{count}} file(s)_one": "கோப்பு நீக்கப்பட்டது",
"Deleted {{count}} file(s)_other": "கோப்புகள் நீக்கப்பட்டன",
"Failed to delete {{count}} file(s)_one": "கோப்பை நீக்க முடியவில்லை",
"Failed to delete {{count}} file(s)_other": "கோப்புகளை நீக்க முடியவில்லை",
"Failed to delete files": "கோப்புகளை நீக்க முடியவில்லை",
"Total Files": "மொத்த கோப்புகள்",
"Total Size": "மொத்த அளவு",
"Quota": "கோட்டா",
"Used": "பயன்படுத்தப்பட்டது",
"Files": "கோப்புகள்",
"Search files...": "கோப்புகளைத் தேடு...",
"Newest First": "புதியவை முதலில்",
"Oldest First": "பழையவை முதலில்",
"Largest First": "பெரியவை முதலில்",
"Smallest First": "சிறியவை முதலில்",
"Name A-Z": "பெயர் A-ஆல்",
"Name Z-A": "பெயர் ஆல்-A",
"{{count}} selected_one": "தேர்ந்தெடுக்கப்பட்ட கோப்பு",
"{{count}} selected_other": "தேர்ந்தெடுக்கப்பட்ட கோப்புகள்",
"Delete Selected": "தேர்ந்தெடுத்தவை நீக்கு",
"Created": "உருவாக்கப்பட்டது",
"No files found": "கோப்புகள் இல்லை",
"No files uploaded yet": "இன்னும் எந்த கோப்பும் பதிவேற்றப்படவில்லை",
"files": "கோப்புகள்",
"Page {{current}} of {{total}}": "பக்கம் {{current}} / {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "இந்த கோப்பை நீக்க விரும்புகிறீர்களா?",
"Are you sure to delete {{count}} selected file(s)?_other": "இந்த கோப்புகளை நீக்க விரும்புகிறீர்களா?",
"Cloud Storage Usage": "மேக சேமிப்பு பயன்பாடு",
"Rename Group": "குழுவை மறுபெயரிடவும்",
"From Directory": "கோப்புறையிலிருந்து",
"Successfully imported {{count}} book(s)_one": "வெற்றிகரமாக 1 புத்தகம் இறக்குமதி செய்யப்பட்டது",
"Successfully imported {{count}} book(s)_other": "வெற்றிகரமாக {{count}} புத்தகங்கள் இறக்குமதி செய்யப்பட்டது"
}
@@ -716,5 +716,42 @@
"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}}",
"Manage Storage": "จัดการพื้นที่เก็บข้อมูล",
"Failed to load files": "ไม่สามารถโหลดไฟล์ได้",
"Deleted {{count}} file(s)_other": "ลบไฟล์เรียบร้อยแล้ว",
"Failed to delete {{count}} file(s)_other": "ลบไฟล์ไม่สำเร็จ",
"Failed to delete files": "ลบไฟล์ไม่สำเร็จ",
"Total Files": "จำนวนไฟล์ทั้งหมด",
"Total Size": "ขนาดรวมทั้งหมด",
"Quota": "โควต้า",
"Used": "ใช้ไปแล้ว",
"Files": "ไฟล์",
"Search files...": "ค้นหาไฟล์...",
"Newest First": "ใหม่ที่สุดก่อน",
"Oldest First": "เก่าที่สุดก่อน",
"Largest First": "ใหญ่ที่สุดก่อน",
"Smallest First": "เล็กที่สุดก่อน",
"Name A-Z": "ชื่อ A-ฮ",
"Name Z-A": "ชื่อ ฮ-A",
"{{count}} selected_other": "เลือกไฟล์แล้ว",
"Delete Selected": "ลบไฟล์ที่เลือก",
"Created": "สร้างเมื่อ",
"No files found": "ไม่พบไฟล์",
"No files uploaded yet": "ยังไม่มีการอัปโหลดไฟล์",
"files": "ไฟล์",
"Page {{current}} of {{total}}": "หน้า {{current}} จาก {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "คุณแน่ใจหรือว่าต้องการลบไฟล์ที่เลือก?",
"Cloud Storage Usage": "การใช้งานพื้นที่เก็บข้อมูลคลาวด์",
"Rename Group": "เปลี่ยนชื่อกลุ่ม",
"From Directory": "จากไดเรกทอรี",
"Successfully imported {{count}} book(s)_other": "นำเข้า {{count}} หนังสือเรียบร้อยแล้ว"
}
@@ -721,5 +721,47 @@
"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",
"Manage Storage": "Depolamayı Yönet",
"Failed to load files": "Dosyalar yüklenemedi",
"Deleted {{count}} file(s)_one": "{{count}} dosya silindi",
"Deleted {{count}} file(s)_other": "{{count}} dosya silindi",
"Failed to delete {{count}} file(s)_one": "{{count}} dosya silinemedi",
"Failed to delete {{count}} file(s)_other": "{{count}} dosya silinemedi",
"Failed to delete files": "Dosyalar silinemedi",
"Total Files": "Toplam Dosya",
"Total Size": "Toplam Boyut",
"Quota": "Kota",
"Used": "Kullanıldı",
"Files": "Dosyalar",
"Search files...": "Dosyaları ara...",
"Newest First": "En Yeni Önce",
"Oldest First": "En Eski Önce",
"Largest First": "En Büyük Önce",
"Smallest First": "En Küçük Önce",
"Name A-Z": "İsim A-Z",
"Name Z-A": "İsim Z-A",
"{{count}} selected_one": "{{count}} seçili dosya",
"{{count}} selected_other": "{{count}} seçili dosya",
"Delete Selected": "Seçilenleri Sil",
"Created": "Oluşturulma Tarihi",
"No files found": "Dosya bulunamadı",
"No files uploaded yet": "Henüz dosya yüklenmedi",
"files": "dosyalar",
"Page {{current}} of {{total}}": "{{total}} sayfa içinde {{current}}. sayfa",
"Are you sure to delete {{count}} selected file(s)?_one": "Seçilen {{count}} dosyayı silmek istediğinizden emin misiniz?",
"Are you sure to delete {{count}} selected file(s)?_other": "Seçilen {{count}} dosyayı silmek istediğinizden emin misiniz?",
"Cloud Storage Usage": "Bulut Depolama Kullanımı",
"Rename Group": "Grubu Yeniden Adlandır",
"From Directory": "Dizinden",
"Successfully imported {{count}} book(s)_one": "Başarıyla 1 kitap içe aktarıldı",
"Successfully imported {{count}} book(s)_other": "Başarıyla {{count}} kitap içe aktarıldı"
}
@@ -731,5 +731,57 @@
"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}}",
"Manage Storage": "Керування сховищем",
"Failed to load files": "Не вдалося завантажити файли",
"Deleted {{count}} file(s)_one": "Видалено {{count}} файл",
"Deleted {{count}} file(s)_few": "Видалено {{count}} файли",
"Deleted {{count}} file(s)_many": "Видалено {{count}} файлів",
"Deleted {{count}} file(s)_other": "Видалено {{count}} файлів",
"Failed to delete {{count}} file(s)_one": "Не вдалося видалити {{count}} файл",
"Failed to delete {{count}} file(s)_few": "Не вдалося видалити {{count}} файли",
"Failed to delete {{count}} file(s)_many": "Не вдалося видалити {{count}} файлів",
"Failed to delete {{count}} file(s)_other": "Не вдалося видалити {{count}} файлів",
"Failed to delete files": "Не вдалося видалити файли",
"Total Files": "Всього файлів",
"Total Size": "Загальний розмір",
"Quota": "Квота",
"Used": "Використано",
"Files": "Файли",
"Search files...": "Пошук файлів...",
"Newest First": "Спершу новіші",
"Oldest First": "Спершу старіші",
"Largest First": "Спершу найбільші",
"Smallest First": "Спершу найменші",
"Name A-Z": "Ім'я A-Z",
"Name Z-A": "Ім'я Z-A",
"{{count}} selected_one": "Вибрано {{count}} файл",
"{{count}} selected_few": "Вибрано {{count}} файли",
"{{count}} selected_many": "Вибрано {{count}} файлів",
"{{count}} selected_other": "Вибрано {{count}} файлів",
"Delete Selected": "Видалити вибране",
"Created": "Створено",
"No files found": "Файли не знайдено",
"No files uploaded yet": "Файли ще не завантажені",
"files": "файли",
"Page {{current}} of {{total}}": "Сторінка {{current}} з {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Ви впевнені, що хочете видалити {{count}} файл?",
"Are you sure to delete {{count}} selected file(s)?_few": "Ви впевнені, що хочете видалити {{count}} файли?",
"Are you sure to delete {{count}} selected file(s)?_many": "Ви впевнені, що хочете видалити {{count}} файлів?",
"Are you sure to delete {{count}} selected file(s)?_other": "Ви впевнені, що хочете видалити {{count}} файлів?",
"Cloud Storage Usage": "Використання хмарного сховища",
"Rename Group": "Перейменувати групу",
"From Directory": "З каталогу",
"Successfully imported {{count}} book(s)_one": "Успішно імпортовано 1 книгу",
"Successfully imported {{count}} book(s)_few": "Успішно імпортовано {{count}} книги",
"Successfully imported {{count}} book(s)_many": "Успішно імпортовано {{count}} книг",
"Successfully imported {{count}} book(s)_other": "Успішно імпортовано {{count}} книг"
}
@@ -716,5 +716,42 @@
"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}}",
"Manage Storage": "Quản lý bộ nhớ",
"Failed to load files": "Không tải được tệp",
"Deleted {{count}} file(s)_other": "Đã xóa {{count}} tệp",
"Failed to delete {{count}} file(s)_other": "Không xóa được {{count}} tệp",
"Failed to delete files": "Không xóa được tệp",
"Total Files": "Tổng số tệp",
"Total Size": "Tổng dung lượng",
"Quota": "Dung lượng cho phép",
"Used": "Đã sử dụng",
"Files": "Tệp",
"Search files...": "Tìm tệp...",
"Newest First": "Mới nhất trước",
"Oldest First": "Cũ nhất trước",
"Largest First": "Lớn nhất trước",
"Smallest First": "Nhỏ nhất trước",
"Name A-Z": "Tên A-Z",
"Name Z-A": "Tên Z-A",
"{{count}} selected_other": "Đã chọn {{count}} tệp",
"Delete Selected": "Xóa các mục đã chọn",
"Created": "Đã tạo",
"No files found": "Không tìm thấy tệp",
"No files uploaded yet": "Chưa tải lên tệp nào",
"files": "tệp",
"Page {{current}} of {{total}}": "Trang {{current}} trên {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "Bạn có chắc muốn xóa {{count}} tệp đã chọn không?",
"Cloud Storage Usage": "Sử dụng lưu trữ đám mây",
"Rename Group": "Đổi tên nhóm",
"From Directory": "Từ thư mục",
"Successfully imported {{count}} book(s)_other": "Đã nhập thành công {{count}} sách"
}
@@ -716,5 +716,42 @@
"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}} 中搜索",
"Manage Storage": "管理存储",
"Failed to load files": "加载文件失败",
"Deleted {{count}} file(s)_other": "已删除 {{count}} 个文件",
"Failed to delete {{count}} file(s)_other": "删除 {{count}} 个文件失败",
"Failed to delete files": "删除文件失败",
"Total Files": "文件总数",
"Total Size": "总大小",
"Quota": "配额",
"Used": "已使用",
"Files": "文件",
"Search files...": "搜索文件...",
"Newest First": "最新优先",
"Oldest First": "最旧优先",
"Largest First": "最大优先",
"Smallest First": "最小优先",
"Name A-Z": "名称 A-Z",
"Name Z-A": "名称 Z-A",
"{{count}} selected_other": "已选择 {{count}} 个文件",
"Delete Selected": "删除选中项",
"Created": "创建时间",
"No files found": "未找到文件",
"No files uploaded yet": "尚未上传文件",
"files": "文件",
"Page {{current}} of {{total}}": "第 {{current}} 页,共 {{total}} 页",
"Are you sure to delete {{count}} selected file(s)?_other": "确定要删除已选择的 {{count}} 个文件吗?",
"Cloud Storage Usage": "云存储使用情况",
"Rename Group": "重命名分组",
"From Directory": "从文件夹导入",
"Successfully imported {{count}} book(s)_other": "成功导入 {{count}} 本书"
}
@@ -716,5 +716,42 @@
"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}} 中搜尋",
"Manage Storage": "管理儲存",
"Failed to load files": "載入檔案失敗",
"Deleted {{count}} file(s)_other": "已刪除 {{count}} 個檔案",
"Failed to delete {{count}} file(s)_other": "刪除 {{count}} 個檔案失敗",
"Failed to delete files": "刪除檔案失敗",
"Total Files": "檔案總數",
"Total Size": "總大小",
"Quota": "配額",
"Used": "已使用",
"Files": "檔案",
"Search files...": "搜尋檔案...",
"Newest First": "最新優先",
"Oldest First": "最舊優先",
"Largest First": "最大優先",
"Smallest First": "最小優先",
"Name A-Z": "名稱 A-Z",
"Name Z-A": "名稱 Z-A",
"{{count}} selected_other": "已選擇 {{count}} 個檔案",
"Delete Selected": "刪除所選",
"Created": "建立時間",
"No files found": "找不到檔案",
"No files uploaded yet": "尚未上傳檔案",
"files": "檔案",
"Page {{current}} of {{total}}": "第 {{current}} 頁,共 {{total}} 頁",
"Are you sure to delete {{count}} selected file(s)?_other": "確定要刪除已選擇的 {{count}} 個檔案嗎?",
"Cloud Storage Usage": "雲端儲存使用情況",
"Rename Group": "重新命名",
"From Directory": "從目錄導入",
"Successfully imported {{count}} book(s)_other": "成功導入 {{count}} 本書"
}
+20
View File
@@ -1,5 +1,25 @@
{
"releases": {
"0.9.95": {
"date": "2025-12-08",
"notes": [
"OPDS: You can now search, browse, and download ebooks directly from OPDS catalogs",
"OPDS: Improved compatibility with Calibre Web for reliable downloads",
"OPDS: Fixed an issue where book covers from self-hosted Calibre OPDS servers did not display correctly",
"Cloud Storage: You can view and delete files stored in your cloud storage directly from the app",
"Bookshelf: Added support for importing books from a folder recursively",
"Bookshelf: You can now rename your bookshelf groups",
"EPUB: Added support for SVG covers from Standard Ebooks",
"Annotations: Keyboard shortcuts no longer copy selected text into the notebook",
"Footnotes: Footnote popups now scale properly on small screens",
"Touch/Styli: Removed the context menu for smoother interaction on touch and stylus devices",
"Layout: Devices with unfoldable screens now automatically switch to a two-column layout",
"PDF: Improved zoomed-in navigation and hand-tool behavior for smoother page navigation",
"Android: Fixed highlighting of the current sentence when using native Android text-to-speech",
"Android: Back button handling now works correctly on Android 15 and above",
"Android: You can now open files shared from other apps"
]
},
"0.9.94": {
"date": "2025-12-02",
"notes": [
+16 -3
View File
@@ -44,9 +44,22 @@ fn build_windows_thumbnail() {
}
let dll_name = "windows_thumbnail.dll";
let dll_src = dll_crate_dir.join("target").join(&profile).join(dll_name);
let dll_dest = dll_crate_dir.join("target").join(dll_name);
let candidate_paths = [
dll_crate_dir.join("target").join(&profile).join(dll_name),
dll_crate_dir
.join("target")
.join(&target_triple)
.join(&profile)
.join(dll_name),
];
fs::copy(&dll_src, &dll_dest).expect("Failed to copy windows_thumbnail DLL");
let dll_src = candidate_paths
.iter()
.find(|p| p.exists())
.expect("Failed to find built windows_thumbnail DLL");
let dll_dest = &dll_crate_dir.join("target").join(dll_name);
fs::copy(dll_src, dll_dest).expect("Failed to copy windows_thumbnail DLL");
println!("cargo:rerun-if-changed={}", dll_dest.display());
}
@@ -17,6 +17,7 @@
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:largeHeap="true"
android:enableOnBackInvokedCallback="false"
android:label="@string/app_name"
android:theme="@style/Theme.readest"
android:hardwareAccelerated="true"
@@ -2,17 +2,23 @@ package com.bilingify.readest
import android.os.Build
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import android.view.KeyEvent
import android.webkit.WebView
import android.net.Uri
import android.util.Log
import android.content.Intent
import android.graphics.Color
import android.app.ActivityManager
import android.content.res.Configuration
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.activity.enableEdgeToEdge
import androidx.activity.OnBackPressedCallback
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import com.readest.native_bridge.KeyDownInterceptor
import com.readest.native_bridge.NativeBridgePlugin
@@ -41,6 +47,32 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
interceptBackKeyEnabled = enabled
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_DOWN) {
val keyCode = event.keyCode
val keyName = keyEventMap[keyCode]
if (keyName != null) {
val shouldIntercept = when (keyCode) {
KeyEvent.KEYCODE_BACK -> interceptBackKeyEnabled
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> interceptVolumeKeysEnabled
else -> false
}
if (shouldIntercept) {
wv.evaluateJavascript(
"""
try { window.onNativeKeyDown("$keyName", $keyCode); } catch (_) {}
""".trimIndent(),
null
)
return true
}
}
}
return super.dispatchKeyEvent(event)
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
val keyName = keyEventMap[keyCode]
if (keyName != null) {
@@ -84,6 +116,8 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
handleIncomingIntent(intent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
setTaskDescription(
ActivityManager.TaskDescription(
@@ -93,6 +127,41 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
)
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
onBackInvokedDispatcher.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
OnBackInvokedCallback {
Log.d("MainActivity", "Back invoked callback triggered ${interceptBackKeyEnabled}")
if (interceptBackKeyEnabled) {
Log.d("MainActivity", "Back intercepted (OnBackInvokedCallback)")
wv.evaluateJavascript(
"""window.onNativeKeyDown("Back", ${KeyEvent.KEYCODE_BACK});""",
null
)
} else {
finish()
}
}
)
}
onBackPressedDispatcher.addCallback(this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (interceptBackKeyEnabled) {
Log.d("MainActivity", "Back intercepted (OnBackPressedDispatcher)")
wv.evaluateJavascript(
"""window.onNativeKeyDown("Back", ${KeyEvent.KEYCODE_BACK});""",
null
)
} else {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}
}
)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@@ -100,4 +169,48 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
NativeBridgePlugin.getInstance()?.handleActivityResult(requestCode, resultCode, data)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent?.let { handleIncomingIntent(it) }
}
private fun handleIncomingIntent(intent: Intent) {
when (intent.action) {
Intent.ACTION_SEND -> {
if (intent.type != null) {
handleSingleFile(intent)
}
}
Intent.ACTION_SEND_MULTIPLE -> {
if (intent.type != null) {
handleMultipleFiles(intent)
}
}
}
}
private fun handleSingleFile(intent: Intent) {
val uri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
uri?.let { fileUri ->
val payload = JSObject().apply {
var urls = JSArray()
urls.put(fileUri.toString())
put("urls", urls)
}
NativeBridgePlugin.getInstance()?.triggerEvent("shared-intent", payload)
}
}
private fun handleMultipleFiles(intent: Intent) {
val uris = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
uris?.let { fileUris ->
val payload = JSObject().apply {
var urls = JSArray()
fileUris.forEach { urls.put(it.toString()) }
put("urls", urls)
}
NativeBridgePlugin.getInstance()?.triggerEvent("shared-intent", payload)
}
}
}
@@ -773,4 +773,10 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
path
}
}
fun triggerEvent(eventName: String, payload: JSObject) {
activity.runOnUiThread {
trigger(eventName, payload)
}
}
}
@@ -20,6 +20,8 @@ const COMMANDS: &[&str] = &[
"get_external_sdcard_path",
"open_external_url",
"select_directory",
"register_listener",
"remove_listener",
"request_manage_storage_permission",
"check_permissions",
"request_permissions",
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-register-listener"
description = "Enables the register_listener command without any pre-configured scope."
commands.allow = ["register_listener"]
[[permission]]
identifier = "deny-register-listener"
description = "Denies the register_listener command without any pre-configured scope."
commands.deny = ["register_listener"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-remove-listener"
description = "Enables the remove_listener command without any pre-configured scope."
commands.allow = ["remove_listener"]
[[permission]]
identifier = "deny-remove-listener"
description = "Denies the remove_listener command without any pre-configured scope."
commands.deny = ["remove_listener"]
@@ -26,6 +26,8 @@ Default permissions for the plugin
- `allow-open-external-url`
- `allow-select-directory`
- `allow-request-manage-storage-permission`
- `allow-register-listener`
- `allow-remove-listener`
- `allow-check-permissions`
- `allow-request-permissions`
- `allow-checkPermissions`
@@ -563,6 +565,58 @@ Denies the open_external_url command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-register-listener`
</td>
<td>
Enables the register_listener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-register-listener`
</td>
<td>
Denies the register_listener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-remove-listener`
</td>
<td>
Enables the remove_listener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-remove-listener`
</td>
<td>
Denies the remove_listener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-request-permissions`
</td>
@@ -23,6 +23,8 @@ permissions = [
"allow-open-external-url",
"allow-select-directory",
"allow-request-manage-storage-permission",
"allow-register-listener",
"allow-remove-listener",
"allow-check-permissions",
"allow-request-permissions",
"allow-checkPermissions",
@@ -534,6 +534,30 @@
"const": "deny-open-external-url",
"markdownDescription": "Denies the open_external_url command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "allow-remove-listener",
"markdownDescription": "Enables the remove_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "deny-remove-listener",
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
},
{
"description": "Enables the request-permissions command without any pre-configured scope.",
"type": "string",
@@ -631,10 +655,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
}
]
}
@@ -1,6 +1,8 @@
use tauri::{command, AppHandle, Runtime};
use std::path::PathBuf;
use tauri::{command, AppHandle, Runtime, State};
use crate::models::*;
use crate::DirectoryCallbackState;
use crate::NativeBridgeExt;
use crate::Result;
@@ -160,8 +162,21 @@ pub(crate) async fn open_external_url<R: Runtime>(
#[command]
pub(crate) async fn select_directory<R: Runtime>(
app: AppHandle<R>,
callback_state: State<'_, DirectoryCallbackState<R>>,
) -> Result<SelectDirectoryResponse> {
app.native_bridge().select_directory()
let result = app.native_bridge().select_directory()?;
if let Some(dir_path) = &result.path {
let path = PathBuf::from(dir_path);
if let Ok(callback_guard) = callback_state.callback.lock() {
if let Some(callback) = callback_guard.as_ref() {
callback(&app, &path);
}
}
}
Ok(result)
}
#[command]
@@ -1,3 +1,4 @@
use std::sync::{Arc, Mutex};
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime,
@@ -17,6 +18,9 @@ mod platform;
pub use error::{Error, Result};
use std::path::PathBuf;
use tauri::AppHandle;
#[cfg(desktop)]
use desktop::NativeBridge;
#[cfg(mobile)]
@@ -33,6 +37,20 @@ impl<R: Runtime, T: Manager<R>> crate::NativeBridgeExt<R> for T {
}
}
type DirectoryCallback<R> = Box<dyn Fn(&AppHandle<R>, &PathBuf) + Send + Sync>;
pub struct DirectoryCallbackState<R: Runtime> {
pub callback: Arc<Mutex<Option<DirectoryCallback<R>>>>,
}
impl<R: Runtime> Default for DirectoryCallbackState<R> {
fn default() -> Self {
Self {
callback: Arc::new(Mutex::new(None)),
}
}
}
/// Initializes the plugin.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("native-bridge")
@@ -66,7 +84,18 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
#[cfg(desktop)]
let native_bridge = desktop::init(app, api)?;
app.manage(native_bridge);
app.manage(DirectoryCallbackState::<R>::default());
Ok(())
})
.build()
}
pub fn register_select_directory_callback<R: Runtime>(
app: &AppHandle<R>,
callback: impl Fn(&AppHandle<R>, &PathBuf) + Send + Sync + 'static,
) {
if let Some(state) = app.try_state::<DirectoryCallbackState<R>>() {
let mut cb = state.callback.lock().unwrap();
*cb = Some(Box::new(callback));
}
}
@@ -383,7 +383,10 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
val args = invoke.parseArgs(SetVoiceArgs::class.java)
try {
val voices = textToSpeech?.voices
val targetVoice = voices?.find { it.name == args.voice }
val targetVoice = voices?.find { voice ->
val languageTag = voice.locale.toLanguageTag()
voice.name == args.voice || (languageTag.contains(voice.name) && languageTag == args.voice)
}
if (targetVoice != null) {
val result = textToSpeech?.setVoice(targetVoice)
@@ -404,10 +407,17 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
fun get_all_voices(invoke: Invoke) {
try {
val voices = textToSpeech?.voices?.map { voice ->
val voiceName = voice.name
val language = voice.locale.toLanguageTag()
val (id, name) = if (language.contains(voiceName)) {
language to language
} else {
voiceName to voiceName
}
JSObject().apply {
put("id", voice.name)
put("name", voice.name)
put("lang", voice.locale.toLanguageTag())
put("id", id)
put("name", name)
put("lang", language)
put("disabled", false)
}
} ?: emptyList()
+10 -5
View File
@@ -13,18 +13,19 @@ use tauri::utils::config::BackgroundThrottlingPolicy;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
#[cfg(desktop)]
use std::path::PathBuf;
#[cfg(desktop)]
use tauri::{AppHandle, Listener, Manager, Url};
#[cfg(desktop)]
use tauri::{AppHandle, Manager};
use tauri_plugin_fs::FsExt;
#[cfg(desktop)]
use tauri::{Listener, Url};
#[cfg(target_os = "macos")]
mod macos;
mod transfer_file;
use tauri::{command, Emitter, WebviewUrl, WebviewWindowBuilder, Window};
#[cfg(target_os = "android")]
use tauri_plugin_native_bridge::register_select_directory_callback;
#[cfg(target_os = "android")]
use tauri_plugin_native_bridge::{NativeBridgeExt, OpenExternalUrlRequest};
use tauri_plugin_oauth::start;
#[cfg(not(target_os = "android"))]
@@ -49,7 +50,6 @@ fn allow_file_in_scopes(app: &AppHandle, files: Vec<PathBuf>) {
}
}
#[cfg(desktop)]
fn allow_dir_in_scopes(app: &AppHandle, dir: &PathBuf) {
let fs_scope = app.fs_scope();
let asset_protocol_scope = app.asset_protocol_scope();
@@ -223,6 +223,11 @@ pub fn run() {
allow_dir_in_scopes(app.handle(), &PathBuf::from(get_executable_dir()));
}
#[cfg(target_os = "android")]
register_select_directory_callback(app.handle(), move |app, path| {
allow_dir_in_scopes(app, path);
});
#[cfg(desktop)]
{
app.handle().plugin(tauri_plugin_cli::init())?;
+1 -1
View File
@@ -16,7 +16,7 @@
"csp": {
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost http://*:* https://*:* https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://translate.toil.cc https://*.microsofttranslator.com https://edge.microsoft.com https://*.googleusercontent.com",
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
"img-src": "'self' blob: data: asset: http://asset.localhost https://* https://*:* http://* http://*:*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://chinese-fonts-cdn.netlify.app https://cdnjs.cloudflare.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://chinese-fonts-cdn.netlify.app https://cdnjs.cloudflare.com",
"frame-src": "'self' blob: asset: http://asset.localhost https://*.stripe.com",
@@ -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, */*',
@@ -54,7 +54,6 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
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,
@@ -83,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';
@@ -1,6 +1,6 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { MdCheck, MdChevronRight } from 'react-icons/md';
import { MdCheck, MdChevronRight, MdEdit } from 'react-icons/md';
import { HiOutlineFolder, HiOutlineFolderAdd, HiOutlineFolderRemove } from 'react-icons/hi';
import { IoMdArrowBack } from 'react-icons/io';
@@ -31,14 +31,23 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { setLibrary, addGroup, getGroups, getGroupsByParent, getParentPath, refreshGroups } =
useLibraryStore();
const {
setLibrary,
addGroup,
getGroups,
getGroupId,
getGroupsByParent,
getParentPath,
refreshGroups,
} = useLibraryStore();
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
const [showInput, setShowInput] = useState(false);
const [editGroupName, setEditGroupName] = useState('');
const [selectedGroup, setSelectedGroup] = useState<BookGroupType | null>(null);
const [newGroup, setNewGroup] = useState<BookGroupType | null>(null);
const [isRenaming, setIsRenaming] = useState(false);
const [originalGroupName, setOriginalGroupName] = useState<string | null>(null);
const divRef = useKeyDownActions({ onCancel, onConfirm });
const editorRef = useRef<HTMLInputElement>(null);
@@ -59,6 +68,11 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
.map((hash) => libraryBooks.find((book) => book.hash === hash)?.groupId)
.some((group) => group && group !== BOOK_UNGROUPED_NAME);
const canRenameGroup = selectedBooks.length === 1 && selectedBooks.every((id) => !isMd5(id));
const currentGroupForRename = canRenameGroup
? allGroups.find((group) => group.id === selectedBooks[0])
: null;
const generateNextUntitledGroupName = () => {
const baseName = _('Untitled Group');
const basePattern = parentGroupName
@@ -86,6 +100,17 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
const nextName = generateNextUntitledGroupName();
setEditGroupName(nextName);
setShowInput(true);
setIsRenaming(false);
setOriginalGroupName(null);
};
const handleRenameGroup = () => {
if (!currentGroupForRename) return;
setEditGroupName(currentGroupForRename.name);
setOriginalGroupName(currentGroupForRename.name);
setShowInput(true);
setIsRenaming(true);
};
const handleRemoveFromGroup = () => {
@@ -112,17 +137,44 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
const handleConfirmCreateGroup = () => {
let groupName = editGroupName.trim();
if (groupName) {
if (currentPath && !groupName.startsWith(currentPath + '/')) {
groupName = `${currentPath}/${groupName}`;
}
if (isRenaming && originalGroupName) {
// Renaming existing group
const oldGroupName = originalGroupName;
const newGroup = addGroup(groupName);
setNewGroup(newGroup);
setSelectedGroup(newGroup);
setShowInput(false);
const parentGroup = getParentPath(groupName);
if (parentGroup) {
setCurrentPath(parentGroup);
// Update the group name for all books in this group and nested groups
libraryBooks.forEach((book) => {
if (book.groupName === oldGroupName) {
book.groupName = groupName;
book.groupId = getGroupId(book.groupName);
book.updatedAt = Date.now();
} else if (book.groupName?.startsWith(oldGroupName + '/')) {
book.groupName = book.groupName.replace(oldGroupName, groupName);
book.groupId = getGroupId(book.groupName);
book.updatedAt = Date.now();
}
});
setLibrary([...libraryBooks]);
appService?.saveLibraryBooks(libraryBooks);
refreshGroups();
setShowInput(false);
setIsRenaming(false);
setOriginalGroupName(null);
} else {
// Creating new group
if (currentPath && !groupName.startsWith(currentPath + '/')) {
groupName = `${currentPath}/${groupName}`;
}
const newGroup = addGroup(groupName);
setNewGroup(newGroup);
setSelectedGroup(newGroup);
setShowInput(false);
const parentGroup = getParentPath(groupName);
if (parentGroup) {
setCurrentPath(parentGroup);
}
}
}
};
@@ -205,25 +257,32 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
{/* Action buttons */}
<div className={clsx('mt-4 grid grid-cols-1 gap-2 text-base md:grid-cols-2')}>
{isSelectedBooksHasGroup && (
<button
onClick={handleRemoveFromGroup}
className='flex items-center space-x-2 p-2 text-blue-500'
>
<HiOutlineFolderRemove size={iconSize} />
<span className='truncate'>{_('Remove From Group')}</span>
</button>
)}
<button
onClick={handleRemoveFromGroup}
className='flex items-center space-x-2 p-2 text-blue-500 disabled:text-gray-400'
disabled={!isSelectedBooksHasGroup}
>
<HiOutlineFolderRemove size={iconSize} />
<span className='truncate'>{_('Remove From Group')}</span>
</button>
<button
onClick={handleCreateGroup}
className='flex items-center space-x-2 p-2 text-blue-500'
className='flex items-center space-x-2 p-2 text-blue-500 disabled:text-gray-400'
>
<HiOutlineFolderAdd size={iconSize} />
<span className='truncate'>{_('Create New Group')}</span>
</button>
<button
onClick={handleRenameGroup}
className='flex items-center space-x-2 p-2 text-blue-500 disabled:text-gray-400'
disabled={!canRenameGroup}
>
<MdEdit size={iconSize} />
<span className='truncate'>{_('Rename Group')}</span>
</button>
</div>
{/* Create group input */}
{/* Create/Rename group input */}
{showInput && (
<div className='mt-4 space-y-2'>
<div className='flex items-center gap-2'>
@@ -234,7 +293,11 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
onChange={(e) => setEditGroupName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirmCreateGroup();
if (e.key === 'Escape') setShowInput(false);
if (e.key === 'Escape') {
setShowInput(false);
setIsRenaming(false);
setOriginalGroupName(null);
}
e.stopPropagation();
}}
className='input input-ghost w-full border-0 px-2 text-base !outline-none sm:text-sm'
@@ -1,5 +1,4 @@
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';
@@ -9,20 +8,26 @@ import Menu from '@/components/Menu';
interface ImportMenuProps {
setIsDropdownOpen?: (open: boolean) => void;
onImportBooks: () => void;
onImportBooksFromFiles: () => void;
onImportBooksFromDirectory?: () => void;
onOpenCatalogManager: () => void;
}
const ImportMenu: React.FC<ImportMenuProps> = ({
setIsDropdownOpen,
onImportBooks,
onImportBooksFromFiles,
onImportBooksFromDirectory,
onOpenCatalogManager,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const handleImportBooks = () => {
onImportBooks();
const handleImportFromFiles = () => {
onImportBooksFromFiles();
setIsDropdownOpen?.(false);
};
const handleImportFromDirectory = () => {
onImportBooksFromDirectory?.();
setIsDropdownOpen?.(false);
};
@@ -33,17 +38,21 @@ const ImportMenu: React.FC<ImportMenuProps> = ({
return (
<Menu
className={clsx(
'dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow',
appService?.isMobile ? 'no-triangle' : 'dropdown-center',
)}
className={clsx('dropdown-content bg-base-100 rounded-box z-[1] mt-3 p-2 shadow')}
onCancel={() => setIsDropdownOpen?.(false)}
>
<MenuItem
label={_('From Local File')}
Icon={<IoFileTray className='h-5 w-5' />}
onClick={handleImportBooks}
onClick={handleImportFromFiles}
/>
{onImportBooksFromDirectory && (
<MenuItem
label={_('From Directory')}
Icon={<IoFileTray className='h-5 w-5' />}
onClick={handleImportFromDirectory}
/>
)}
<MenuItem
label={_('Online Library')}
Icon={<MdRssFeed className='h-5 w-5' />}
@@ -26,7 +26,8 @@ import ViewMenu from './ViewMenu';
interface LibraryHeaderProps {
isSelectMode: boolean;
isSelectAll: boolean;
onImportBooks: () => void;
onImportBooksFromFiles: () => void;
onImportBooksFromDirectory?: () => void;
onOpenCatalogManager: () => void;
onToggleSelectMode: () => void;
onSelectAll: () => void;
@@ -36,7 +37,8 @@ interface LibraryHeaderProps {
const LibraryHeader: React.FC<LibraryHeaderProps> = ({
isSelectMode,
isSelectAll,
onImportBooks,
onImportBooksFromFiles,
onImportBooksFromDirectory,
onOpenCatalogManager,
onToggleSelectMode,
onSelectAll,
@@ -152,14 +154,14 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<Dropdown
label={_('Import Books')}
className={clsx(
'exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center',
isMobile ? 'dropdown-end' : 'dropdown-center',
'exclude-title-bar-mousedown dropdown-bottom dropdown-center flex h-6 cursor-pointer justify-center',
)}
buttonClassName='p-0 h-6 min-h-6 w-6 flex items-center justify-center !bg-transparent'
buttonClassName='p-0 h-6 min-h-6 w-6 flex touch-target items-center justify-center !bg-transparent'
toggleButton={<PiPlus role='none' className='m-0.5 h-5 w-5' />}
>
<ImportMenu
onImportBooks={onImportBooks}
onImportBooksFromFiles={onImportBooksFromFiles}
onImportBooksFromDirectory={onImportBooksFromDirectory}
onOpenCatalogManager={onOpenCatalogManager}
/>
</Dropdown>
@@ -7,7 +7,6 @@ import {
RiLoader2Line,
} from 'react-icons/ri';
import { documentDir, join } from '@tauri-apps/api/path';
import { invoke, PermissionState } from '@tauri-apps/api/core';
import { relaunch } from '@tauri-apps/plugin-process';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
@@ -20,6 +19,7 @@ import { formatBytes } from '@/utils/book';
import { getOSPlatform } from '@/utils/misc';
import { getExternalSDCardPath } from '@/utils/bridge';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { requestStoragePermission } from '@/utils/permission';
import Dialog from '@/components/Dialog';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/MenuItem';
@@ -42,10 +42,6 @@ interface MigrationProgress {
currentFile?: string;
}
interface Permissions {
manageStorage: PermissionState;
}
export const MigrateDataWindow = () => {
const _ = useTranslation();
const { appService, envConfig } = useEnv();
@@ -158,13 +154,7 @@ export const MigrateDataWindow = () => {
setErrorMessage('');
if (!dir.includes('Android/data')) {
let permission = await invoke<Permissions>('plugin:native-bridge|checkPermissions');
if (permission.manageStorage !== 'granted') {
permission = await invoke<Permissions>(
'plugin:native-bridge|request_manage_storage_permission',
);
}
if (permission.manageStorage !== 'granted') return;
if (!(await requestStoragePermission())) return;
}
try {
@@ -1,5 +1,5 @@
import { clsx } from 'clsx';
import { CatalogManager } from '@/app/opds/CatelogManager';
import { CatalogManager } from '@/app/opds/components/CatelogManager';
import { useTranslation } from '@/hooks/useTranslation';
import Dialog from '@/components/Dialog';
@@ -20,6 +20,7 @@ import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/
import { optInTelemetry, optOutTelemetry } from '@/utils/telemetry';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { setMigrateDataDirDialogVisible } from '@/app/library/components/MigrateDataWindow';
import { requestStoragePermission } from '@/utils/permission';
import { saveSysSettings } from '@/helpers/settings';
import { selectDirectory } from '@/utils/bridge';
import UserAvatar from '@/components/UserAvatar';
@@ -175,13 +176,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
};
const handleSetSavedBookCoverForLockScreen = async () => {
let permission = await invoke<Permissions>('plugin:native-bridge|checkPermissions');
if (permission.manageStorage !== 'granted') {
permission = await invoke<Permissions>(
'plugin:native-bridge|request_manage_storage_permission',
);
}
if (permission.manageStorage !== 'granted' && appService?.distChannel === 'readest') return;
if (!(await requestStoragePermission()) && appService?.distChannel === 'readest') return;
const newValue = settings.savedBookCoverForLockScreen ? '' : 'default';
if (newValue) {
@@ -334,7 +329,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
noIcon={!appService?.isAndroidApp}
onClick={handleSetRootDir}
/>
{appService?.isAndroidApp && (
{appService?.isAndroidApp && appService?.distChannel !== 'playstore' && (
<MenuItem
label={_('Save Book Cover')}
tooltip={_('Auto-save last book cover')}
+70 -8
View File
@@ -15,7 +15,7 @@ import { formatAuthors, formatTitle, getPrimaryLanguage, listFormater } from '@/
import { eventDispatcher } from '@/utils/event';
import { ProgressPayload } from '@/utils/transfer';
import { throttle } from '@/utils/throttle';
import { getFilename } from '@/utils/path';
import { getDirPath, getFilename, joinPaths } from '@/utils/path';
import { parseOpenWithFiles } from '@/helpers/openWith';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
@@ -34,10 +34,13 @@ import { useTheme } from '@/hooks/useTheme';
import { useUICSS } from '@/hooks/useUICSS';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useBookDataStore } from '@/store/bookDataStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
import { lockScreenOrientation } from '@/utils/bridge';
import { lockScreenOrientation, selectDirectory } from '@/utils/bridge';
import { requestStoragePermission } from '@/utils/permission';
import { SUPPORTED_BOOK_EXTS } from '@/services/constants';
import {
tauriHandleClose,
tauriHandleSetAlwaysOnTop,
@@ -87,6 +90,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const _ = useTranslation();
const { selectFiles } = useFileSelector(appService, _);
const { safeAreaInsets: insets, isRoundedWindow } = useThemeStore();
const { clearBookData } = useBookDataStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore();
const [showCatalogManager, setShowCatalogManager] = useState(false);
@@ -141,7 +145,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setSettingsDialogOpen(true);
},
onOpenBooks: () => {
handleImportBooks();
handleImportBooksFromFiles();
},
});
@@ -364,6 +368,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setLoading(true);
const { library } = useLibraryStore.getState();
const failedImports: Array<{ filename: string; errorMessage: string }> = [];
const successfulImports: string[] = [];
const errorMap: [string, string][] = [
['No chapters detected', _('No chapters detected')],
['Failed to parse EPUB', _('Failed to parse the EPUB file')],
@@ -378,15 +383,25 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
if (!file) return;
try {
const book = await appService?.importBook(file, library);
const { path, basePath } = selectedFile;
if (book && groupId) {
book.groupId = groupId;
book.groupName = getGroupName(groupId);
await updateBook(envConfig, book);
} else if (book && path && basePath) {
const rootPath = getDirPath(basePath);
const groupName = getDirPath(path).replace(rootPath, '').replace(/^\//, '');
book.groupName = groupName;
book.groupId = getGroupId(groupName);
await updateBook(envConfig, book);
}
if (user && book && !book.uploadedAt && settings.autoUpload) {
console.log('Uploading book:', book.title);
handleBookUpload(book, false);
}
if (book) {
successfulImports.push(book.title);
}
} catch (error) {
const filename = typeof file === 'string' ? file : file.name;
const baseFilename = getFilename(filename);
@@ -415,8 +430,17 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
_('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(filenames),
}) + (errorMessage ? `\n${errorMessage}` : ''),
timeout: 5000,
type: 'error',
});
} else if (successfulImports.length > 0) {
eventDispatcher.dispatch('toast', {
message: _('Successfully imported {{count}} book(s)', {
count: successfulImports.length,
}),
timeout: 2000,
type: 'success',
});
}
setLibrary([...library]);
@@ -531,6 +555,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
try {
await appService?.deleteBook(book, deleteAction);
await updateBook(envConfig, book);
clearBookData(book.hash);
if (syncBooks) pushLibrary();
eventDispatcher.dispatch('toast', {
type: 'info',
@@ -579,9 +604,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
await updateBook(envConfig, book);
};
const handleImportBooks = async () => {
const handleImportBooksFromFiles = async () => {
setIsSelectMode(false);
console.log('Importing books...');
console.log('Importing books from files...');
selectFiles({ type: 'books', multiple: true }).then((result) => {
if (result.files.length === 0 || result.error) return;
const groupId = searchParams?.get('group') || '';
@@ -589,6 +614,40 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
});
};
const handleImportBooksFromDirectory = async () => {
if (!appService || !isTauriAppPlatform()) return;
setIsSelectMode(false);
console.log('Importing books from directory...');
let importDirectory: string | undefined = '';
if (appService.isAndroidApp) {
if (!(await requestStoragePermission())) return;
const response = await selectDirectory();
importDirectory = response.path;
} else {
const selectedDir = await appService.selectDirectory?.('read');
importDirectory = selectedDir;
}
if (!importDirectory) {
console.log('No directory selected');
return;
}
const files = await appService.readDirectory(importDirectory, 'None');
const supportedFiles = files.filter((file) => {
const ext = file.path.split('.').pop()?.toLowerCase() || '';
return SUPPORTED_BOOK_EXTS.includes(ext);
});
const toImportFiles = await Promise.all(
supportedFiles.map(async (file) => {
return {
path: await joinPaths(importDirectory, file.path),
basePath: importDirectory,
};
}),
);
importBooks(toImportFiles, undefined);
};
const handleSetSelectMode = (selectMode: boolean) => {
if (selectMode && appService?.hasHaptics) {
impactFeedback('medium');
@@ -653,7 +712,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<LibraryHeader
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
onImportBooks={handleImportBooks}
onImportBooksFromFiles={handleImportBooksFromFiles}
onImportBooksFromDirectory={
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
}
onOpenCatalogManager={() => setShowCatalogManager(true)}
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
onSelectAll={handleSelectAll}
@@ -739,7 +801,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
isSelectNone={isSelectNone}
handleImportBooks={handleImportBooks}
handleImportBooks={handleImportBooksFromFiles}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete('both')}
@@ -761,7 +823,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
'Welcome to your library. You can import your books here and read them anytime.',
)}
</p>
<button className='btn btn-primary rounded-xl' onClick={handleImportBooks}>
<button className='btn btn-primary rounded-xl' onClick={handleImportBooksFromFiles}>
{_('Import Books')}
</button>
</div>
@@ -10,7 +10,7 @@ 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 { validateOPDSURL } from '../utils/opdsUtils';
import ModalPortal from '@/components/ModalPortal';
const POPULAR_CATALOGS: OPDSCatalog[] = [
@@ -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() &&
@@ -119,8 +140,7 @@ export function CatalogManager() {
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);
if (catalog.username) params.set('id', catalog.id);
router.push(`/opds?${params.toString()}`);
};
@@ -217,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
@@ -6,7 +6,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { OPDSFeed, OPDSLink } from '@/types/opds';
import { PublicationCard } from './PublicationCard';
import { NavigationCard } from './NavigationCard';
import { groupByArray } from './utils/opdsUtils';
import { groupByArray } from '../utils/opdsUtils';
interface FeedViewProps {
feed: OPDSFeed;
@@ -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>
@@ -1,10 +1,10 @@
'use client';
import { useMemo } from 'react';
import { groupByArray } from './utils/opdsUtils';
import { useTranslation } from '@/hooks/useTranslation';
import { CachedImage } from '@/components/CachedImage';
import { OPDSPublication, REL } from '@/types/opds';
import { useTranslation } from '@/hooks/useTranslation';
import { groupByArray } from '../utils/opdsUtils';
interface PublicationCardProps {
publication: OPDSPublication;
@@ -5,13 +5,14 @@ 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 { 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';
import { CachedImage } from '@/components/CachedImage';
import { OPDSLink, OPDSPublication, REL, SYMBOL } from '@/types/opds';
import { groupByArray } from '../utils/opdsUtils';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/MenuItem';
@@ -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>
+107 -34
View File
@@ -1,30 +1,30 @@
'use client';
import { useEffect, useState, useCallback, useRef } from 'react';
import clsx from 'clsx';
import { md5 } from 'js-md5';
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 { 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 { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTheme } from '@/hooks/useTheme';
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 clsx from 'clsx';
import { useThemeStore } from '@/store/themeStore';
import { useTheme } from '@/hooks/useTheme';
import { FeedView } from './components/FeedView';
import { PublicationView } from './components/PublicationView';
import { SearchView } from './components/SearchView';
import { Navigation } from './components/Navigation';
type ViewMode = 'feed' | 'publication' | 'search' | 'loading' | 'error';
@@ -50,6 +50,7 @@ export default function BrowserPage() {
const { appService } = useEnv();
const { libraryLoaded } = useLibrary();
const { safeAreaInsets, isRoundedWindow } = useThemeStore();
const { settings } = useSettingsStore();
const [viewMode, setViewMode] = useState<ViewMode>('loading');
const [state, setState] = useState<OPDSState>({
baseURL: '',
@@ -65,16 +66,15 @@ export default function BrowserPage() {
const [historyIndex, setHistoryIndex] = useState(-1);
const searchParams = useSearchParams();
const usernameRef = useRef(searchParams?.get('username'));
const passwordRef = useRef(searchParams?.get('password'));
const usernameRef = useRef<string | null | undefined>(undefined);
const passwordRef = useRef<string | null | undefined>(undefined);
const startURLRef = useRef<string | null | undefined>(undefined);
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]);
@@ -103,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;
@@ -117,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;
@@ -231,14 +248,15 @@ export default function BrowserPage() {
loadingOPDSRef.current = false;
}
},
[router, addToHistory],
[_, router, addToHistory],
);
useEffect(() => {
const url = searchParams?.get('url');
const username = searchParams?.get('username');
const password = searchParams?.get('password');
if (url && !isNavigatingHistoryRef.current) {
const catalogId = searchParams?.get('id') || '';
const catalog = settings.opdsCatalogs?.find((cat) => cat.id === catalogId);
const { username, password } = catalog || {};
if (username || password) {
usernameRef.current = username;
passwordRef.current = password;
@@ -246,25 +264,76 @@ export default function BrowserPage() {
usernameRef.current = null;
passwordRef.current = null;
}
loadOPDS(url);
if (libraryLoaded) {
loadOPDS(url);
}
} else if (isNavigatingHistoryRef.current) {
isNavigatingHistoryRef.current = false;
} else {
setViewMode('error');
setError(new Error('No OPDS URL provided'));
}
}, [searchParams, loadOPDS]);
}, [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,
@@ -284,7 +353,7 @@ export default function BrowserPage() {
return;
} else {
const ext = parsed?.mediaType ? getFileExtFromMimeType(parsed.mediaType) : '';
const basename = getBaseFilename(url);
const basename = new URL(url).pathname.replaceAll('/', '_');
const filename = ext ? `${basename}.${ext}` : basename;
const dstFilePath = await appService?.resolveFilePath(filename, 'Cache');
if (dstFilePath) {
@@ -292,7 +361,9 @@ export default function BrowserPage() {
const password = passwordRef.current || '';
const useProxy = needsProxy(url);
let downloadUrl = useProxy ? getProxiedURL(url, '', true) : url;
const headers: Record<string, string> = {};
const headers: Record<string, string> = {
'User-Agent': 'Readest/1.0 (OPDS Browser)',
};
if (username || password) {
const authHeader = await probeAuth(url, username, password, useProxy);
if (authHeader) {
@@ -451,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=')) {
@@ -48,6 +48,14 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
return Math.min(size, maxSize - popupPadding - 12);
};
const clipPopupWith = (size: number) => {
return Math.min(size, window.innerWidth - popupPadding - 12);
};
const clipPopupHeight = (size: number) => {
return Math.min(size, window.innerHeight - popupPadding - 12);
};
useEffect(() => {
const handleBeforeRender = (e: Event) => {
const detail = (e as CustomEvent).detail;
@@ -100,7 +108,9 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const viewSettings = getViewSettings(bookKey)!;
if (viewSettings.vertical) {
setResponsiveWidth(getResponsivePopupSize(renderer.viewSize, true));
setResponsiveHeight(clipPopupHeight(popupHeight));
} else {
setResponsiveWidth(clipPopupWith(popupWidth));
setResponsiveHeight(getResponsivePopupSize(renderer.viewSize, false));
}
setShowPopup(true);
@@ -124,11 +134,11 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
useEffect(() => {
if (viewSettings.vertical) {
setResponsiveWidth(popupHeight);
setResponsiveWidth(clipPopupWith(popupHeight));
setResponsiveHeight(Math.max(popupWidth, window.innerHeight / 4));
} else {
setResponsiveWidth(Math.max(popupWidth, window.innerWidth / 4));
setResponsiveHeight(popupHeight);
setResponsiveHeight(clipPopupHeight(popupHeight));
}
}, [viewSettings]);
@@ -250,7 +260,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
onDismiss={handleDismissPopup}
>
<div
className=''
className='footnote-content'
ref={footnoteRef}
style={{
width: `${responsiveWidth}px`,
@@ -116,7 +116,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
useEffect(() => {
if (!appService?.isMobileApp) return;
const systemUIVisible = !!hoveredBookKey || settings.alwaysShowStatusBar;
const visible = systemUIVisible && !systemUIAlwaysHidden;
const visible = !!(systemUIVisible && !systemUIAlwaysHidden);
setSystemUIVisibility({ visible, darkMode: isDarkMode });
if (visible) {
showSystemUI();
@@ -70,7 +70,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
}
: {
top: `${topInset}px`,
paddingInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
paddingInline: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
width: '100%',
}
}
@@ -163,10 +163,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
handleScroll,
handleTouchStart,
handleTouchEnd,
handlePointerdown,
handlePointerup,
handleSelectionchange,
handleShowPopup,
handleUpToPopup,
handleContextmenu,
} = useTextSelector(bookKey, setSelection, handleDismissPopup);
const onLoad = (event: Event) => {
@@ -189,6 +191,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
detail.doc?.addEventListener('touchstart', handleTouchStart);
detail.doc?.addEventListener('touchmove', handleTouchmove);
detail.doc?.addEventListener('touchend', handleTouchEnd);
detail.doc?.addEventListener('pointerdown', handlePointerdown);
detail.doc?.addEventListener('pointerup', (ev: PointerEvent) =>
handlePointerup(doc, index, ev),
);
@@ -222,13 +225,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}
// Disable the default context menu on mobile devices (selection handles suffice)
if (appService?.isMobile) {
detail.doc?.addEventListener('contextmenu', (event: Event) => {
event.preventDefault();
event.stopPropagation();
return false;
});
}
detail.doc?.addEventListener('contextmenu', handleContextmenu);
};
const onDrawAnnotation = (event: Event) => {
@@ -385,8 +382,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowWikipediaPopup(false);
};
const handleCopy = () => {
const handleCopy = (copyToNotebook = true) => {
if (!selection || !selection.text) return;
navigator.clipboard?.writeText(selection.text);
handleDismissPopupAndSelection();
if (!copyToNotebook) return;
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Copied to notebook'),
@@ -395,7 +397,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
});
const { booknotes: annotations = [] } = config;
if (selection) navigator.clipboard?.writeText(selection.text);
const cfi = view?.getCFI(selection.index, selection.range);
if (!cfi) return;
const annotation: BookNote = {
@@ -421,7 +422,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
handleDismissPopupAndSelection();
if (!appService?.isMobile) {
setNotebookVisible(true);
}
@@ -535,7 +535,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
handleSearch();
},
onCopySelection: () => {
handleCopy();
handleCopy(false);
},
onTranslateSelection: () => {
handleTranslation();
@@ -26,6 +26,7 @@ export const useTextSelector = (
const isTextSelected = useRef(false);
const isTouchStarted = useRef(false);
const selectionPosition = useRef<number | null>(null);
const lastPointerType = useRef<string>('mouse');
const [textSelected, setTextSelected] = useState(false);
const isValidSelection = (sel: Selection) => {
@@ -111,6 +112,9 @@ export const useTextSelector = (
}
return false;
};
const handlePointerdown = (e: PointerEvent) => {
lastPointerType.current = e.pointerType;
};
const handlePointerup = (doc: Document, index: number, ev: PointerEvent) => {
// Available on iOS and Desktop, fired at touchend or mouseup
// Note that on Android, pointerup event is fired after an additional touch event
@@ -162,6 +166,19 @@ export const useTextSelector = (
isUpToPopup.current = true;
};
const handleContextmenu = (event: Event) => {
if (appService?.isMobile) {
event.preventDefault();
event.stopPropagation();
return false;
} else if (lastPointerType.current === 'touch' || lastPointerType.current === 'pen') {
event.preventDefault();
event.stopPropagation();
return false;
}
return;
};
useEffect(() => {
if (isTextSelected.current && !selectionPosition.current) {
selectionPosition.current = view?.renderer?.start || null;
@@ -202,9 +219,11 @@ export const useTextSelector = (
handleScroll,
handleTouchStart,
handleTouchEnd,
handlePointerdown,
handlePointerup,
handleSelectionchange,
handleShowPopup,
handleUpToPopup,
handleContextmenu,
};
};
@@ -53,6 +53,7 @@ interface AccountActionsProps {
onConfirmDelete: () => void;
onRestorePurchase?: () => void;
onManageSubscription?: () => void;
onManageStorage?: () => void;
}
const AccountActions: React.FC<AccountActionsProps> = ({
@@ -63,6 +64,7 @@ const AccountActions: React.FC<AccountActionsProps> = ({
onConfirmDelete,
onRestorePurchase,
onManageSubscription,
onManageStorage,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
@@ -104,6 +106,14 @@ const AccountActions: React.FC<AccountActionsProps> = ({
</button>
)
)}
{onManageStorage && (
<button
onClick={onManageStorage}
className='w-full rounded-lg bg-purple-100 px-6 py-3 font-medium text-purple-600 transition-colors hover:bg-purple-200 md:w-auto'
>
{_('Manage Storage')}
</button>
)}
<button
onClick={onResetPassword}
className='w-full rounded-lg bg-gray-200 px-6 py-3 font-medium text-gray-800 transition-colors hover:bg-gray-300 md:w-auto'
@@ -0,0 +1,570 @@
'use client';
import clsx from 'clsx';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibrary } from '@/hooks/useLibrary';
import {
listFiles,
getStorageStats,
purgeFiles,
type FileRecord,
type StorageStats,
type ListFilesParams,
} from '@/libs/storage';
import { eventDispatcher } from '@/utils/event';
import { debounce } from '@/utils/debounce';
import Spinner from '@/components/Spinner';
import Alert from '@/components/Alert';
const StorageManager = () => {
const _ = useTranslation();
const { appService } = useEnv();
const { libraryLoaded } = useLibrary();
const { safeAreaInsets } = useThemeStore();
const [loading, setLoading] = useState(false);
const [filesLoaded, setFilesLoaded] = useState(false);
const [files, setFiles] = useState<FileRecord[]>([]);
const [stats, setStats] = useState<StorageStats | null>(null);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [searchInput, setSearchInput] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [sortBy, setSortBy] = useState<ListFilesParams['sortBy']>('created_at');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const [expandedBooks, setExpandedBooks] = useState<Set<string>>(new Set());
const loadFiles = useCallback(async () => {
setLoading(true);
try {
const params: ListFilesParams = {
page: currentPage,
pageSize: 20,
sortBy,
sortOrder,
};
if (searchQuery.trim()) {
params.search = searchQuery.trim();
}
const response = await listFiles(params);
setFiles(response.files);
setTotalPages(response.totalPages);
setFilesLoaded(true);
} catch (error) {
console.error('Failed to load files:', error);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to load files'),
});
} finally {
setLoading(false);
}
}, [currentPage, sortBy, sortOrder, searchQuery, _]);
const loadStats = useCallback(async () => {
setLoading(true);
try {
const statsData = await getStorageStats();
setStats(statsData);
} catch (error) {
console.error('Failed to load stats:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadFiles();
loadStats();
}, [loadFiles, loadStats]);
// Group files by book_hash
const groupedFiles = React.useMemo(() => {
const groups = new Map<string, FileRecord[]>();
files.forEach((file) => {
const bookHash = file.book_hash || 'no-book';
if (!groups.has(bookHash)) {
groups.set(bookHash, []);
}
groups.get(bookHash)!.push(file);
});
return groups;
}, [files]);
const getFileName = (fileKey: string): string => {
const parts = fileKey.split('/');
return parts[parts.length - 1] || fileKey;
};
const isCoverFile = (file: FileRecord): boolean => {
return getFileName(file.file_key).toLowerCase() === 'cover.png';
};
// Get main book file (first non-cover file)
const getMainBookFile = (bookFiles: FileRecord[]): FileRecord | null => {
return bookFiles.find((f) => !isCoverFile(f)) || bookFiles[0] || null;
};
// Get all files for a book including covers
const getAllBookFiles = (bookFiles: FileRecord[]): FileRecord[] => {
return bookFiles;
};
const toggleBookExpansion = (bookHash: string) => {
const newExpanded = new Set(expandedBooks);
if (newExpanded.has(bookHash)) {
newExpanded.delete(bookHash);
} else {
newExpanded.add(bookHash);
}
setExpandedBooks(newExpanded);
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedFiles(new Set(files.map((f) => f.file_key)));
} else {
setSelectedFiles(new Set());
}
};
const handleSelectBook = (bookFiles: FileRecord[], checked: boolean) => {
const newSelected = new Set(selectedFiles);
const allBookFiles = getAllBookFiles(bookFiles);
allBookFiles.forEach((file) => {
if (checked) {
newSelected.add(file.file_key);
} else {
newSelected.delete(file.file_key);
}
});
setSelectedFiles(newSelected);
};
const handleSelectFile = (fileKey: string, checked: boolean) => {
const newSelected = new Set(selectedFiles);
if (checked) {
newSelected.add(fileKey);
} else {
newSelected.delete(fileKey);
}
setSelectedFiles(newSelected);
};
const isBookSelected = (bookFiles: FileRecord[]): boolean => {
const allBookFiles = getAllBookFiles(bookFiles);
return allBookFiles.length > 0 && allBookFiles.every((f) => selectedFiles.has(f.file_key));
};
const isBookPartiallySelected = (bookFiles: FileRecord[]): boolean => {
const allBookFiles = getAllBookFiles(bookFiles);
const selectedCount = allBookFiles.filter((f) => selectedFiles.has(f.file_key)).length;
return selectedCount > 0 && selectedCount < allBookFiles.length;
};
const handleDeleteSelected = async () => {
if (selectedFiles.size === 0) return;
if (!libraryLoaded || !appService) return;
setLoading(true);
try {
const fileKeys = Array.from(selectedFiles);
const fileRecords = files.filter((f) => selectedFiles.has(f.file_key));
const selectedBookHashes = new Set(
fileRecords.map((f) => f.book_hash).filter((hash): hash is string => !!hash),
);
const result = await purgeFiles(fileKeys, true);
const { library, setLibrary } = useLibraryStore.getState();
library
.filter((book) => selectedBookHashes.has(book.hash))
.forEach((book) => {
book.uploadedAt = null;
book.updatedAt = Date.now();
});
setLibrary(library);
appService.saveLibraryBooks(library);
if (result.deletedCount > 0) {
await loadFiles();
await loadStats();
setSelectedFiles(new Set());
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Deleted {{count}} file(s)', { count: result.deletedCount }),
});
if (result.failedCount > 0) {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to delete {{count}} file(s)', { count: result.failedCount }),
});
}
}
} catch (error) {
console.error('Failed to delete files:', error);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to delete files'),
});
} finally {
setLoading(false);
setShowConfirmDelete(false);
}
};
const handleSearchChange = useMemo(
() =>
debounce((value: string) => {
setSearchQuery(value);
setCurrentPage(1);
}, 1000),
[setSearchQuery, setCurrentPage],
);
useEffect(() => {
handleSearchChange(searchInput);
}, [searchInput, handleSearchChange]);
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const getBookTotalSize = (bookFiles: FileRecord[]): number => {
return bookFiles.reduce((sum, file) => sum + file.file_size, 0);
};
const isAllSelected = files.length > 0 && selectedFiles.size === files.length;
return (
<div className='flex flex-col gap-6'>
{/* Stats Section */}
{stats ? (
<div className='bg-base-100 border-base-300 rounded-lg border p-4'>
<h3 className='text-base-content mb-4 text-lg font-semibold'>
{_('Cloud Storage Usage')}
</h3>
<div className='grid grid-cols-2 gap-4 sm:grid-cols-4'>
<div>
<div className='text-base-content/60 text-sm'>{_('Total Files')}</div>
<div className='text-base-content text-xl font-semibold'>{stats.totalFiles}</div>
</div>
<div>
<div className='text-base-content/60 text-sm'>{_('Total Size')}</div>
<div className='text-base-content text-xl font-semibold'>
{formatFileSize(stats.totalSize)}
</div>
</div>
<div>
<div className='text-base-content/60 text-sm'>{_('Quota')}</div>
<div className='text-base-content text-xl font-semibold'>
{formatFileSize(stats.quota)}
</div>
</div>
<div>
<div className='text-base-content/60 text-sm'>{_('Used')}</div>
<div className='text-base-content text-xl font-semibold'>
{stats.usagePercentage}%
</div>
</div>
</div>
<div className='bg-base-300 mt-4 h-2 w-full overflow-hidden rounded-full'>
<div
className='bg-primary h-full transition-all'
style={{ width: `${Math.min(stats.usagePercentage, 100)}%` }}
/>
</div>
</div>
) : (
<div className='bg-base-100 border-base-300 rounded-lg border p-4'>
<div className='skeleton mb-4 h-6 w-32'></div>
<div className='grid grid-cols-2 gap-4 sm:grid-cols-4'>
<div className='skeleton h-16 w-full'></div>
<div className='skeleton h-16 w-full'></div>
<div className='skeleton h-16 w-full'></div>
<div className='skeleton h-16 w-full'></div>
</div>
<div className='skeleton mt-4 h-2 w-full rounded-full'></div>
</div>
)}
{/* Files Section */}
<div className='bg-base-100 border-base-300 rounded-lg border'>
<div className='border-base-300 flex flex-col gap-4 border-b p-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='hidden items-center justify-center sm:flex'>
<h3 className='text-base-content text-lg font-semibold'>{_('Files')}</h3>
</div>
<div className='flex flex-col gap-2 sm:flex-row'>
<input
type='text'
placeholder={_('Search files...')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className='input input-bordered input-sm w-full sm:w-64'
disabled={loading}
/>
<select
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [newSortBy, newSortOrder] = e.target.value.split('-');
setSortBy(newSortBy as ListFilesParams['sortBy']);
setSortOrder(newSortOrder as 'asc' | 'desc');
}}
disabled={loading}
className='select select-bordered select-sm'
>
<option value='created_at-desc'>{_('Newest First')}</option>
<option value='created_at-asc'>{_('Oldest First')}</option>
<option value='file_size-desc'>{_('Largest First')}</option>
<option value='file_size-asc'>{_('Smallest First')}</option>
<option value='file_key-asc'>{_('Name A-Z')}</option>
<option value='file_key-desc'>{_('Name Z-A')}</option>
</select>
</div>
</div>
{/* Actions Bar */}
<div className='bg-base-200 border-base-300 flex items-center justify-between border-b p-4'>
<span className='text-base-content text-sm'>
{_('{{count}} selected', { count: selectedFiles.size })}
</span>
<button
onClick={() => setShowConfirmDelete(true)}
className='btn btn-error btn-sm'
disabled={loading || selectedFiles.size === 0}
>
{_('Delete Selected')}
</button>
</div>
{loading && <Spinner loading />}
{/* Files List - Grouped by Book */}
<div className='w-full'>
<table className='table-sm table w-full [&_td]:px-2 [&_td]:py-1 [&_th]:px-2 [&_th]:py-1'>
<thead className='h-10'>
<tr>
<th className='w-12'>
<div className='flex items-center'>
<input
type='checkbox'
checked={isAllSelected}
onChange={(e) => handleSelectAll(e.target.checked)}
className='checkbox checkbox-sm'
disabled={!filesLoaded || loading}
/>
</div>
</th>
<th className='!ps-0'>{_('File Name')}</th>
<th className='hidden sm:table-cell'>{_('Size')}</th>
<th className='hidden sm:table-cell'>{_('Created')}</th>
</tr>
</thead>
<tbody>
{!filesLoaded ? (
<>
{[...Array(5)].map((_, i) => (
<tr key={i}>
<td className='min-w-16'>
<div className='skeleton h-5 w-5'></div>
</td>
<td className='max-w-0 !ps-0 sm:w-[80%]'>
<div className='flex flex-col gap-2'>
<div className='skeleton h-4 w-3/4'></div>
<div className='skeleton h-3 w-1/2 sm:hidden'></div>
</div>
</td>
<td className='hidden sm:table-cell'>
<div className='skeleton h-4 w-16'></div>
</td>
<td className='hidden sm:table-cell'>
<div className='skeleton h-4 w-20'></div>
</td>
</tr>
))}
</>
) : groupedFiles.size === 0 ? (
<tr>
<td colSpan={4} className='text-center'>
<div className='text-base-content/60 py-8'>
{searchQuery ? _('No files found') : _('No files uploaded yet')}
</div>
</td>
</tr>
) : (
Array.from(groupedFiles.entries()).map(([bookHash, bookFiles]) => {
const mainFile = getMainBookFile(bookFiles);
const isExpanded = expandedBooks.has(bookHash);
const hasMultipleFiles = bookFiles.length > 1;
const bookSelected = isBookSelected(bookFiles);
const bookPartiallySelected = isBookPartiallySelected(bookFiles);
if (!mainFile) return null;
return (
<React.Fragment key={bookHash}>
{/* Main book row */}
<tr className='hover'>
<td>
<div className='flex items-center gap-1'>
<input
type='checkbox'
checked={bookSelected}
ref={(el) => {
if (el) el.indeterminate = bookPartiallySelected;
}}
onChange={(e) => handleSelectBook(bookFiles, e.target.checked)}
disabled={loading}
className='checkbox checkbox-sm'
/>
{hasMultipleFiles && (
<button
onClick={() => toggleBookExpansion(bookHash)}
className='btn btn-ghost btn-xs'
>
{isExpanded ? '' : '+'}
</button>
)}
</div>
</td>
<td className='max-w-0 !ps-0 sm:w-[80%]'>
<div className='flex flex-col'>
<div className='flex items-center gap-2'>
<span className='text-base-content block max-w-full truncate font-medium'>
{getFileName(mainFile.file_key)}
</span>
{hasMultipleFiles && (
<span className='text-base-content/60 flex-shrink-0 whitespace-nowrap text-xs'>
({bookFiles.length} {_('files')})
</span>
)}
</div>
<span className='text-base-content/60 text-xs sm:hidden'>
{formatFileSize(getBookTotalSize(bookFiles))} ·{' '}
{formatDate(mainFile.created_at)}
</span>
</div>
</td>
<td className='hidden whitespace-nowrap sm:table-cell'>
{formatFileSize(getBookTotalSize(bookFiles))}
</td>
<td className='hidden whitespace-nowrap sm:table-cell'>
{formatDate(mainFile.created_at)}
</td>
</tr>
{/* Expanded files (excluding covers unless expanded) */}
{isExpanded &&
bookFiles.map((file) => (
<tr key={file.file_key} className='hover bg-base-200/50'>
<td>
<div className='pl-4'>
<input
type='checkbox'
checked={selectedFiles.has(file.file_key)}
onChange={(e) =>
handleSelectFile(file.file_key, e.target.checked)
}
disabled={loading}
className='checkbox checkbox-sm'
/>
</div>
</td>
<td className='max-w-0 !ps-0 sm:w-[80%]'>
<div className='flex flex-col'>
<span className='text-base-content/80 text-xs'>
{getFileName(file.file_key)}
</span>
</div>
</td>
<td className='hidden sm:table-cell'>
{formatFileSize(file.file_size)}
</td>
<td className='hidden sm:table-cell'>{formatDate(file.created_at)}</td>
</tr>
))}
</React.Fragment>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className='border-base-300 flex items-center justify-between border-t p-4'>
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className='btn btn-sm'
>
{_('Previous')}
</button>
<span className='text-base-content text-sm'>
{_('Page {{current}} of {{total}}', { current: currentPage, total: totalPages })}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className='btn btn-sm'
>
{_('Next')}
</button>
</div>
</div>
{/* Confirm Delete Modal */}
{showConfirmDelete && (
<div
className={clsx('fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
style={{
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
>
<Alert
title={_('Confirm Deletion')}
message={_('Are you sure to delete {{count}} selected file(s)?', {
count: selectedFiles.size,
})}
onCancel={() => {
setShowConfirmDelete(false);
}}
onConfirm={() => {
handleDeleteSelected();
setShowConfirmDelete(false);
}}
/>
</div>
)}
</div>
);
};
export default StorageManager;
+37 -20
View File
@@ -22,6 +22,7 @@ import {
restoreIAPPurchases,
getSubscriptionSuccessUrl as getIAPSubscriptionSuccessUrl,
} from '@/libs/payment/iap/client';
import { isPurchaseProduct } from '@/libs/payment/iap/utils';
import {
createStripeCheckoutSession,
redirectToStripeCheckout,
@@ -38,8 +39,8 @@ import UserInfo from './components/UserInfo';
import UsageStats from './components/UsageStats';
import PlansComparison from './components/PlansComparison';
import AccountActions from './components/AccountActions';
import StorageManager from './components/StorageManager';
import Checkout from './components/Checkout';
import { isPurchaseProduct } from '@/libs/payment/iap/utils';
type CheckoutState = {
clientSecret: string;
@@ -56,6 +57,7 @@ const ProfilePage = () => {
const [loading, setLoading] = useState(false);
const [showEmbeddedCheckout, setShowEmbeddedCheckout] = useState(false);
const [showStorageManager, setShowStorageManager] = useState(false);
const [checkoutState, setCheckoutState] = useState<CheckoutState>({
clientSecret: '',
sessionId: '',
@@ -87,6 +89,8 @@ const ProfilePage = () => {
const handleGoBack = () => {
if (showEmbeddedCheckout) {
setShowEmbeddedCheckout(false);
} else if (showStorageManager) {
setShowStorageManager(false);
} else {
navigateToLibrary(router);
}
@@ -200,6 +204,10 @@ const ProfilePage = () => {
handleConfirmDelete(_('Failed to delete user. Please try again later.'));
};
const handleManageStorage = () => {
setShowStorageManager(true);
};
if (!mounted) {
return null;
}
@@ -262,28 +270,37 @@ const ProfilePage = () => {
planDetails={userPlanDetails}
/>
<UsageStats quotas={quotas} />
{!showStorageManager && <UsageStats quotas={quotas} />}
</div>
<div className='flex flex-col gap-y-8 sm:px-6'>
<PlansComparison
availablePlans={availablePlans}
userPlan={userProfilePlan}
onSubscribe={appService.hasIAP ? handleIAPSubscribe : handleStripeSubscribe}
/>
</div>
{showStorageManager ? (
<div className='flex flex-col gap-y-8 px-6'>
<StorageManager />
</div>
) : (
<>
<div className='flex flex-col gap-y-8 sm:px-6'>
<PlansComparison
availablePlans={availablePlans}
userPlan={userProfilePlan}
onSubscribe={appService.hasIAP ? handleIAPSubscribe : handleStripeSubscribe}
/>
</div>
<div className='flex flex-col gap-y-8 px-6'>
<AccountActions
userPlan={userProfilePlan}
onLogout={handleLogout}
onResetPassword={handleResetPassword}
onUpdateEmail={handleUpdateEmail}
onConfirmDelete={handleDeleteWithMessage}
onRestorePurchase={handleIAPRestorePurchase}
onManageSubscription={handleManageSubscription}
onManageStorage={handleManageStorage}
/>
</div>
</>
)}
<div className='flex flex-col gap-y-8 px-6'>
<AccountActions
userPlan={userProfilePlan}
onLogout={handleLogout}
onResetPassword={handleResetPassword}
onUpdateEmail={handleUpdateEmail}
onConfirmDelete={handleDeleteWithMessage}
onRestorePurchase={handleIAPRestorePurchase}
onManageSubscription={handleManageSubscription}
/>
</div>
<LegalLinks />
</div>
</div>
@@ -87,7 +87,7 @@ export const AboutWindow = () => {
boxClassName='sm:!w-[480px] sm:!max-w-screen-sm sm:h-auto'
>
{isOpen && (
<div className='about-content flex h-full flex-col items-center justify-center gap-4 pb-10 sm:pb-0'>
<div className='about-content flex flex-col items-center justify-center gap-4 pb-10 sm:pb-0'>
<div className='flex flex-1 flex-col items-center justify-end gap-2 px-8 py-2'>
<div className='mb-2 mt-6'>
<Image src='/icon.png' alt='App Logo' className='h-20 w-20' width={64} height={64} />
@@ -5,7 +5,6 @@ import { Book } from '@/types/book';
import { BookMetadata } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useMetadataEdit } from './useMetadataEdit';
import { DeleteAction } from '@/types/system';
@@ -46,14 +45,13 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
handleBookMetadataUpdate,
}) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { safeAreaInsets } = useThemeStore();
const [loading, setLoading] = useState(false);
const [activeDeleteAction, setActiveDeleteAction] = useState<DeleteAction | null>(null);
const [editMode, setEditMode] = useState(false);
const [bookMeta, setBookMeta] = useState<BookMetadata | null>(null);
const [fileSize, setFileSize] = useState<number | null>(null);
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
// Initialize metadata edit hook
const {
@@ -97,7 +95,7 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
const fetchBookDetails = async () => {
const appService = await envConfig.getAppService();
try {
const details = book.metadata || (await appService.fetchBookDetails(book, settings));
const details = book.metadata || (await appService.fetchBookDetails(book));
setBookMeta(details);
const size = await appService.getBookFileSize(book);
setFileSize(size);
@@ -19,7 +19,8 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const { token } = useAuth();
const { envConfig } = useEnv();
const { settings, applyUILanguage } = useSettingsStore();
const { getViewSettings, setViewSettings, recreateViewer } = useReaderStore();
const { getView, getViewSettings, setViewSettings, recreateViewer } = useReaderStore();
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
const [uiLanguage, setUILanguage] = useState(viewSettings.uiLanguage);
@@ -191,7 +192,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
bookKey,
'replaceQuotationMarks',
replaceQuotationMarks,
true,
false,
false,
).then(() => {
recreateViewer(envConfig, bookKey);
@@ -231,7 +232,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
bookKey,
'convertChineseVariant',
convertChineseVariant,
true,
false,
false,
).then(() => {
recreateViewer(envConfig, bookKey);
@@ -268,6 +269,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
className='toggle'
checked={translationEnabled}
onChange={() => setTranslationEnabled(!translationEnabled)}
disabled={!bookKey}
/>
</div>
@@ -311,7 +313,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
</div>
</div>
{isCJKEnv() && (
{(isCJKEnv() || view?.language.isCJK) && (
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Punctuation')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
@@ -333,7 +335,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
</div>
)}
{isCJKEnv() && (
{(isCJKEnv() || view?.language.isCJK) && (
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Convert Simplified and Traditional Chinese')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
@@ -18,6 +18,7 @@ export interface SelectedFile {
// For Tauri file
path?: string;
basePath?: string;
}
export interface FileSelectionResult {
+40 -12
View File
@@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { addPluginListener, PluginListener } from '@tauri-apps/api/core';
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { getCurrentWindow, getAllWindows } from '@tauri-apps/api/window';
import { isTauriAppPlatform } from '@/services/environment';
@@ -13,6 +14,10 @@ interface SingleInstancePayload {
cwd: string;
}
interface SharedIntentPayload {
urls: string[];
}
export function useOpenWithBooks() {
const router = useRouter();
const { appService } = useEnv();
@@ -26,26 +31,43 @@ export function useOpenWithBooks() {
return sortedWindows[0]?.label === currentWindow.label;
};
const handleOpenWithFileUrl = async (url: string) => {
console.log('Handle Open with URL:', url);
let filePath = url;
if (filePath.startsWith('file://')) {
filePath = decodeURI(filePath.replace('file://', ''));
const handleOpenWithFileUrl = async (urls: string[]) => {
console.log('Handle Open with URL:', urls);
const filePaths = [];
for (let url of urls) {
if (url.startsWith('file://')) {
url = decodeURI(url.replace('file://', ''));
}
if (!/^(https?:|data:|blob:)/i.test(url)) {
filePaths.push(url);
}
}
if (!/^(https?:|data:|blob:)/i.test(filePath)) {
if (filePaths.length > 0) {
const settings = useSettingsStore.getState().settings;
if (appService?.hasWindow && settings.openBookInNewWindow) {
if (await isFirstWindow()) {
showLibraryWindow(appService, [filePath]);
showLibraryWindow(appService, filePaths);
}
} else {
window.OPEN_WITH_FILES = [filePath];
window.OPEN_WITH_FILES = filePaths;
setCheckOpenWithBooks(true);
navigateToLibrary(router, `reload=${Date.now()}`);
}
}
};
const initializeListeners = async () => {
return await addPluginListener<SharedIntentPayload>(
'native-bridge',
'shared-intent',
(payload) => {
console.log('Received shared intent:', payload);
const { urls } = payload;
handleOpenWithFileUrl(urls);
},
);
};
useEffect(() => {
if (!isTauriAppPlatform() || !appService) return;
if (listenedOpenWithBooks.current) return;
@@ -55,20 +77,26 @@ export function useOpenWithBooks() {
console.log('Received deep link:', event, payload);
const { args } = payload as SingleInstancePayload;
if (args?.[1]) {
handleOpenWithFileUrl(args[1]);
handleOpenWithFileUrl([args[1]]);
}
});
let unlistenSharedIntent: Promise<PluginListener> | null = null;
// FIXME: register/unregister plugin listeniner on iOS might cause app freeze for unknown reason
// so we only register it on Android for now to support "Shared to Readest" feature
if (appService?.isAndroidApp) {
unlistenSharedIntent = initializeListeners();
}
const listenOpenWithFiles = async () => {
return await onOpenUrl((urls) => {
urls.forEach((url) => {
handleOpenWithFileUrl(url);
});
handleOpenWithFileUrl(urls);
});
};
const unlistenOpenUrl = listenOpenWithFiles();
return () => {
unlistenDeeplink.then((f) => f());
unlistenOpenUrl.then((f) => f());
unlistenSharedIntent?.then((f) => f.unregister());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService]);
+119
View File
@@ -15,6 +15,9 @@ const API_ENDPOINTS = {
upload: getAPIBaseUrl() + '/storage/upload',
download: getAPIBaseUrl() + '/storage/download',
delete: getAPIBaseUrl() + '/storage/delete',
stats: getAPIBaseUrl() + '/storage/stats',
list: getAPIBaseUrl() + '/storage/list',
purge: getAPIBaseUrl() + '/storage/purge',
};
export const createProgressHandler = (
@@ -171,3 +174,119 @@ export const deleteFile = async (filePath: string) => {
throw new Error('File deletion failed');
}
};
export interface StorageStats {
totalFiles: number;
totalSize: number;
usage: number;
quota: number;
usagePercentage: number;
byBookHash: Array<{
bookHash: string | null;
fileCount: number;
totalSize: number;
}>;
}
export const getStorageStats = async (): Promise<StorageStats> => {
try {
const response = await fetchWithAuth(API_ENDPOINTS.stats, {
method: 'GET',
});
return await response.json();
} catch (error) {
console.error('Get storage stats failed:', error);
throw new Error('Get storage stats failed');
}
};
export interface FileRecord {
file_key: string;
file_size: number;
book_hash: string | null;
created_at: string;
updated_at: string | null;
}
export interface ListFilesParams {
page?: number;
pageSize?: number;
sortBy?: 'created_at' | 'updated_at' | 'file_size' | 'file_key';
sortOrder?: 'asc' | 'desc';
bookHash?: string;
search?: string;
}
interface ListFilesResponse {
files: FileRecord[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export const listFiles = async (params?: ListFilesParams): Promise<ListFilesResponse> => {
try {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.set('page', params.page.toString());
if (params?.pageSize) queryParams.set('pageSize', params.pageSize.toString());
if (params?.sortBy) queryParams.set('sortBy', params.sortBy);
if (params?.sortOrder) queryParams.set('sortOrder', params.sortOrder);
if (params?.bookHash) queryParams.set('bookHash', params.bookHash);
if (params?.search) queryParams.set('search', params.search);
const url = queryParams.toString()
? `${API_ENDPOINTS.list}?${queryParams.toString()}`
: API_ENDPOINTS.list;
const response = await fetchWithAuth(url, {
method: 'GET',
});
return await response.json();
} catch (error) {
console.error('List files failed:', error);
throw new Error('List files failed');
}
};
interface PurgeFilesResult {
success: string[];
failed: Array<{ fileKey: string; error: string }>;
deletedCount: number;
failedCount: number;
}
export const purgeFiles = async (
filePathsOrKeys: string[],
isFileKeys = false,
): Promise<PurgeFilesResult> => {
try {
let fileKeys: string[];
if (isFileKeys) {
fileKeys = filePathsOrKeys;
} else {
const userId = await getUserID();
if (!userId) {
throw new Error('Not authenticated');
}
fileKeys = filePathsOrKeys.map((path) => `${userId}/${path}`);
}
const response = await fetchWithAuth(API_ENDPOINTS.purge, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ fileKeys }),
});
return await response.json();
} catch (error) {
console.error('Purge files failed:', error);
throw new Error('Purge files failed');
}
};
@@ -0,0 +1,124 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { validateUserAndToken } from '@/utils/access';
interface FileRecord {
file_key: string;
file_size: number;
book_hash: string | null;
created_at: string;
updated_at: string | null;
}
interface ListFilesResponse {
files: FileRecord[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await runMiddleware(req, res, corsAllMethods);
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { user, token } = await validateUserAndToken(req.headers['authorization']);
if (!user || !token) {
return res.status(403).json({ error: 'Not authenticated' });
}
const reqQuery = req.query as {
page?: string;
pageSize?: string;
sortBy?: string;
sortOrder?: string;
bookHash?: string;
search?: string;
};
const page = parseInt(reqQuery.page as string) || 1;
const pageSize = Math.min(parseInt(reqQuery.pageSize as string) || 50, 100);
const sortBy = (reqQuery.sortBy as string) || 'created_at';
const sortOrder = (reqQuery.sortOrder as string) === 'asc' ? 'asc' : 'desc';
const bookHash = reqQuery.bookHash as string | undefined;
const search = reqQuery.search as string | undefined;
const supabase = createSupabaseAdminClient();
let query = supabase
.from('files')
.select('file_key, file_size, book_hash, created_at, updated_at', { count: 'exact' })
.eq('user_id', user.id)
.is('deleted_at', null);
if (bookHash) {
query = query.eq('book_hash', bookHash);
}
if (search) {
query = query.ilike('file_key', `%${search}%`);
}
const validSortColumns = ['created_at', 'updated_at', 'file_size', 'file_key'];
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : 'created_at';
query = query.order(sortColumn, { ascending: sortOrder === 'asc' });
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
query = query.range(from, to);
const { data: files, error: filesError, count } = await query;
if (filesError) {
console.error('Error querying files:', filesError);
return res.status(500).json({ error: 'Failed to retrieve files' });
}
const total = count || 0;
const totalPages = Math.ceil(total / pageSize);
// Get all book_hashes from the paginated results
const bookHashes = Array.from(
new Set((files || []).map((f) => f.book_hash).filter((hash): hash is string => !!hash)),
);
// Fetch all files with the same book_hashes to ensure complete book groups
// IMPORTANT: We don't apply the search filter here. This ensures that ALL files
// for matched books are included (e.g., cover.png files), even if they don't
// match the search term. This is crucial for proper book grouping and selection.
let allRelatedFiles = files || [];
if (bookHashes.length > 0) {
const relatedQuery = supabase
.from('files')
.select('file_key, file_size, book_hash, created_at, updated_at')
.eq('user_id', user.id)
.is('deleted_at', null)
.in('book_hash', bookHashes);
const { data: relatedFiles, error: relatedError } = await relatedQuery;
if (!relatedError && relatedFiles) {
const fileMap = new Map(allRelatedFiles.map((f) => [f.file_key, f]));
relatedFiles.forEach((f) => fileMap.set(f.file_key, f));
allRelatedFiles = Array.from(fileMap.values());
}
}
const response: ListFilesResponse = {
files: allRelatedFiles,
total,
page,
pageSize,
totalPages,
};
return res.status(200).json(response);
} catch (error) {
console.error(error);
return res.status(500).json({ error: 'Something went wrong' });
}
}
@@ -0,0 +1,146 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { validateUserAndToken } from '@/utils/access';
import { deleteObject } from '@/utils/object';
interface BulkDeleteResult {
success: string[];
failed: Array<{ fileKey: string; error: string }>;
deletedCount: number;
failedCount: number;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await runMiddleware(req, res, corsAllMethods);
if (req.method !== 'DELETE') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { user, token } = await validateUserAndToken(req.headers['authorization']);
if (!user || !token) {
return res.status(403).json({ error: 'Not authenticated' });
}
const { fileKeys } = req.body;
if (!fileKeys || !Array.isArray(fileKeys)) {
return res.status(400).json({ error: 'Missing or invalid fileKeys array' });
}
if (fileKeys.length === 0) {
return res.status(400).json({ error: 'fileKeys array cannot be empty' });
}
if (fileKeys.length > 100) {
return res.status(400).json({ error: 'Cannot delete more than 100 files at once' });
}
if (!fileKeys.every((key) => typeof key === 'string')) {
return res.status(400).json({ error: 'All fileKeys must be strings' });
}
const supabase = createSupabaseAdminClient();
// Fetch all files that match the provided keys and belong to the user
const { data: fileRecords, error: fileError } = await supabase
.from('files')
.select('id, user_id, file_key')
.eq('user_id', user.id)
.in('file_key', fileKeys)
.is('deleted_at', null);
if (fileError) {
console.error('Error querying files:', fileError);
return res.status(500).json({ error: 'Failed to retrieve files for deletion' });
}
if (!fileRecords || fileRecords.length === 0) {
return res.status(404).json({ error: 'No matching files found' });
}
// Verify all files belong to the user
const unauthorizedFiles = fileRecords.filter((record) => record.user_id !== user.id);
if (unauthorizedFiles.length > 0) {
return res.status(403).json({ error: 'Unauthorized access to one or more files' });
}
// Process deletions
const results = await Promise.allSettled(
fileRecords.map(async (fileRecord) => {
try {
// Delete from storage
await deleteObject(fileRecord.file_key);
// Delete from database
const { error: deleteError } = await supabase
.from('files')
.delete()
.eq('id', fileRecord.id);
if (deleteError) {
throw new Error(`Database deletion failed: ${deleteError.message}`);
}
return { fileKey: fileRecord.file_key, success: true };
} catch (error) {
console.error(`Error deleting file ${fileRecord.file_key}:`, error);
return {
fileKey: fileRecord.file_key,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}),
);
const success: string[] = [];
const failed: Array<{ fileKey: string; error: string }> = [];
results.forEach((result) => {
if (result.status === 'fulfilled') {
if (result.value.success) {
success.push(result.value.fileKey);
} else {
failed.push({
fileKey: result.value.fileKey,
error: result.value.error || 'Unknown error',
});
}
} else {
failed.push({
fileKey: 'unknown',
error: result.reason?.message || 'Promise rejected',
});
}
});
// Handle files that weren't found in the database
const foundFileKeys = new Set(fileRecords.map((record) => record.file_key));
const notFoundKeys = fileKeys.filter((key) => !foundFileKeys.has(key));
notFoundKeys.forEach((key) => {
failed.push({
fileKey: key,
error: 'File not found or already deleted',
});
});
const response: BulkDeleteResult = {
success,
failed,
deletedCount: success.length,
failedCount: failed.length,
};
// Return 207 Multi-Status if there are partial failures
const statusCode =
failed.length > 0 && success.length > 0 ? 207 : failed.length > 0 ? 500 : 200;
return res.status(statusCode).json(response);
} catch (error) {
console.error(error);
return res.status(500).json({ error: 'Something went wrong' });
}
}
@@ -0,0 +1,109 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { validateUserAndToken, getStoragePlanData } from '@/utils/access';
interface StorageStats {
totalFiles: number;
totalSize: number;
usage: number;
quota: number;
usagePercentage: number;
byBookHash: Array<{
bookHash: string | null;
fileCount: number;
totalSize: number;
}>;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await runMiddleware(req, res, corsAllMethods);
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { user, token } = await validateUserAndToken(req.headers['authorization']);
if (!user || !token) {
return res.status(403).json({ error: 'Not authenticated' });
}
const supabase = createSupabaseAdminClient();
// Get total file count and size
const { data: totalStats, error: totalError } = await supabase
.from('files')
.select('file_size')
.eq('user_id', user.id)
.is('deleted_at', null);
if (totalError) {
console.error('Error querying total stats:', totalError);
return res.status(500).json({ error: 'Failed to retrieve storage statistics' });
}
const totalFiles = totalStats?.length || 0;
const totalSize = totalStats?.reduce((sum, file) => sum + (file.file_size || 0), 0) || 0;
// Get storage plan data
const { usage, quota } = getStoragePlanData(token);
const usagePercentage = quota > 0 ? Math.round((usage / quota) * 100) : 0;
// Get stats grouped by book_hash
const { data: bookHashStats, error: bookHashError } = await supabase.rpc(
'get_storage_by_book_hash',
{ p_user_id: user.id },
);
// Fallback if RPC function doesn't exist - manual aggregation
let byBookHash: Array<{ bookHash: string | null; fileCount: number; totalSize: number }> = [];
if (bookHashError) {
console.warn('RPC function not available, using fallback aggregation:', bookHashError);
const { data: allFiles, error: filesError } = await supabase
.from('files')
.select('book_hash, file_size')
.eq('user_id', user.id)
.is('deleted_at', null);
if (!filesError && allFiles) {
const grouped = new Map<string | null, { count: number; size: number }>();
allFiles.forEach((file) => {
const key = file.book_hash;
const current = grouped.get(key) || { count: 0, size: 0 };
grouped.set(key, {
count: current.count + 1,
size: current.size + file.file_size,
});
});
byBookHash = Array.from(grouped.entries())
.map(([bookHash, stats]) => ({
bookHash,
fileCount: stats.count,
totalSize: stats.size,
}))
.sort((a, b) => b.totalSize - a.totalSize);
}
} else if (bookHashStats) {
byBookHash = bookHashStats;
}
const response: StorageStats = {
totalFiles,
totalSize,
usage,
quota,
usagePercentage,
byBookHash,
};
return res.status(200).json(response);
} catch (error) {
console.error(error);
return res.status(500).json({ error: 'Something went wrong' });
}
}
+23 -8
View File
@@ -72,6 +72,7 @@ import { BOOK_FILE_NOT_FOUND_ERROR } from './errors';
import { CustomTextureInfo } from '@/styles/textures';
import { CustomFont, CustomFontInfo } from '@/styles/fonts';
import { parseFontInfo } from '@/utils/font';
import { svg2png } from '@/utils/svg';
export abstract class BaseAppService implements AppService {
osPlatform: OsPlatform = getOSPlatform();
@@ -98,6 +99,7 @@ export abstract class BaseAppService implements AppService {
hasScreenBrightness = false;
hasIAP = false;
canCustomizeRootDir = false;
canReadExternalDir = false;
distChannel = 'readest' as DistChannel;
protected CURRENT_MIGRATION_VERSION = 20251124;
@@ -354,7 +356,6 @@ export abstract class BaseAppService implements AppService {
loadedBook.metadata.title = getBaseFilename(filename);
}
} catch (error) {
console.error(error);
throw new Error(`Failed to open the book: ${(error as Error).message || error}`);
}
@@ -405,13 +406,26 @@ export abstract class BaseAppService implements AppService {
} else if (typeof file === 'string' && isContentURI(file)) {
await this.fs.copyFile(file, getLocalBookFilename(book), 'Books');
} else if (typeof file === 'string' && !isValidURL(file)) {
await this.fs.copyFile(file, getLocalBookFilename(book), 'Books');
try {
// try to copy the file directly first in case of large files to avoid memory issues
// on desktop when reading recursively from selected directory the direct copy will fail
// due to permission issues, then fallback to read and write files
await this.fs.copyFile(file, getLocalBookFilename(book), 'Books');
} catch {
await this.fs.writeFile(getLocalBookFilename(book), 'Books', fileobj);
}
} else {
await this.fs.writeFile(getLocalBookFilename(book), 'Books', fileobj);
}
}
if (saveCover && (!(await this.fs.exists(getCoverFilename(book), 'Books')) || overwrite)) {
const cover = await loadedBook.getCover();
let cover = await loadedBook.getCover();
if (cover?.type === 'image/svg+xml') {
try {
console.log('Converting SVG cover to PNG...');
cover = await svg2png(cover);
} catch {}
}
if (cover) {
await this.fs.writeFile(getCoverFilename(book), 'Books', await cover.arrayBuffer());
}
@@ -441,6 +455,7 @@ export abstract class BaseAppService implements AppService {
return existingBook || book;
} catch (error) {
console.error('Error importing book:', error);
throw error;
}
}
@@ -668,7 +683,7 @@ export abstract class BaseAppService implements AppService {
return null;
}
async loadBookContent(book: Book, settings: SystemSettings): Promise<BookContent> {
async loadBookContent(book: Book): Promise<BookContent> {
let file: File;
const fp = getLocalBookFilename(book);
if (await this.fs.exists(fp, 'Books')) {
@@ -692,7 +707,7 @@ export abstract class BaseAppService implements AppService {
throw new Error(BOOK_FILE_NOT_FOUND_ERROR);
}
}
return { book, file, config: await this.loadBookConfig(book, settings) };
return { book, file };
}
async loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig> {
@@ -711,13 +726,13 @@ export abstract class BaseAppService implements AppService {
}
}
async fetchBookDetails(book: Book, settings: SystemSettings) {
async fetchBookDetails(book: Book) {
const fp = getLocalBookFilename(book);
if (!(await this.fs.exists(fp, 'Books')) && book.uploadedAt) {
await this.downloadBook(book);
}
const { file } = (await this.loadBookContent(book, settings)) as BookContent;
const bookDoc = (await new DocumentLoader(file).open()).book as BookDoc;
const { file } = await this.loadBookContent(book);
const bookDoc = (await new DocumentLoader(file).open()).book;
const f = file as ClosableFile;
if (f && f.close) {
await f.close();
@@ -34,7 +34,7 @@ import {
FileItem,
DistChannel,
} from '@/types/system';
import { getOSPlatform, isContentURI, isValidURL } from '@/utils/misc';
import { getOSPlatform, isContentURI, isFileURI, isValidURL } from '@/utils/misc';
import { getDirPath, getFilename } from '@/utils/path';
import { NativeFile, RemoteFile } from '@/utils/file';
import { copyURIToPath } from '@/utils/bridge';
@@ -212,17 +212,20 @@ export const nativeFileSystem: FileSystem = {
}
return await new NativeFile(dst, fname, baseDir ? baseDir : null).open();
}
} else if (isFileURI(path)) {
return await new NativeFile(fp, fname, baseDir ? baseDir : null).open();
} else {
const prefix = await this.getPrefix(base);
const absolutePath = path.startsWith('/') ? path : prefix ? await join(prefix, path) : null;
if (absolutePath && OS_TYPE !== 'android') {
if (OS_TYPE === 'android') {
// NOTE: RemoteFile is not usable on Android due to a known issue of range request in Android WebView.
// see https://issues.chromium.org/issues/40739128
return await new NativeFile(fp, fname, baseDir ? baseDir : null).open();
} else {
// NOTE: RemoteFile currently performs about 2× faster than NativeFile
// due to an unresolved performance issue in Tauri (see tauri-apps/tauri#9190).
// Once the bug is resolved, we should switch back to using NativeFile.
// RemoteFile is not usable on Android due to unknown issues of range fetch with Android WebView.
const prefix = await this.getPrefix(base);
const absolutePath = prefix ? await join(prefix, path) : path;
return await new RemoteFile(this.getURL(absolutePath), fname).open();
} else {
return await new NativeFile(fp, fname, baseDir ? baseDir : null).open();
}
}
},
@@ -319,10 +322,13 @@ export const nativeFileSystem: FileSystem = {
} else {
const filePath = await join(parent, entry.name);
const relativePath = relative ? await join(relative, entry.name) : entry.name;
const fileInfo = await stat(filePath, baseDir ? { baseDir } : undefined);
const opts = baseDir ? { baseDir } : undefined;
const fileSize = await stat(filePath, opts)
.then((info) => info.size)
.catch(() => 0);
fileList.push({
path: relativePath,
size: fileInfo.size,
size: fileSize,
});
}
}
@@ -374,6 +380,7 @@ export class NativeAppService extends BaseAppService {
// CustomizeRootDir has a blocker on macOS App Store builds due to Security Scoped Resource restrictions.
// See: https://github.com/tauri-apps/tauri/issues/3716
override canCustomizeRootDir = DIST_CHANNEL !== 'appstore';
override canReadExternalDir = DIST_CHANNEL !== 'appstore' && DIST_CHANNEL !== 'playstore';
override distChannel = DIST_CHANNEL;
private execDir?: string = undefined;
@@ -181,6 +181,7 @@ export class NativeTTSClient implements TTSClient {
const { marks } = parseSSMLMarks(ssml, this.#primaryLang);
for (const mark of marks) {
this.controller?.dispatchSpeakMark(mark);
for await (const ev of this.speakMark(mark, preload, signal)) {
if (signal.aborted) {
yield { code: 'error', message: 'Aborted' } as TTSMessageEvent;
@@ -27,6 +27,7 @@ interface BookDataState {
) => void;
updateBooknotes: (key: string, booknotes: BookNote[]) => BookConfig | undefined;
getBookData: (keyOrId: string) => BookData | null;
clearBookData: (keyOrId: string) => void;
}
export const useBookDataStore = create<BookDataState>((set, get) => ({
@@ -35,6 +36,16 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
const id = keyOrId.split('-')[0]!;
return get().booksData[id] || null;
},
clearBookData: (keyOrId: string) => {
const id = keyOrId.split('-')[0]!;
set((state) => {
const newBooksData = { ...state.booksData };
delete newBooksData[id];
return {
booksData: newBooksData,
};
});
},
getConfig: (key: string | null) => {
if (!key) return null;
const id = key.split('-')[0]!;
+4 -11
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { Book, BookGroupType, BooksGroup } from '@/types/book';
import { EnvConfigType, isTauriAppPlatform } from '@/services/environment';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { BOOK_UNGROUPED_NAME } from '@/services/constants';
import { md5Fingerprint } from '@/utils/md5';
interface LibraryState {
@@ -94,19 +94,12 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
const groups: Record<string, string> = {};
library.forEach((book) => {
if (
book.groupId &&
book.groupName &&
book.groupId !== BOOK_UNGROUPED_ID &&
book.groupName !== BOOK_UNGROUPED_NAME &&
!book.deletedAt
) {
groups[book.groupId] = book.groupName;
if (book.groupName && book.groupName !== BOOK_UNGROUPED_NAME && !book.deletedAt) {
groups[md5Fingerprint(book.groupName)] = book.groupName;
let nextSlashIndex = book.groupName.indexOf('/', 0);
while (nextSlashIndex > 0) {
const groupName = book.groupName.substring(0, nextSlashIndex);
const groupId = md5Fingerprint(groupName);
groups[groupId] = groupName;
groups[md5Fingerprint(groupName)] = groupName;
nextSlashIndex = book.groupName.indexOf('/', nextSlashIndex + 1);
}
}
+45 -43
View File
@@ -144,51 +144,53 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
},
}));
try {
const appService = await envConfig.getAppService();
const { settings } = useSettingsStore.getState();
if (!bookData || reload) {
const appService = await envConfig.getAppService();
const { library } = useLibraryStore.getState();
const book = library.find((b) => b.hash === id);
if (!book) {
throw new Error('Book not found');
}
const content = (await appService.loadBookContent(book, settings)) as BookContent;
const { file, config } = content;
console.log('Loading book', key);
const { book: bookDoc } = await new DocumentLoader(file).open();
await updateToc(
bookDoc,
config.viewSettings?.sortedTOC ?? false,
config.viewSettings?.convertChineseVariant ?? 'none',
);
if (!bookDoc.metadata.title) {
bookDoc.metadata.title = getBaseFilename(file.name);
}
book.sourceTitle = formatTitle(bookDoc.metadata.title);
// Correct language codes mistakenly set with language names
if (typeof bookDoc.metadata?.language === 'string') {
if (bookDoc.metadata.language in SUPPORTED_LANGNAMES) {
bookDoc.metadata.language = SUPPORTED_LANGNAMES[bookDoc.metadata.language]!;
}
}
// Set the book's language for formerly imported books, newly imported books have this field set
const primaryLanguage = getPrimaryLanguage(bookDoc.metadata.language);
book.primaryLanguage = book.primaryLanguage ?? primaryLanguage;
book.metadata = book.metadata ?? bookDoc.metadata;
// TODO: uncomment this when we can ensure metaHash is correctly generated for all books
// book.metaHash = book.metaHash ?? getMetadataHash(bookDoc.metadata);
book.metaHash = getMetadataHash(bookDoc.metadata);
const isFixedLayout = FIXED_LAYOUT_FORMATS.has(book.format);
useBookDataStore.setState((state) => ({
booksData: {
...state.booksData,
[id]: { id, book, file, config, bookDoc, isFixedLayout },
},
}));
const { library } = useLibraryStore.getState();
const book = library.find((b) => b.hash === id);
if (!book) {
throw new Error('Book not found');
}
const booksData = useBookDataStore.getState().booksData;
const config = booksData[id]?.config as BookConfig;
let bookDoc = bookData?.bookDoc;
let file = bookData?.file;
if (!bookDoc || !file || reload) {
const content = (await appService.loadBookContent(book)) as BookContent;
file = content.file;
console.log('Loading book', key);
const doc = await new DocumentLoader(file).open();
bookDoc = doc.book;
}
const config = await appService.loadBookConfig(book, settings);
await updateToc(
bookDoc,
config.viewSettings?.sortedTOC ?? false,
config.viewSettings?.convertChineseVariant ?? 'none',
);
if (!bookDoc.metadata.title) {
bookDoc.metadata.title = getBaseFilename(file.name);
}
book.sourceTitle = formatTitle(bookDoc.metadata.title);
// Correct language codes mistakenly set with language names
if (typeof bookDoc.metadata?.language === 'string') {
if (bookDoc.metadata.language in SUPPORTED_LANGNAMES) {
bookDoc.metadata.language = SUPPORTED_LANGNAMES[bookDoc.metadata.language]!;
}
}
// Set the book's language for formerly imported books, newly imported books have this field set
const primaryLanguage = getPrimaryLanguage(bookDoc.metadata.language);
book.primaryLanguage = book.primaryLanguage ?? primaryLanguage;
book.metadata = book.metadata ?? bookDoc.metadata;
// TODO: uncomment this when we can ensure metaHash is correctly generated for all books
// book.metaHash = book.metaHash ?? getMetadataHash(bookDoc.metadata);
book.metaHash = getMetadataHash(bookDoc.metadata);
const isFixedLayout = FIXED_LAYOUT_FORMATS.has(book.format);
useBookDataStore.setState((state) => ({
booksData: {
...state.booksData,
[id]: { id, book, file, config, bookDoc, isFixedLayout },
},
}));
const configViewSettings = config.viewSettings!;
const globalViewSettings = settings.globalViewSettings;
set((state) => ({
+10
View File
@@ -371,6 +371,16 @@ foliate-fxl {
display: block;
}
.touch-target {
position: relative;
}
.touch-target::before {
content: "";
position: absolute;
inset: -12px;
}
.no-context-menu {
-webkit-touch-callout: none;
-webkit-user-select: none;
-1
View File
@@ -293,5 +293,4 @@ export interface BooksGroup {
export interface BookContent {
book: Book;
file: File;
config: BookConfig;
}
+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;
+3 -2
View File
@@ -66,6 +66,7 @@ export interface AppService {
isPortableApp: boolean;
isDesktopApp: boolean;
canCustomizeRootDir: boolean;
canReadExternalDir: boolean;
distChannel: DistChannel;
init(): Promise<void>;
@@ -112,9 +113,9 @@ export interface AppService {
isBookAvailable(book: Book): Promise<boolean>;
getBookFileSize(book: Book): Promise<number | null>;
loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig>;
fetchBookDetails(book: Book, settings: SystemSettings): Promise<BookMetadata>;
fetchBookDetails(book: Book): Promise<BookMetadata>;
saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings): Promise<void>;
loadBookContent(book: Book, settings: SystemSettings): Promise<BookContent>;
loadBookContent(book: Book): Promise<BookContent>;
loadLibraryBooks(): Promise<Book[]>;
saveLibraryBooks(books: Book[]): Promise<void>;
getCoverImageUrl(book: Book): string;
+8 -3
View File
@@ -1,13 +1,18 @@
import { ViewSettings } from '@/types/book';
export const getMaxInlineSize = (viewSettings: ViewSettings) => {
const isVertical = viewSettings.vertical!;
const isVertical = viewSettings.vertical;
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
return isVertical && false
const screenAspectRatio = isVertical ? screenHeight / screenWidth : screenWidth / screenHeight;
const isUnfoldedScreen = screenAspectRatio < 1.3 && screenAspectRatio > 0.77 && screenWidth > 600;
return isVertical
? Math.max(screenWidth, screenHeight, 720)
: viewSettings.maxInlineSize!;
: isUnfoldedScreen
? viewSettings.maxInlineSize * 0.8
: viewSettings.maxInlineSize;
};
export const getDefaultMaxInlineSize = () => {
+5
View File
@@ -1,3 +1,4 @@
import { join } from '@tauri-apps/api/path';
import { isContentURI, isFileURI, isValidURL } from './misc';
export const getFilename = (fileOrUri: string) => {
@@ -28,3 +29,7 @@ export const getDirPath = (filePath: string) => {
parts.pop();
return parts.join('/');
};
export const joinPaths = async (...paths: string[]) => {
return await join(...paths);
};
+15
View File
@@ -0,0 +1,15 @@
import { invoke, PermissionState } from '@tauri-apps/api/core';
interface Permissions {
manageStorage: PermissionState;
}
export const requestStoragePermission = async (): Promise<boolean> => {
let permission = await invoke<Permissions>('plugin:native-bridge|checkPermissions');
if (permission.manageStorage !== 'granted') {
permission = await invoke<Permissions>(
'plugin:native-bridge|request_manage_storage_permission',
);
}
return permission.manageStorage === 'granted';
};
+13
View File
@@ -612,16 +612,29 @@ export const transformStylesheet = (vw: number, vh: number, css: string) => {
// Process duokan-bleed
css = css.replace(ruleRegex, (_, selector, block) => {
const directions = ['top', 'bottom', 'left', 'right'];
let hasBleed = false;
for (const dir of directions) {
const bleedRegex = new RegExp(`duokan-bleed\\s*:\\s*[^;]*${dir}[^;]*;`);
const marginRegex = new RegExp(`margin-${dir}\\s*:`);
if (bleedRegex.test(block) && !marginRegex.test(block)) {
hasBleed = true;
block = block.replace(
/}$/,
` margin-${dir}: calc(-1 * var(--margin-${dir})) !important; }`,
);
}
}
if (hasBleed) {
if (!/position\s*:/.test(block)) {
block = block.replace(/}$/, ' position: relative !important; }');
}
if (!/overflow\s*:/.test(block)) {
block = block.replace(/}$/, ' overflow: hidden !important; }');
}
if (!/display\s*:/.test(block)) {
block = block.replace(/}$/, ' display: flow-root !important; }');
}
}
return selector + block;
});
+69
View File
@@ -0,0 +1,69 @@
function parseSvgLength(value: string) {
const n = parseFloat(value);
if (!isNaN(n)) return n;
return undefined;
}
async function getSvgSize(
svgBlob: Blob,
defaultWidth: number = 700,
defaultHeight: number = 1050,
): Promise<{ width: number; height: number }> {
const text = await svgBlob.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'image/svg+xml');
const svg = doc.documentElement;
const widthAttr = svg.getAttribute('width');
const heightAttr = svg.getAttribute('height');
if (widthAttr && heightAttr) {
return {
width: parseSvgLength(widthAttr) || defaultWidth,
height: parseSvgLength(heightAttr) || defaultHeight,
};
}
const viewBox = svg.getAttribute('viewBox');
if (viewBox) {
const parts = viewBox.split(/\s+/).map(Number);
if (parts.length === 4 && !parts.some(isNaN)) {
const [, , vbWidth, vbHeight] = parts;
return { width: vbWidth || defaultWidth, height: vbHeight || defaultHeight };
}
}
return { width: defaultWidth, height: defaultHeight };
}
export async function svg2png(svgBlob: Blob, quality: number = 0.9): Promise<Blob> {
const svgText = await svgBlob.text();
const svgUrl = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' }));
const img = new Image();
img.decoding = 'sync';
img.crossOrigin = 'anonymous';
img.src = svgUrl;
await img.decode().catch(
() =>
new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
}),
);
const canvas = document.createElement('canvas');
const { width, height } = await getSvgSize(svgBlob);
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0);
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob!), 'image/png', quality);
});
}
+41 -26
View File
@@ -59,6 +59,47 @@
<launchable type="desktop-id">com.bilingify.readest.desktop</launchable>
<releases>
<release version="0.9.95" date="2025-12-08">
<description>
<ul>
<li>OPDS: You can now search, browse, and download ebooks directly from OPDS catalogs</li>
<li>OPDS: Improved compatibility with Calibre Web for reliable downloads</li>
<li>OPDS: Fixed an issue where book covers from self-hosted Calibre OPDS servers did not display correctly</li>
<li>Cloud Storage: You can view and delete files stored in your cloud storage directly from the app</li>
<li>Bookshelf: Added support for importing books from a folder recursively</li>
<li>Bookshelf: You can now rename your bookshelf groups</li>
<li>EPUB: Added support for SVG covers from Standard Ebooks</li>
<li>Annotations: Keyboard shortcuts no longer copy selected text into the notebook</li>
<li>Footnotes: Footnote popups now scale properly on small screens</li>
<li>Touch/Styli: Removed the context menu for smoother interaction on touch and stylus devices</li>
<li>Layout: Devices with unfoldable screens now automatically switch to a two-column layout</li>
<li>PDF: Improved zoomed-in navigation and hand-tool behavior for smoother page navigation</li>
<li>Android: Fixed highlighting of the current sentence when using native Android text-to-speech</li>
<li>Android: Back button handling now works correctly on Android 15 and above</li>
<li>Android: You can now open files shared from other apps</li>
</ul>
</description>
</release>
<release version="0.9.94" date="2025-12-02">
<description>
<ul>
<li>OPDS: You can now browse and download ebooks directly from OPDS catalogs</li>
<li>PDF: Added a hand-tool mode that makes navigating PDF pages easier</li>
<li>Covers: You can now choose a folder to save the latest book cover image</li>
<li>CJK: Added one-tap conversion between Simplified and Traditional Chinese</li>
<li>Shortcuts: Use Ctrl + mouse wheel to quickly zoom in or out</li>
<li>Library: Added a new option to adjust cover sizes in grid view</li>
<li>Windows: Added Ebook thumbnails with book covers in Windows Explorer</li>
<li>TTS: Fixed an issue where the page would scroll when showing the annotator</li>
<li>Annotations: Resolved layout shifts when selecting text in paginated mode on Android</li>
<li>Files: Improved backup handling to prevent file corruption</li>
<li>Layout: Fixed text clipping caused by negative indents</li>
<li>E-ink: Improved sidebar and notebook background colors for e-ink devices</li>
<li>Android: The Back button now works properly in menus, dialogs, and alerts</li>
<li>iOS: Fixed occasional failures when opening books on older iOS versions (16.4 and below)</li>
</ul>
</description>
</release>
<release version="0.9.93" date="2025-11-20">
<description>
<ul>
@@ -163,32 +204,6 @@
</ul>
</description>
</release>
<release version="0.9.82" date="2025-10-01">
<description>
<ul>
<li>Sync: More reliable syncing between KOReader and Readest</li>
<li>MOBI: Fixed an issue where some footnotes in MOBI/AZW files were not parsed correctly</li>
<li>Storage: Added support for changing data location on Windows, macOS, Linux, and Android</li>
<li>Windows: Portable EXE version now stores all reading data in the directory of the executable</li>
<li>Android: Added background TTS support with media session controls</li>
<li>WebView: Extended compatibility down to version 92</li>
<li>Settings: Added global settings menu to the library page</li>
<li>iOS: Fixed an issue where custom theme colors were not handled correctly on older Safari versions</li>
<li>TTS: Added more languages for Edge TTS</li>
</ul>
</description>
</release>
<release version="0.9.81" date="2025-09-19">
<description>
<ul>
<li>TTS: Added desktop common media control support when TTS is playing</li>
<li>PDF: Disabled swipe-up gesture for toggling the action bar when zoomed in</li>
<li>Fonts: Fixed an issue where embedded fonts in ebooks were occasionally not applied</li>
<li>Reader: Fixed an issue where newly created highlights sometimes would not appear</li>
<li>Reader: Enabled scrolling in the menu when the menu is too long</li>
</ul>
</description>
</release>
</releases>
<metadata_license>FSFAP</metadata_license>
+1 -1
View File
@@ -1 +1 @@
9edddf69c669d9df9a2eab0e0a016af74582195729de1e1307a4a08e10007ac2 ../../data/metainfo/appdata.xml
73a2e3dbe1e611ed79a06ca0cb5f1d6c38b93de0be5669f8082e8a209283945e ../../data/metainfo/appdata.xml
+9
View File
@@ -17,5 +17,14 @@
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.8",
"typescript": "^5"
},
"pnpm": {
"overrides": {
"glob": ">=11.1.0",
"jws": ">=4.0.1",
"vite": ">=7.0.8",
"@babel/runtime": ">=7.26.10",
"@babel/helpers": ">=7.26.10"
}
}
}

Some files were not shown because too many files have changed in this diff Show More