forked from akai/readest
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eb7d91122 | |||
| 1fb468b3a6 | |||
| 3c7d95cf10 | |||
| ba3f060cc4 | |||
| 11bc7497e8 | |||
| fb5d149413 | |||
| 42b47d73b7 | |||
| 00f36af03a | |||
| b78466ca93 | |||
| 4e6f146b8f | |||
| cbdd4940d0 | |||
| d022cb984a | |||
| 8de6fa267e | |||
| b08b7de8e9 | |||
| a232a39f0e | |||
| fad7966fc4 | |||
| a1487fd60c | |||
| 9606e315d4 | |||
| 978673268b | |||
| 70158a7f15 | |||
| 18d65a2c5b | |||
| 1b0c2afad7 | |||
| cef444d374 | |||
| 75f6efe27a | |||
| 852f9f40ec | |||
| b9dadc0f4f |
@@ -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"
|
||||
|
||||
@@ -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. | 🔄 |
|
||||
|
||||
@@ -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 A–Z",
|
||||
"Name Z-A": "Name Z–A",
|
||||
"{{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": "Όνομα 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": "Χρήση αποθήκευσης 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 A–Z",
|
||||
"Name Z-A": "Nombre Z–A",
|
||||
"{{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": "نام 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}} کتاب وارد شد"
|
||||
}
|
||||
|
||||
@@ -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}} 本書"
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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"
|
||||
|
||||
+114
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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",
|
||||
|
||||
+13
@@ -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"]
|
||||
+13
@@ -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"]
|
||||
+54
@@ -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",
|
||||
|
||||
+26
-2
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+14
-4
@@ -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()
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+24
-4
@@ -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
|
||||
+1
-1
@@ -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;
|
||||
+14
-1
@@ -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>
|
||||
+2
-2
@@ -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;
|
||||
+8
-3
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
+15
-13
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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]!;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -293,5 +293,4 @@ export interface BooksGroup {
|
||||
export interface BookContent {
|
||||
book: Book;
|
||||
file: File;
|
||||
config: BookConfig;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
9edddf69c669d9df9a2eab0e0a016af74582195729de1e1307a4a08e10007ac2 ../../data/metainfo/appdata.xml
|
||||
73a2e3dbe1e611ed79a06ca0cb5f1d6c38b93de0be5669f8082e8a209283945e ../../data/metainfo/appdata.xml
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: a6b6252d7c...771c2bcf16
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user