feat(library): add Import from Folder dialog with format/size filters (#4229)
* feat(library): add Import from Folder dialog with format/size filters Replaces the silent "import every supported file recursively" behaviour of the directory import menu item with an explicit dialog that lets users pick which formats to include, set a minimum file size, and choose between mirroring subfolders as nested groups (legacy behaviour) or flattening every match into the current library view. The folder, the chosen Folder Structure radio, the ticked File Formats and the File Size threshold are all persisted in localStorage so re-opening the dialog seeds every field with the user's last choice. Cancelling the dialog does not write to storage so an aborted pick won't pollute the next session. Also hides the native number-input spinner via a small .no-spinner utility in globals.css; on macOS WebKit the spin buttons were drawing over the rounded input border and looked broken. The KB suffix now lives inside the input's bordered shell instead of beside it. Two correctness fixes the dialog flow exposed: * The library importer + ingestService now treat groupId as a tri-state — undefined means "don't touch the existing group", '' means "explicitly the library root", any other string means a specific group. Previously a falsy check in both layers conflated '' with undefined, so re-importing a deduped book under flatten mode silently kept its stale groupId/groupName from the prior keep-as-groups run, making the book reappear in the old subfolder group instead of moving into the library root. New regression tests in ingest-service.test.ts cover both the empty-string case and the omitted case. * Imports of arbitrary user paths (e.g. ~/Downloads) now go through a new allow_paths_in_scopes Tauri command that extends both fs_scope and asset_protocol_scope. The dialog plugin only auto-grants fs_scope, so reads through the asset protocol (RemoteFile / convertFileSrc) used to fail with "asset protocol not configured to allow the path". The shim is invoked after every selectFiles / selectDirectory call and once more at the start of runFolderImport so localStorage-restored paths are also covered. Granted scopes persist across restarts via tauri_plugin_persisted_scope. * fixup(library): harden Import-from-Folder scope grant + RTL/dialog polish Three review fixes on top of the Import-from-Folder feature: * lib.rs: refuse to extend asset_protocol_scope for paths not already in fs_scope. Without this gate, any frontend code (XSS via book content, OPDS HTML, dictionary lookups, or a compromised dependency) could call allow_paths_in_scopes with '/' or '~/.ssh' and gain persistent read access to arbitrary user files via the asset protocol — the grant survives restarts thanks to tauri_plugin_persisted_scope. Mirrors the defensive check in dir_scanner.rs. * ImportFromFolderDialog.tsx: migrate from a custom ModalPortal chassis to the project's shared <Dialog> primitive so eink mode auto-removes shadows, mobile gets the bottom-sheet treatment, RTL direction is applied, and focus management is correct. * ImportFromFolderDialog.tsx: swap directional Tailwind utilities for the logical equivalents (text-start, ps-/pe-, rounded-s-, text-end) per DESIGN.md §2.8 — Arabic/Hebrew users were getting a mirrored number-input row with the KB suffix on the wrong side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * i18n(library): translate Import-from-Folder dialog strings across 33 locales Translates the 13 new strings introduced with the Import-from-Folder dialog (folder picker label, format-filter section, size-threshold input, folder-structure radios, OK button, empty-result toast). All 33 supported locales — including RTL fa/he/ar — are now complete; no __STRING_NOT_TRANSLATED__ placeholders remain in the catalog. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1608,5 +1608,18 @@
|
||||
"System": "النظام",
|
||||
"System dictionary integration is coming soon on this platform.": "تكامل قاموس النظام قادم قريباً على هذه المنصة.",
|
||||
"Waiting to be processed": "في انتظار المعالجة",
|
||||
"Processing…": "جارٍ المعالجة…"
|
||||
"Processing…": "جارٍ المعالجة…",
|
||||
"Folder": "مجلد",
|
||||
"Choose a folder": "اختر مجلداً",
|
||||
"File Formats": "صيغ الملفات",
|
||||
"File size larger than": "حجم الملف أكبر من",
|
||||
"Minimum file size (KB)": "الحد الأدنى لحجم الملف (كيلوبايت)",
|
||||
"KB": "كيلوبايت",
|
||||
"Folder Structure": "بنية المجلدات",
|
||||
"Create groups from subfolders": "إنشاء مجموعات من المجلدات الفرعية",
|
||||
"Each first-level subfolder becomes a library group.": "يصبح كل مجلد فرعي من المستوى الأول مجموعةً في المكتبة.",
|
||||
"Import all into library": "استيراد الكل إلى المكتبة",
|
||||
"Recursively add every matching file directly to the library.": "إضافة كل ملف مطابق مباشرةً إلى المكتبة بشكل تكراري.",
|
||||
"OK": "موافق",
|
||||
"No matching books found in the selected folder.": "لم يتم العثور على كتب مطابقة في المجلد المحدد."
|
||||
}
|
||||
|
||||
@@ -1496,5 +1496,18 @@
|
||||
"System": "সিস্টেম",
|
||||
"System dictionary integration is coming soon on this platform.": "এই প্ল্যাটফর্মে সিস্টেম অভিধান ইন্টিগ্রেশন শীঘ্রই আসছে।",
|
||||
"Waiting to be processed": "প্রক্রিয়াকরণের অপেক্ষায়",
|
||||
"Processing…": "প্রক্রিয়াকরণ হচ্ছে…"
|
||||
"Processing…": "প্রক্রিয়াকরণ হচ্ছে…",
|
||||
"Folder": "ফোল্ডার",
|
||||
"Choose a folder": "একটি ফোল্ডার নির্বাচন করুন",
|
||||
"File Formats": "ফাইল ফরম্যাট",
|
||||
"File size larger than": "ফাইলের আকার বড়",
|
||||
"Minimum file size (KB)": "ন্যূনতম ফাইলের আকার (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "ফোল্ডার গঠন",
|
||||
"Create groups from subfolders": "সাবফোল্ডার থেকে গ্রুপ তৈরি করুন",
|
||||
"Each first-level subfolder becomes a library group.": "প্রথম স্তরের প্রতিটি সাবফোল্ডার একটি লাইব্রেরি গ্রুপ হয়ে যায়।",
|
||||
"Import all into library": "সব লাইব্রেরিতে আমদানি করুন",
|
||||
"Recursively add every matching file directly to the library.": "প্রতিটি মিলিত ফাইলকে পুনরাবৃত্তিকভাবে সরাসরি লাইব্রেরিতে যোগ করুন।",
|
||||
"OK": "ঠিক আছে",
|
||||
"No matching books found in the selected folder.": "নির্বাচিত ফোল্ডারে কোনো মিলিত বই পাওয়া যায়নি।"
|
||||
}
|
||||
|
||||
@@ -1468,5 +1468,18 @@
|
||||
"System": "མ་ལག",
|
||||
"System dictionary integration is coming soon on this platform.": "སྡོམ་གཞི་འདིའི་ནང་མ་ལག་ཚིག་མཛོད་མཐུན་སྒྲིལ་གྱི་ནུས་པ་མྱུར་དུ་འབྱུང་ངེས།",
|
||||
"Waiting to be processed": "སྦྱོར་སྦྱང་གི་སྒུག་ནས།",
|
||||
"Processing…": "སྦྱོར་སྦྱང་བྱེད་བཞིན་པ…"
|
||||
"Processing…": "སྦྱོར་སྦྱང་བྱེད་བཞིན་པ…",
|
||||
"Folder": "ཡིག་སྣོད་",
|
||||
"Choose a folder": "ཡིག་སྣོད་ཞིག་འདེམས།",
|
||||
"File Formats": "ཡིག་ཆའི་རྣམ་གཞག",
|
||||
"File size larger than": "ཡིག་ཆའི་ཆེ་ཆུང་འདི་ལས་ཆེ་བ་",
|
||||
"Minimum file size (KB)": "ཡིག་ཆའི་ཆེ་ཆུང་ཉུང་མཐར་ (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "ཡིག་སྣོད་ཀྱི་གཞི་རྩ་",
|
||||
"Create groups from subfolders": "ཡན་ལག་ཡིག་སྣོད་ནས་སྡེ་ཚན་གསར་སྐྲུན་བྱེད།",
|
||||
"Each first-level subfolder becomes a library group.": "རིམ་པ་དང་པོའི་ཡན་ལག་ཡིག་སྣོད་རེ་དཔེ་མཛོད་ཀྱི་སྡེ་ཚན་དུ་འགྱུར་ངེས།",
|
||||
"Import all into library": "ཡོངས་སུ་དཔེ་མཛོད་དུ་ནང་འདྲེན་བྱེད།",
|
||||
"Recursively add every matching file directly to the library.": "མཐུན་པའི་ཡིག་ཆ་ཡོངས་སུ་དཔེ་མཛོད་དུ་ཐད་ཀར་ལོག་སྣོན་བྱས་ཏེ་ཁ་སྣོན་གྱིས།",
|
||||
"OK": "ཡིན།",
|
||||
"No matching books found in the selected folder.": "བདམས་པའི་ཡིག་སྣོད་ནང་མཐུན་པའི་དཔེ་ཆ་ག་ཡང་མ་རྙེད།"
|
||||
}
|
||||
|
||||
@@ -1496,5 +1496,18 @@
|
||||
"System": "System",
|
||||
"System dictionary integration is coming soon on this platform.": "Die Integration des Systemwörterbuchs ist auf dieser Plattform bald verfügbar.",
|
||||
"Waiting to be processed": "Wartet auf Verarbeitung",
|
||||
"Processing…": "Wird verarbeitet…"
|
||||
"Processing…": "Wird verarbeitet…",
|
||||
"Folder": "Ordner",
|
||||
"Choose a folder": "Ordner auswählen",
|
||||
"File Formats": "Dateiformate",
|
||||
"File size larger than": "Dateigröße größer als",
|
||||
"Minimum file size (KB)": "Mindestdateigröße (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Ordnerstruktur",
|
||||
"Create groups from subfolders": "Gruppen aus Unterordnern erstellen",
|
||||
"Each first-level subfolder becomes a library group.": "Jeder Unterordner der ersten Ebene wird zu einer Bibliotheksgruppe.",
|
||||
"Import all into library": "Alles in Bibliothek importieren",
|
||||
"Recursively add every matching file directly to the library.": "Alle passenden Dateien rekursiv direkt zur Bibliothek hinzufügen.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "Keine passenden Bücher im ausgewählten Ordner gefunden."
|
||||
}
|
||||
|
||||
@@ -1496,5 +1496,18 @@
|
||||
"System": "Σύστημα",
|
||||
"System dictionary integration is coming soon on this platform.": "Η ενσωμάτωση λεξικού συστήματος έρχεται σύντομα σε αυτή την πλατφόρμα.",
|
||||
"Waiting to be processed": "Αναμονή για επεξεργασία",
|
||||
"Processing…": "Επεξεργασία…"
|
||||
"Processing…": "Επεξεργασία…",
|
||||
"Folder": "Φάκελος",
|
||||
"Choose a folder": "Επιλέξτε φάκελο",
|
||||
"File Formats": "Μορφές αρχείων",
|
||||
"File size larger than": "Μέγεθος αρχείου μεγαλύτερο από",
|
||||
"Minimum file size (KB)": "Ελάχιστο μέγεθος αρχείου (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Δομή φακέλων",
|
||||
"Create groups from subfolders": "Δημιουργία ομάδων από υποφακέλους",
|
||||
"Each first-level subfolder becomes a library group.": "Κάθε υποφάκελος πρώτου επιπέδου γίνεται ομάδα στη βιβλιοθήκη.",
|
||||
"Import all into library": "Εισαγωγή όλων στη βιβλιοθήκη",
|
||||
"Recursively add every matching file directly to the library.": "Αναδρομική προσθήκη κάθε αντίστοιχου αρχείου απευθείας στη βιβλιοθήκη.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "Δεν βρέθηκαν αντίστοιχα βιβλία στον επιλεγμένο φάκελο."
|
||||
}
|
||||
|
||||
@@ -1524,5 +1524,18 @@
|
||||
"System": "Sistema",
|
||||
"System dictionary integration is coming soon on this platform.": "La integración del diccionario del sistema estará disponible próximamente en esta plataforma.",
|
||||
"Waiting to be processed": "Esperando ser procesado",
|
||||
"Processing…": "Procesando…"
|
||||
"Processing…": "Procesando…",
|
||||
"Folder": "Carpeta",
|
||||
"Choose a folder": "Elegir una carpeta",
|
||||
"File Formats": "Formatos de archivo",
|
||||
"File size larger than": "Tamaño de archivo mayor que",
|
||||
"Minimum file size (KB)": "Tamaño mínimo de archivo (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Estructura de carpetas",
|
||||
"Create groups from subfolders": "Crear grupos a partir de subcarpetas",
|
||||
"Each first-level subfolder becomes a library group.": "Cada subcarpeta de primer nivel se convierte en un grupo de la biblioteca.",
|
||||
"Import all into library": "Importar todo a la biblioteca",
|
||||
"Recursively add every matching file directly to the library.": "Añadir recursivamente cada archivo coincidente directamente a la biblioteca.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "No se encontraron libros que coincidan en la carpeta seleccionada."
|
||||
}
|
||||
|
||||
@@ -1496,5 +1496,18 @@
|
||||
"System": "سیستم",
|
||||
"System dictionary integration is coming soon on this platform.": "یکپارچهسازی فرهنگنامه سیستم بهزودی در این پلتفرم در دسترس قرار میگیرد.",
|
||||
"Waiting to be processed": "در انتظار پردازش",
|
||||
"Processing…": "در حال پردازش…"
|
||||
"Processing…": "در حال پردازش…",
|
||||
"Folder": "پوشه",
|
||||
"Choose a folder": "یک پوشه انتخاب کنید",
|
||||
"File Formats": "قالبهای فایل",
|
||||
"File size larger than": "اندازه فایل بزرگتر از",
|
||||
"Minimum file size (KB)": "حداقل اندازه فایل (کیلوبایت)",
|
||||
"KB": "کیلوبایت",
|
||||
"Folder Structure": "ساختار پوشه",
|
||||
"Create groups from subfolders": "ایجاد گروه از زیرپوشهها",
|
||||
"Each first-level subfolder becomes a library group.": "هر زیرپوشه سطح اول به یک گروه کتابخانه تبدیل میشود.",
|
||||
"Import all into library": "وارد کردن همه به کتابخانه",
|
||||
"Recursively add every matching file directly to the library.": "افزودن بازگشتی همه فایلهای مطابق بهطور مستقیم به کتابخانه.",
|
||||
"OK": "تأیید",
|
||||
"No matching books found in the selected folder.": "هیچ کتاب مطابقی در پوشه انتخابشده یافت نشد."
|
||||
}
|
||||
|
||||
@@ -1524,5 +1524,18 @@
|
||||
"System": "Système",
|
||||
"System dictionary integration is coming soon on this platform.": "L’intégration du dictionnaire système arrive bientôt sur cette plateforme.",
|
||||
"Waiting to be processed": "En attente de traitement",
|
||||
"Processing…": "Traitement…"
|
||||
"Processing…": "Traitement…",
|
||||
"Folder": "Dossier",
|
||||
"Choose a folder": "Choisir un dossier",
|
||||
"File Formats": "Formats de fichier",
|
||||
"File size larger than": "Taille de fichier supérieure à",
|
||||
"Minimum file size (KB)": "Taille minimale du fichier (Ko)",
|
||||
"KB": "Ko",
|
||||
"Folder Structure": "Structure des dossiers",
|
||||
"Create groups from subfolders": "Créer des groupes à partir des sous-dossiers",
|
||||
"Each first-level subfolder becomes a library group.": "Chaque sous-dossier de premier niveau devient un groupe de la bibliothèque.",
|
||||
"Import all into library": "Tout importer dans la bibliothèque",
|
||||
"Recursively add every matching file directly to the library.": "Ajouter récursivement tous les fichiers correspondants directement à la bibliothèque.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "Aucun livre correspondant trouvé dans le dossier sélectionné."
|
||||
}
|
||||
|
||||
@@ -1524,5 +1524,18 @@
|
||||
"System": "מערכת",
|
||||
"System dictionary integration is coming soon on this platform.": "שילוב מילון המערכת יהיה זמין בקרוב בפלטפורמה זו.",
|
||||
"Waiting to be processed": "ממתין לעיבוד",
|
||||
"Processing…": "מעבד…"
|
||||
"Processing…": "מעבד…",
|
||||
"Folder": "תיקייה",
|
||||
"Choose a folder": "בחר תיקייה",
|
||||
"File Formats": "סוגי קבצים",
|
||||
"File size larger than": "גודל קובץ גדול מ-",
|
||||
"Minimum file size (KB)": "גודל קובץ מינימלי (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "מבנה תיקיות",
|
||||
"Create groups from subfolders": "צור קבוצות מתת-תיקיות",
|
||||
"Each first-level subfolder becomes a library group.": "כל תת-תיקייה ברמה הראשונה הופכת לקבוצה בספרייה.",
|
||||
"Import all into library": "ייבא הכול לספרייה",
|
||||
"Recursively add every matching file directly to the library.": "הוסף באופן רקורסיבי כל קובץ תואם ישירות לספרייה.",
|
||||
"OK": "אישור",
|
||||
"No matching books found in the selected folder.": "לא נמצאו ספרים תואמים בתיקייה שנבחרה."
|
||||
}
|
||||
|
||||
@@ -1496,5 +1496,18 @@
|
||||
"System": "सिस्टम",
|
||||
"System dictionary integration is coming soon on this platform.": "इस प्लेटफ़ॉर्म पर सिस्टम शब्दकोश एकीकरण जल्द ही आ रहा है।",
|
||||
"Waiting to be processed": "संसाधन की प्रतीक्षा में",
|
||||
"Processing…": "संसाधित किया जा रहा है…"
|
||||
"Processing…": "संसाधित किया जा रहा है…",
|
||||
"Folder": "फ़ोल्डर",
|
||||
"Choose a folder": "फ़ोल्डर चुनें",
|
||||
"File Formats": "फ़ाइल फ़ॉर्मेट",
|
||||
"File size larger than": "फ़ाइल आकार इससे बड़ा",
|
||||
"Minimum file size (KB)": "न्यूनतम फ़ाइल आकार (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "फ़ोल्डर संरचना",
|
||||
"Create groups from subfolders": "सब-फ़ोल्डर से समूह बनाएँ",
|
||||
"Each first-level subfolder becomes a library group.": "प्रत्येक प्रथम-स्तरीय सब-फ़ोल्डर एक लाइब्रेरी समूह बन जाता है।",
|
||||
"Import all into library": "सभी को लाइब्रेरी में आयात करें",
|
||||
"Recursively add every matching file directly to the library.": "हर मेल खाने वाली फ़ाइल को सीधे लाइब्रेरी में पुनरावर्ती रूप से जोड़ें।",
|
||||
"OK": "ठीक है",
|
||||
"No matching books found in the selected folder.": "चयनित फ़ोल्डर में कोई मेल खाती पुस्तक नहीं मिली।"
|
||||
}
|
||||
|
||||
@@ -1496,5 +1496,18 @@
|
||||
"System": "Rendszer",
|
||||
"System dictionary integration is coming soon on this platform.": "A rendszerszótár integrációja hamarosan elérhető lesz ezen a platformon.",
|
||||
"Waiting to be processed": "Feldolgozásra vár",
|
||||
"Processing…": "Feldolgozás…"
|
||||
"Processing…": "Feldolgozás…",
|
||||
"Folder": "Mappa",
|
||||
"Choose a folder": "Mappa kiválasztása",
|
||||
"File Formats": "Fájlformátumok",
|
||||
"File size larger than": "Fájlméret nagyobb mint",
|
||||
"Minimum file size (KB)": "Minimális fájlméret (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Mappastruktúra",
|
||||
"Create groups from subfolders": "Csoportok létrehozása almappákból",
|
||||
"Each first-level subfolder becomes a library group.": "Minden első szintű almappa könyvtárcsoporttá válik.",
|
||||
"Import all into library": "Mindent importálni a könyvtárba",
|
||||
"Recursively add every matching file directly to the library.": "Minden megfelelő fájl rekurzív hozzáadása közvetlenül a könyvtárhoz.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "Nem található megfelelő könyv a kiválasztott mappában."
|
||||
}
|
||||
|
||||
@@ -1468,5 +1468,18 @@
|
||||
"System": "Sistem",
|
||||
"System dictionary integration is coming soon on this platform.": "Integrasi kamus sistem akan segera hadir di platform ini.",
|
||||
"Waiting to be processed": "Menunggu untuk diproses",
|
||||
"Processing…": "Memproses…"
|
||||
"Processing…": "Memproses…",
|
||||
"Folder": "Folder",
|
||||
"Choose a folder": "Pilih folder",
|
||||
"File Formats": "Format Berkas",
|
||||
"File size larger than": "Ukuran berkas lebih besar dari",
|
||||
"Minimum file size (KB)": "Ukuran berkas minimum (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Struktur Folder",
|
||||
"Create groups from subfolders": "Buat grup dari subfolder",
|
||||
"Each first-level subfolder becomes a library group.": "Setiap subfolder tingkat pertama menjadi grup pustaka.",
|
||||
"Import all into library": "Impor semua ke pustaka",
|
||||
"Recursively add every matching file directly to the library.": "Tambahkan secara rekursif setiap berkas yang cocok langsung ke pustaka.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "Tidak ada buku yang cocok ditemukan di folder yang dipilih."
|
||||
}
|
||||
|
||||
@@ -1524,5 +1524,18 @@
|
||||
"System": "Sistema",
|
||||
"System dictionary integration is coming soon on this platform.": "L’integrazione del dizionario di sistema arriverà presto su questa piattaforma.",
|
||||
"Waiting to be processed": "In attesa di elaborazione",
|
||||
"Processing…": "Elaborazione…"
|
||||
"Processing…": "Elaborazione…",
|
||||
"Folder": "Cartella",
|
||||
"Choose a folder": "Scegli una cartella",
|
||||
"File Formats": "Formati di file",
|
||||
"File size larger than": "Dimensione del file maggiore di",
|
||||
"Minimum file size (KB)": "Dimensione minima del file (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Struttura delle cartelle",
|
||||
"Create groups from subfolders": "Crea gruppi dalle sottocartelle",
|
||||
"Each first-level subfolder becomes a library group.": "Ogni sottocartella di primo livello diventa un gruppo della libreria.",
|
||||
"Import all into library": "Importa tutto nella libreria",
|
||||
"Recursively add every matching file directly to the library.": "Aggiungi ricorsivamente ogni file corrispondente direttamente alla libreria.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "Nessun libro corrispondente trovato nella cartella selezionata."
|
||||
}
|
||||
|
||||
@@ -1468,5 +1468,18 @@
|
||||
"System": "システム",
|
||||
"System dictionary integration is coming soon on this platform.": "システム辞書の統合は、このプラットフォームでまもなく利用可能になります。",
|
||||
"Waiting to be processed": "処理待ち",
|
||||
"Processing…": "処理中…"
|
||||
"Processing…": "処理中…",
|
||||
"Folder": "フォルダ",
|
||||
"Choose a folder": "フォルダを選択",
|
||||
"File Formats": "ファイル形式",
|
||||
"File size larger than": "ファイルサイズが次より大きい",
|
||||
"Minimum file size (KB)": "最小ファイルサイズ (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "フォルダ構造",
|
||||
"Create groups from subfolders": "サブフォルダからグループを作成",
|
||||
"Each first-level subfolder becomes a library group.": "第一階層の各サブフォルダがライブラリグループになります。",
|
||||
"Import all into library": "すべてライブラリに取り込む",
|
||||
"Recursively add every matching file directly to the library.": "一致するすべてのファイルを再帰的にライブラリに直接追加します。",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "選択したフォルダ内に一致する書籍が見つかりませんでした。"
|
||||
}
|
||||
|
||||
@@ -1468,5 +1468,18 @@
|
||||
"System": "시스템",
|
||||
"System dictionary integration is coming soon on this platform.": "이 플랫폼에서 시스템 사전 통합이 곧 제공됩니다.",
|
||||
"Waiting to be processed": "처리 대기 중",
|
||||
"Processing…": "처리 중…"
|
||||
"Processing…": "처리 중…",
|
||||
"Folder": "폴더",
|
||||
"Choose a folder": "폴더 선택",
|
||||
"File Formats": "파일 형식",
|
||||
"File size larger than": "파일 크기가 다음보다 큰 경우",
|
||||
"Minimum file size (KB)": "최소 파일 크기 (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "폴더 구조",
|
||||
"Create groups from subfolders": "하위 폴더로 그룹 만들기",
|
||||
"Each first-level subfolder becomes a library group.": "첫 번째 수준의 각 하위 폴더가 라이브러리 그룹이 됩니다.",
|
||||
"Import all into library": "모두 라이브러리로 가져오기",
|
||||
"Recursively add every matching file directly to the library.": "일치하는 모든 파일을 재귀적으로 라이브러리에 직접 추가합니다.",
|
||||
"OK": "확인",
|
||||
"No matching books found in the selected folder.": "선택한 폴더에서 일치하는 책을 찾을 수 없습니다."
|
||||
}
|
||||
|
||||
@@ -1468,5 +1468,18 @@
|
||||
"System": "Sistem",
|
||||
"System dictionary integration is coming soon on this platform.": "Penyepaduan kamus sistem akan tiba tidak lama lagi di platform ini.",
|
||||
"Waiting to be processed": "Menunggu untuk diproses",
|
||||
"Processing…": "Memproses…"
|
||||
"Processing…": "Memproses…",
|
||||
"Folder": "Folder",
|
||||
"Choose a folder": "Pilih folder",
|
||||
"File Formats": "Format Fail",
|
||||
"File size larger than": "Saiz fail lebih besar daripada",
|
||||
"Minimum file size (KB)": "Saiz fail minimum (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Struktur Folder",
|
||||
"Create groups from subfolders": "Cipta kumpulan daripada subfolder",
|
||||
"Each first-level subfolder becomes a library group.": "Setiap subfolder peringkat pertama menjadi kumpulan pustaka.",
|
||||
"Import all into library": "Import semua ke pustaka",
|
||||
"Recursively add every matching file directly to the library.": "Tambah secara rekursif setiap fail yang sepadan terus ke pustaka.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "Tiada buku sepadan dijumpai dalam folder yang dipilih."
|
||||
}
|
||||
|
||||
@@ -1496,5 +1496,18 @@
|
||||
"System": "Systeem",
|
||||
"System dictionary integration is coming soon on this platform.": "Integratie van het systeemwoordenboek komt binnenkort beschikbaar op dit platform.",
|
||||
"Waiting to be processed": "Wacht op verwerking",
|
||||
"Processing…": "Bezig met verwerken…"
|
||||
"Processing…": "Bezig met verwerken…",
|
||||
"Folder": "Map",
|
||||
"Choose a folder": "Kies een map",
|
||||
"File Formats": "Bestandsformaten",
|
||||
"File size larger than": "Bestandsgrootte groter dan",
|
||||
"Minimum file size (KB)": "Minimale bestandsgrootte (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Mapstructuur",
|
||||
"Create groups from subfolders": "Groepen maken van submappen",
|
||||
"Each first-level subfolder becomes a library group.": "Elke submap op het eerste niveau wordt een bibliotheekgroep.",
|
||||
"Import all into library": "Alles in bibliotheek importeren",
|
||||
"Recursively add every matching file directly to the library.": "Voeg recursief elk overeenkomend bestand rechtstreeks toe aan de bibliotheek.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "Geen overeenkomende boeken gevonden in de geselecteerde map."
|
||||
}
|
||||
|
||||
@@ -1552,5 +1552,18 @@
|
||||
"System": "System",
|
||||
"System dictionary integration is coming soon on this platform.": "Integracja słownika systemowego wkrótce pojawi się na tej platformie.",
|
||||
"Waiting to be processed": "Oczekuje na przetworzenie",
|
||||
"Processing…": "Przetwarzanie…"
|
||||
"Processing…": "Przetwarzanie…",
|
||||
"Folder": "Folder",
|
||||
"Choose a folder": "Wybierz folder",
|
||||
"File Formats": "Formaty plików",
|
||||
"File size larger than": "Rozmiar pliku większy niż",
|
||||
"Minimum file size (KB)": "Minimalny rozmiar pliku (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Struktura folderów",
|
||||
"Create groups from subfolders": "Twórz grupy z podfolderów",
|
||||
"Each first-level subfolder becomes a library group.": "Każdy podfolder pierwszego poziomu staje się grupą biblioteki.",
|
||||
"Import all into library": "Importuj wszystko do biblioteki",
|
||||
"Recursively add every matching file directly to the library.": "Dodaj rekurencyjnie każdy pasujący plik bezpośrednio do biblioteki.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "Nie znaleziono pasujących książek w wybranym folderze."
|
||||
}
|
||||
|
||||
@@ -1524,5 +1524,18 @@
|
||||
"System": "Sistema",
|
||||
"System dictionary integration is coming soon on this platform.": "A integração com o dicionário do sistema chegará em breve nesta plataforma.",
|
||||
"Waiting to be processed": "Aguardando processamento",
|
||||
"Processing…": "Processando…"
|
||||
"Processing…": "Processando…",
|
||||
"Folder": "Pasta",
|
||||
"Choose a folder": "Escolher uma pasta",
|
||||
"File Formats": "Formatos de arquivo",
|
||||
"File size larger than": "Tamanho do arquivo maior que",
|
||||
"Minimum file size (KB)": "Tamanho mínimo do arquivo (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Estrutura de pastas",
|
||||
"Create groups from subfolders": "Criar grupos a partir de subpastas",
|
||||
"Each first-level subfolder becomes a library group.": "Cada subpasta de primeiro nível se torna um grupo da biblioteca.",
|
||||
"Import all into library": "Importar tudo para a biblioteca",
|
||||
"Recursively add every matching file directly to the library.": "Adicionar recursivamente todo arquivo correspondente diretamente à biblioteca.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "Nenhum livro correspondente encontrado na pasta selecionada."
|
||||
}
|
||||
|
||||
@@ -1524,5 +1524,18 @@
|
||||
"System": "Sistema",
|
||||
"System dictionary integration is coming soon on this platform.": "A integração com o dicionário do sistema chegará em breve nesta plataforma.",
|
||||
"Waiting to be processed": "Aguardando processamento",
|
||||
"Processing…": "A processar…"
|
||||
"Processing…": "A processar…",
|
||||
"Folder": "Pasta",
|
||||
"Choose a folder": "Escolher uma pasta",
|
||||
"File Formats": "Formatos de ficheiro",
|
||||
"File size larger than": "Tamanho do ficheiro maior que",
|
||||
"Minimum file size (KB)": "Tamanho mínimo do ficheiro (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Estrutura de pastas",
|
||||
"Create groups from subfolders": "Criar grupos a partir de subpastas",
|
||||
"Each first-level subfolder becomes a library group.": "Cada subpasta de primeiro nível torna-se um grupo da biblioteca.",
|
||||
"Import all into library": "Importar tudo para a biblioteca",
|
||||
"Recursively add every matching file directly to the library.": "Adicionar recursivamente cada ficheiro correspondente diretamente à biblioteca.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "Não foram encontrados livros correspondentes na pasta selecionada."
|
||||
}
|
||||
|
||||
@@ -1524,5 +1524,18 @@
|
||||
"System": "Sistem",
|
||||
"System dictionary integration is coming soon on this platform.": "Integrarea cu dicționarul sistemului va fi disponibilă în curând pe această platformă.",
|
||||
"Waiting to be processed": "În așteptarea procesării",
|
||||
"Processing…": "Se procesează…"
|
||||
"Processing…": "Se procesează…",
|
||||
"Folder": "Folder",
|
||||
"Choose a folder": "Alege un folder",
|
||||
"File Formats": "Formate de fișier",
|
||||
"File size larger than": "Dimensiunea fișierului mai mare decât",
|
||||
"Minimum file size (KB)": "Dimensiunea minimă a fișierului (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Structura folderului",
|
||||
"Create groups from subfolders": "Creează grupuri din subfoldere",
|
||||
"Each first-level subfolder becomes a library group.": "Fiecare subfolder de prim nivel devine un grup în bibliotecă.",
|
||||
"Import all into library": "Importă tot în bibliotecă",
|
||||
"Recursively add every matching file directly to the library.": "Adaugă recursiv fiecare fișier potrivit direct în bibliotecă.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "Nu s-au găsit cărți potrivite în folderul selectat."
|
||||
}
|
||||
|
||||
@@ -1552,5 +1552,18 @@
|
||||
"System": "Система",
|
||||
"System dictionary integration is coming soon on this platform.": "Интеграция системного словаря скоро появится на этой платформе.",
|
||||
"Waiting to be processed": "Ожидает обработки",
|
||||
"Processing…": "Обработка…"
|
||||
"Processing…": "Обработка…",
|
||||
"Folder": "Папка",
|
||||
"Choose a folder": "Выберите папку",
|
||||
"File Formats": "Форматы файлов",
|
||||
"File size larger than": "Размер файла больше чем",
|
||||
"Minimum file size (KB)": "Минимальный размер файла (КБ)",
|
||||
"KB": "КБ",
|
||||
"Folder Structure": "Структура папок",
|
||||
"Create groups from subfolders": "Создать группы из подпапок",
|
||||
"Each first-level subfolder becomes a library group.": "Каждая подпапка первого уровня становится группой библиотеки.",
|
||||
"Import all into library": "Импортировать всё в библиотеку",
|
||||
"Recursively add every matching file directly to the library.": "Рекурсивно добавить каждый подходящий файл напрямую в библиотеку.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "В выбранной папке не найдено подходящих книг."
|
||||
}
|
||||
|
||||
@@ -1496,5 +1496,18 @@
|
||||
"System": "පද්ධතිය",
|
||||
"System dictionary integration is coming soon on this platform.": "මෙම වේදිකාවේ පද්ධති ශබ්දකෝෂ ඒකාබද්ධතාව ඉක්මනින් පැමිණේ.",
|
||||
"Waiting to be processed": "සැකසීමට බලාපොරොත්තු වෙමින්",
|
||||
"Processing…": "සැකසෙමින්…"
|
||||
"Processing…": "සැකසෙමින්…",
|
||||
"Folder": "ෆෝල්ඩරය",
|
||||
"Choose a folder": "ෆෝල්ඩරයක් තෝරන්න",
|
||||
"File Formats": "ගොනු ආකෘති",
|
||||
"File size larger than": "ගොනු ප්රමාණය ඊට වඩා විශාල",
|
||||
"Minimum file size (KB)": "අවම ගොනු ප්රමාණය (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "ෆෝල්ඩර ව්යුහය",
|
||||
"Create groups from subfolders": "උපෆෝල්ඩරවලින් කණ්ඩායම් සාදන්න",
|
||||
"Each first-level subfolder becomes a library group.": "පළමු මට්ටමේ එක් එක් උපෆෝල්ඩරය පුස්තකාල කණ්ඩායමක් වේ.",
|
||||
"Import all into library": "සියල්ල පුස්තකාලයට ආයාත කරන්න",
|
||||
"Recursively add every matching file directly to the library.": "ගැලපෙන සෑම ගොනුවක්ම පුස්තකාලයට කෙළින්ම පුනරාවර්තනීයව එක් කරන්න.",
|
||||
"OK": "හරි",
|
||||
"No matching books found in the selected folder.": "තෝරාගත් ෆෝල්ඩරයේ ගැලපෙන පොත් කිසිවක් හමු නොවීය."
|
||||
}
|
||||
|
||||
@@ -1552,5 +1552,18 @@
|
||||
"System": "Sistem",
|
||||
"System dictionary integration is coming soon on this platform.": "Integracija sistemskega slovarja bo kmalu na voljo na tej platformi.",
|
||||
"Waiting to be processed": "Čaka na obdelavo",
|
||||
"Processing…": "Obdelovanje…"
|
||||
"Processing…": "Obdelovanje…",
|
||||
"Folder": "Mapa",
|
||||
"Choose a folder": "Izberite mapo",
|
||||
"File Formats": "Oblike datotek",
|
||||
"File size larger than": "Velikost datoteke večja od",
|
||||
"Minimum file size (KB)": "Najmanjša velikost datoteke (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Struktura map",
|
||||
"Create groups from subfolders": "Ustvari skupine iz podmap",
|
||||
"Each first-level subfolder becomes a library group.": "Vsaka podmapa prve ravni postane skupina v knjižnici.",
|
||||
"Import all into library": "Uvozi vse v knjižnico",
|
||||
"Recursively add every matching file directly to the library.": "Rekurzivno dodaj vsako ujemajočo datoteko neposredno v knjižnico.",
|
||||
"OK": "V redu",
|
||||
"No matching books found in the selected folder.": "V izbrani mapi ni najdenih ujemajočih se knjig."
|
||||
}
|
||||
|
||||
@@ -1496,5 +1496,18 @@
|
||||
"System": "System",
|
||||
"System dictionary integration is coming soon on this platform.": "Integration med systemets ordbok kommer snart till denna plattform.",
|
||||
"Waiting to be processed": "Väntar på bearbetning",
|
||||
"Processing…": "Bearbetar…"
|
||||
"Processing…": "Bearbetar…",
|
||||
"Folder": "Mapp",
|
||||
"Choose a folder": "Välj en mapp",
|
||||
"File Formats": "Filformat",
|
||||
"File size larger than": "Filstorlek större än",
|
||||
"Minimum file size (KB)": "Minsta filstorlek (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Mappstruktur",
|
||||
"Create groups from subfolders": "Skapa grupper från undermappar",
|
||||
"Each first-level subfolder becomes a library group.": "Varje undermapp på första nivån blir en biblioteksgrupp.",
|
||||
"Import all into library": "Importera allt till biblioteket",
|
||||
"Recursively add every matching file directly to the library.": "Lägg rekursivt till varje matchande fil direkt i biblioteket.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "Inga matchande böcker hittades i den valda mappen."
|
||||
}
|
||||
|
||||
@@ -1496,5 +1496,18 @@
|
||||
"System": "கணினி",
|
||||
"System dictionary integration is coming soon on this platform.": "இந்த தளத்தில் கணினி அகராதி ஒருங்கிணைப்பு விரைவில் வரும்.",
|
||||
"Waiting to be processed": "செயலாக்கத்திற்காகக் காத்திருக்கிறது",
|
||||
"Processing…": "செயலாக்கப்படுகிறது…"
|
||||
"Processing…": "செயலாக்கப்படுகிறது…",
|
||||
"Folder": "கோப்புறை",
|
||||
"Choose a folder": "ஒரு கோப்புறையை தேர்வுசெய்க",
|
||||
"File Formats": "கோப்பு வடிவங்கள்",
|
||||
"File size larger than": "கோப்பு அளவு இதைவிட பெரியது",
|
||||
"Minimum file size (KB)": "குறைந்தபட்ச கோப்பு அளவு (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "கோப்புறை அமைப்பு",
|
||||
"Create groups from subfolders": "துணை கோப்புறைகளில் இருந்து குழுக்களை உருவாக்கு",
|
||||
"Each first-level subfolder becomes a library group.": "முதல் நிலை ஒவ்வொரு துணை கோப்புறையும் ஒரு நூலக குழுவாக மாறும்.",
|
||||
"Import all into library": "அனைத்தையும் நூலகத்திற்கு இறக்குமதி செய்",
|
||||
"Recursively add every matching file directly to the library.": "பொருந்தும் ஒவ்வொரு கோப்பையும் நேரடியாக நூலகத்திற்கு மீள்கூற்று முறையில் சேர்க்கவும்.",
|
||||
"OK": "சரி",
|
||||
"No matching books found in the selected folder.": "தேர்ந்தெடுக்கப்பட்ட கோப்புறையில் பொருந்தும் புத்தகங்கள் எதுவும் கிடைக்கவில்லை."
|
||||
}
|
||||
|
||||
@@ -1468,5 +1468,18 @@
|
||||
"System": "ระบบ",
|
||||
"System dictionary integration is coming soon on this platform.": "การผสานพจนานุกรมของระบบจะมาถึงในไม่ช้าบนแพลตฟอร์มนี้",
|
||||
"Waiting to be processed": "รอการประมวลผล",
|
||||
"Processing…": "กำลังประมวลผล…"
|
||||
"Processing…": "กำลังประมวลผล…",
|
||||
"Folder": "โฟลเดอร์",
|
||||
"Choose a folder": "เลือกโฟลเดอร์",
|
||||
"File Formats": "รูปแบบไฟล์",
|
||||
"File size larger than": "ขนาดไฟล์ใหญ่กว่า",
|
||||
"Minimum file size (KB)": "ขนาดไฟล์ขั้นต่ำ (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "โครงสร้างโฟลเดอร์",
|
||||
"Create groups from subfolders": "สร้างกลุ่มจากโฟลเดอร์ย่อย",
|
||||
"Each first-level subfolder becomes a library group.": "โฟลเดอร์ย่อยระดับแรกแต่ละโฟลเดอร์จะกลายเป็นกลุ่มในห้องสมุด",
|
||||
"Import all into library": "นำเข้าทั้งหมดไปยังห้องสมุด",
|
||||
"Recursively add every matching file directly to the library.": "เพิ่มทุกไฟล์ที่ตรงกันโดยตรงไปยังห้องสมุดแบบเรียกซ้ำ",
|
||||
"OK": "ตกลง",
|
||||
"No matching books found in the selected folder.": "ไม่พบหนังสือที่ตรงกันในโฟลเดอร์ที่เลือก"
|
||||
}
|
||||
|
||||
@@ -1496,5 +1496,18 @@
|
||||
"System": "Sistem",
|
||||
"System dictionary integration is coming soon on this platform.": "Sistem sözlüğü entegrasyonu yakında bu platformda kullanıma sunulacak.",
|
||||
"Waiting to be processed": "İşlenmeyi bekliyor",
|
||||
"Processing…": "İşleniyor…"
|
||||
"Processing…": "İşleniyor…",
|
||||
"Folder": "Klasör",
|
||||
"Choose a folder": "Bir klasör seçin",
|
||||
"File Formats": "Dosya Biçimleri",
|
||||
"File size larger than": "Dosya boyutu şundan büyük",
|
||||
"Minimum file size (KB)": "Minimum dosya boyutu (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Klasör Yapısı",
|
||||
"Create groups from subfolders": "Alt klasörlerden gruplar oluştur",
|
||||
"Each first-level subfolder becomes a library group.": "Her birinci düzey alt klasör bir kitaplık grubu olur.",
|
||||
"Import all into library": "Tümünü kitaplığa aktar",
|
||||
"Recursively add every matching file directly to the library.": "Eşleşen her dosyayı doğrudan kitaplığa özyinelemeli olarak ekle.",
|
||||
"OK": "Tamam",
|
||||
"No matching books found in the selected folder.": "Seçilen klasörde eşleşen kitap bulunamadı."
|
||||
}
|
||||
|
||||
@@ -1552,5 +1552,18 @@
|
||||
"System": "Система",
|
||||
"System dictionary integration is coming soon on this platform.": "Інтеграція системного словника незабаром з’явиться на цій платформі.",
|
||||
"Waiting to be processed": "Очікує на обробку",
|
||||
"Processing…": "Обробка…"
|
||||
"Processing…": "Обробка…",
|
||||
"Folder": "Папка",
|
||||
"Choose a folder": "Виберіть папку",
|
||||
"File Formats": "Формати файлів",
|
||||
"File size larger than": "Розмір файлу більше ніж",
|
||||
"Minimum file size (KB)": "Мінімальний розмір файлу (КБ)",
|
||||
"KB": "КБ",
|
||||
"Folder Structure": "Структура папок",
|
||||
"Create groups from subfolders": "Створити групи з підпапок",
|
||||
"Each first-level subfolder becomes a library group.": "Кожна підпапка першого рівня стає групою бібліотеки.",
|
||||
"Import all into library": "Імпортувати все до бібліотеки",
|
||||
"Recursively add every matching file directly to the library.": "Рекурсивно додати кожен відповідний файл безпосередньо до бібліотеки.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "Не знайдено відповідних книг у вибраній папці."
|
||||
}
|
||||
|
||||
@@ -1496,5 +1496,18 @@
|
||||
"System": "Tizim",
|
||||
"System dictionary integration is coming soon on this platform.": "Tizim lug‘ati integratsiyasi tez orada ushbu platformada paydo bo‘ladi.",
|
||||
"Waiting to be processed": "Qayta ishlanishi kutilmoqda",
|
||||
"Processing…": "Qayta ishlanmoqda…"
|
||||
"Processing…": "Qayta ishlanmoqda…",
|
||||
"Folder": "Papka",
|
||||
"Choose a folder": "Papkani tanlang",
|
||||
"File Formats": "Fayl formatlari",
|
||||
"File size larger than": "Fayl hajmi shundan katta",
|
||||
"Minimum file size (KB)": "Minimal fayl hajmi (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Papka tuzilishi",
|
||||
"Create groups from subfolders": "Quyi papkalardan guruhlar yarating",
|
||||
"Each first-level subfolder becomes a library group.": "Birinchi darajadagi har bir quyi papka kutubxona guruhiga aylanadi.",
|
||||
"Import all into library": "Hammasini kutubxonaga import qiling",
|
||||
"Recursively add every matching file directly to the library.": "Har bir mos keladigan faylni to'g'ridan-to'g'ri kutubxonaga rekursiv qo'shing.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "Tanlangan papkada mos keladigan kitoblar topilmadi."
|
||||
}
|
||||
|
||||
@@ -1468,5 +1468,18 @@
|
||||
"System": "Hệ thống",
|
||||
"System dictionary integration is coming soon on this platform.": "Tích hợp từ điển hệ thống sẽ sớm có mặt trên nền tảng này.",
|
||||
"Waiting to be processed": "Đang chờ xử lý",
|
||||
"Processing…": "Đang xử lý…"
|
||||
"Processing…": "Đang xử lý…",
|
||||
"Folder": "Thư mục",
|
||||
"Choose a folder": "Chọn thư mục",
|
||||
"File Formats": "Định dạng tệp",
|
||||
"File size larger than": "Kích thước tệp lớn hơn",
|
||||
"Minimum file size (KB)": "Kích thước tệp tối thiểu (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "Cấu trúc thư mục",
|
||||
"Create groups from subfolders": "Tạo nhóm từ thư mục con",
|
||||
"Each first-level subfolder becomes a library group.": "Mỗi thư mục con cấp đầu tiên trở thành một nhóm thư viện.",
|
||||
"Import all into library": "Nhập tất cả vào thư viện",
|
||||
"Recursively add every matching file directly to the library.": "Đệ quy thêm mọi tệp khớp trực tiếp vào thư viện.",
|
||||
"OK": "OK",
|
||||
"No matching books found in the selected folder.": "Không tìm thấy sách khớp trong thư mục đã chọn."
|
||||
}
|
||||
|
||||
@@ -1468,5 +1468,18 @@
|
||||
"System": "系统",
|
||||
"System dictionary integration is coming soon on this platform.": "即将在此平台支持系统词典集成。",
|
||||
"Waiting to be processed": "等待处理",
|
||||
"Processing…": "处理中…"
|
||||
"Processing…": "处理中…",
|
||||
"Folder": "文件夹",
|
||||
"Choose a folder": "选择一个文件夹",
|
||||
"File Formats": "文件格式",
|
||||
"File size larger than": "文件大小大于",
|
||||
"Minimum file size (KB)": "最小文件大小 (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "文件夹结构",
|
||||
"Create groups from subfolders": "从子文件夹创建分组",
|
||||
"Each first-level subfolder becomes a library group.": "每个一级子文件夹将成为一个书库分组。",
|
||||
"Import all into library": "全部导入到书库",
|
||||
"Recursively add every matching file directly to the library.": "递归地将所有匹配的文件直接添加到书库。",
|
||||
"OK": "确定",
|
||||
"No matching books found in the selected folder.": "在所选文件夹中未找到匹配的书籍。"
|
||||
}
|
||||
|
||||
@@ -1468,5 +1468,18 @@
|
||||
"System": "系統",
|
||||
"System dictionary integration is coming soon on this platform.": "即將在此平臺支援系統字典整合。",
|
||||
"Waiting to be processed": "等待處理",
|
||||
"Processing…": "處理中…"
|
||||
"Processing…": "處理中…",
|
||||
"Folder": "資料夾",
|
||||
"Choose a folder": "選擇一個資料夾",
|
||||
"File Formats": "檔案格式",
|
||||
"File size larger than": "檔案大小大於",
|
||||
"Minimum file size (KB)": "最小檔案大小 (KB)",
|
||||
"KB": "KB",
|
||||
"Folder Structure": "資料夾結構",
|
||||
"Create groups from subfolders": "從子資料夾建立群組",
|
||||
"Each first-level subfolder becomes a library group.": "每個第一層子資料夾將成為一個書庫群組。",
|
||||
"Import all into library": "全部匯入書庫",
|
||||
"Recursively add every matching file directly to the library.": "遞迴地將所有相符的檔案直接新增到書庫。",
|
||||
"OK": "確定",
|
||||
"No matching books found in the selected folder.": "在所選資料夾中找不到相符的書籍。"
|
||||
}
|
||||
|
||||
@@ -73,6 +73,57 @@ fn allow_dir_in_scopes(app: &AppHandle, dir: &PathBuf) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frontend-callable shim around [`allow_file_in_scopes`] /
|
||||
/// [`allow_dir_in_scopes`]. Used after dialog-based file/folder pickers
|
||||
/// because the Tauri `dialog` plugin only auto-grants `fs_scope`, not
|
||||
/// `asset_protocol_scope` — and our importer relies on the asset
|
||||
/// protocol (`RemoteFile`) to read user-selected files. Without this,
|
||||
/// importing a book from e.g. `~/Downloads/...` fails with
|
||||
/// "asset protocol not configured to allow the path".
|
||||
///
|
||||
/// Granted scopes are persisted across app restarts thanks to
|
||||
/// `tauri_plugin_persisted_scope`, so re-picking the same file isn't
|
||||
/// required after the first allow call.
|
||||
///
|
||||
/// Security: this command refuses to extend `asset_protocol_scope` for
|
||||
/// any path that is not already allowed in `fs_scope`. The `fs_scope`
|
||||
/// is populated only by the Tauri `dialog` plugin (when the user picks
|
||||
/// through the OS picker) or by `tauri_plugin_persisted_scope` (which
|
||||
/// restores prior dialog grants on startup). That gate constrains the
|
||||
/// command to user-selected paths only — otherwise any frontend code
|
||||
/// (including a future XSS via book content, OPDS HTML, dictionary
|
||||
/// lookups, or a compromised dependency) could invoke it with an
|
||||
/// arbitrary path like `/` or `~/.ssh` and gain persistent read access
|
||||
/// to the entire user home directory via the asset protocol.
|
||||
#[command]
|
||||
fn allow_paths_in_scopes(_app: AppHandle, _paths: Vec<String>, _is_directory: bool) {
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let fs_scope = _app.fs_scope();
|
||||
for raw in _paths {
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let path = PathBuf::from(&raw);
|
||||
if !fs_scope.is_allowed(&path) {
|
||||
log::warn!("allow_paths_in_scopes refused (path not in fs_scope): {path:?}");
|
||||
continue;
|
||||
}
|
||||
if _is_directory {
|
||||
allow_dir_in_scopes(&_app, &path);
|
||||
} else {
|
||||
allow_file_in_scopes(&_app, vec![path]);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
// Android picker already routes through register_select_directory_callback
|
||||
// for directories; files go through SAF / content-URIs and don't use
|
||||
// asset_protocol_scope. Nothing to do here.
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn get_files_from_argv(argv: Vec<String>) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
@@ -168,6 +219,7 @@ pub fn run() {
|
||||
upload_file,
|
||||
get_environment_variable,
|
||||
get_executable_dir,
|
||||
allow_paths_in_scopes,
|
||||
dir_scanner::read_dir,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos::safari_auth::auth_with_safari,
|
||||
|
||||
@@ -77,6 +77,38 @@ describe('ingestFile', () => {
|
||||
expect(book?.groupName).toBe('Sci-Fi');
|
||||
});
|
||||
|
||||
test('clears the group when groupId is the empty string (flatten-into-root)', async () => {
|
||||
// A previously-imported book may already carry a groupId/Name from
|
||||
// a prior import. Re-importing with an explicit empty groupId
|
||||
// should demote it back to the library root rather than silently
|
||||
// keeping the stale group — that behaviour matters for the
|
||||
// Import-from-Folder dialog's "flatten" mode.
|
||||
const { appService, settings, isLoggedIn } = makeDeps({
|
||||
importResult: makeBook({ groupId: 'old', groupName: 'Old/Folder' }),
|
||||
});
|
||||
const book = await ingestFile(
|
||||
{ file: 'book.epub', books: [], groupId: '', groupName: undefined },
|
||||
{ appService, settings, isLoggedIn },
|
||||
);
|
||||
expect(book?.groupId).toBe('');
|
||||
expect(book?.groupName).toBeUndefined();
|
||||
});
|
||||
|
||||
test('leaves the group untouched when groupId is omitted', async () => {
|
||||
// Sanity check for the tri-state contract: undefined groupId means
|
||||
// "don't touch the existing group" (used by the inbox drainer and
|
||||
// /send page where the user hasn't picked a destination).
|
||||
const { appService, settings, isLoggedIn } = makeDeps({
|
||||
importResult: makeBook({ groupId: 'keep', groupName: 'Keep/Me' }),
|
||||
});
|
||||
const book = await ingestFile(
|
||||
{ file: 'book.epub', books: [] },
|
||||
{ appService, settings, isLoggedIn },
|
||||
);
|
||||
expect(book?.groupId).toBe('keep');
|
||||
expect(book?.groupName).toBe('Keep/Me');
|
||||
});
|
||||
|
||||
test('applies a subject tag and bumps updatedAt', async () => {
|
||||
const { appService, settings, isLoggedIn } = makeDeps();
|
||||
const book = await ingestFile(
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
import { MdFolderOpen } from 'react-icons/md';
|
||||
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
/**
|
||||
* Per-extension grouping presented to the user. Each card is a single
|
||||
* checkbox in the dialog whose underlying value is one or more entries
|
||||
* from {@link import('@/services/constants').SUPPORTED_BOOK_EXTS}. The
|
||||
* label is what the user sees; `exts` is what the importer filters on.
|
||||
*
|
||||
* Keep order roughly aligned with the design mockup so the muscle memory
|
||||
* of users coming from the prior screenshot still works (EPUB and PDF
|
||||
* first, common eBook formats in the middle, archives at the end).
|
||||
*/
|
||||
export interface FormatGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
/** lower-case extensions without the leading dot. */
|
||||
exts: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_FORMAT_GROUPS: FormatGroup[] = [
|
||||
{ id: 'epub', label: 'EPUB', exts: ['epub'] },
|
||||
{ id: 'pdf', label: 'PDF', exts: ['pdf'] },
|
||||
{ id: 'mobi', label: 'MOBI/AZW/AZW3', exts: ['mobi', 'azw', 'azw3'] },
|
||||
{ id: 'fb2', label: 'FB2', exts: ['fb2'] },
|
||||
{ id: 'cbz', label: 'CBZ/ZIP', exts: ['cbz', 'zip'] },
|
||||
{ id: 'txt', label: 'TXT', exts: ['txt'] },
|
||||
];
|
||||
|
||||
export interface ImportFromFolderResult {
|
||||
directory: string;
|
||||
/** Lower-case file extensions (without the leading dot) to include. */
|
||||
extensions: string[];
|
||||
/**
|
||||
* IDs of the {@link FormatGroup}s the user ticked. Forwarded so the
|
||||
* caller can persist the user's selection at the group level (rather
|
||||
* than having to reverse-engineer it from {@link extensions}).
|
||||
*/
|
||||
selectedGroupIds: string[];
|
||||
/**
|
||||
* Minimum file size in KB — files strictly smaller than `minSizeKB *
|
||||
* 1024` bytes are skipped by the importer. `0` keeps everything.
|
||||
* Stored in KB (not bytes) because that matches the dialog's input
|
||||
* and the persistence format; callers that need bytes should
|
||||
* multiply by 1024 themselves.
|
||||
*/
|
||||
minSizeKB: number;
|
||||
/**
|
||||
* When `false` (default), each first-level subfolder under
|
||||
* {@link directory} becomes its own library group, mirroring the
|
||||
* folder structure. When `true`, every matching file is dropped
|
||||
* directly into the library root without creating any groups.
|
||||
*/
|
||||
flatten: boolean;
|
||||
}
|
||||
|
||||
interface ImportFromFolderDialogProps {
|
||||
/** Initial directory shown in the path field; the user can change it. */
|
||||
initialDirectory: string;
|
||||
/**
|
||||
* Initial value for the folder-structure radios. Persisted by the
|
||||
* caller across dialog opens so users don't have to re-pick the same
|
||||
* mode every time. Defaults to `'keep'` when omitted.
|
||||
*/
|
||||
initialFolderMode?: 'keep' | 'flatten';
|
||||
/**
|
||||
* Initial set of {@link FormatGroup.id}s to mark as checked. Persisted
|
||||
* by the caller. Falls back to a sensible "EPUB + PDF" default when
|
||||
* omitted (or when the persisted set is empty, since no boxes ticked
|
||||
* would block the OK button immediately on dialog open).
|
||||
*/
|
||||
initialSelectedGroupIds?: string[];
|
||||
/**
|
||||
* Initial value for the "File size larger than" input, in KB.
|
||||
* Defaults to 20 KB when omitted.
|
||||
*/
|
||||
initialMinSizeKB?: number;
|
||||
/**
|
||||
* Pop the platform's native folder picker and return the chosen path,
|
||||
* or `undefined` when the user cancels. Required because folder
|
||||
* pickers vary between desktop (Tauri dialog) and Android (SAF) and
|
||||
* the dialog itself shouldn't reach into platform code.
|
||||
*/
|
||||
onPickDirectory: () => Promise<string | undefined>;
|
||||
onCancel: () => void;
|
||||
onConfirm: (result: ImportFromFolderResult) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SELECTED_GROUP_IDS = ['epub', 'pdf'];
|
||||
const DEFAULT_MIN_SIZE_KB = 20;
|
||||
|
||||
/**
|
||||
* Folder import dialog: lets the user pick a directory, choose which
|
||||
* book formats to include, and skip files below a size threshold. The
|
||||
* caller is responsible for the actual scan & import — we just collect
|
||||
* the user's intent and hand it back via {@link onConfirm}.
|
||||
*
|
||||
* Renders inside the project's shared `<Dialog>` primitive so it picks
|
||||
* up the standard chassis (modal-box, eink-aware borders, mobile bottom
|
||||
* sheet, RTL direction, focus management) instead of reimplementing
|
||||
* them locally.
|
||||
*/
|
||||
const ImportFromFolderDialog: React.FC<ImportFromFolderDialogProps> = ({
|
||||
initialDirectory,
|
||||
initialFolderMode = 'keep',
|
||||
initialSelectedGroupIds,
|
||||
initialMinSizeKB,
|
||||
onPickDirectory,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
|
||||
const [directory, setDirectory] = useState(initialDirectory);
|
||||
// EPUB + PDF default to selected; this matches the screenshot the
|
||||
// feature was modelled on and reflects the two formats new users are
|
||||
// overwhelmingly most likely to have on disk. A persisted empty set
|
||||
// is treated as "use defaults" so the OK button isn't disabled on
|
||||
// dialog open.
|
||||
const [selectedGroups, setSelectedGroups] = useState<Set<string>>(() => {
|
||||
const valid = (initialSelectedGroupIds ?? []).filter((id) =>
|
||||
DEFAULT_FORMAT_GROUPS.some((g) => g.id === id),
|
||||
);
|
||||
return new Set(valid.length > 0 ? valid : DEFAULT_SELECTED_GROUP_IDS);
|
||||
});
|
||||
const [minSizeKB, setMinSizeKB] = useState<number>(
|
||||
Number.isFinite(initialMinSizeKB) && (initialMinSizeKB as number) >= 0
|
||||
? (initialMinSizeKB as number)
|
||||
: DEFAULT_MIN_SIZE_KB,
|
||||
);
|
||||
// `keep` mirrors folders into nested groups (legacy behaviour);
|
||||
// `flatten` drops every book straight into the current library
|
||||
// root regardless of where it lived on disk. The caller seeds this
|
||||
// from localStorage so the user's last choice is restored.
|
||||
const [folderMode, setFolderMode] = useState<'keep' | 'flatten'>(initialFolderMode);
|
||||
const [picking, setPicking] = useState(false);
|
||||
|
||||
// Enter to confirm. Escape is handled by <Dialog> via onClose.
|
||||
useKeyDownActions({
|
||||
onConfirm: () => {
|
||||
// Block the Enter shortcut while a folder pick is in flight so
|
||||
// we don't dispatch a confirm with a stale directory.
|
||||
if (picking) return;
|
||||
handleConfirm();
|
||||
},
|
||||
});
|
||||
|
||||
const toggleGroup = (id: string) => {
|
||||
setSelectedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handlePickDirectory = async () => {
|
||||
if (picking) return;
|
||||
setPicking(true);
|
||||
try {
|
||||
const picked = await onPickDirectory();
|
||||
if (picked) setDirectory(picked);
|
||||
} finally {
|
||||
setPicking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!directory) return;
|
||||
const exts: string[] = [];
|
||||
const selectedIds: string[] = [];
|
||||
for (const g of DEFAULT_FORMAT_GROUPS) {
|
||||
if (selectedGroups.has(g.id)) {
|
||||
exts.push(...g.exts);
|
||||
selectedIds.push(g.id);
|
||||
}
|
||||
}
|
||||
if (exts.length === 0) return;
|
||||
const safeMinSizeKB = Math.max(0, Math.floor(minSizeKB));
|
||||
onConfirm({
|
||||
directory,
|
||||
extensions: exts,
|
||||
selectedGroupIds: selectedIds,
|
||||
minSizeKB: safeMinSizeKB,
|
||||
flatten: folderMode === 'flatten',
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDisabled = !directory || selectedGroups.size === 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
title={_('Import Books')}
|
||||
onClose={onCancel}
|
||||
boxClassName='sm:min-w-[480px] sm:max-w-[480px] sm:h-auto sm:max-h-[90%]'
|
||||
contentClassName='!px-6 !py-2'
|
||||
>
|
||||
<div className='flex flex-col gap-4 pt-2'>
|
||||
{/* Directory row — clickable input that pops the native folder
|
||||
picker. We render it as a real <button> so screen readers and
|
||||
keyboard navigation work, but style it as an input row so the
|
||||
visual matches the original screenshot's design. */}
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='text-base-content/70 text-xs'>{_('Folder')}</span>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handlePickDirectory}
|
||||
disabled={picking}
|
||||
className={clsx(
|
||||
'eink-bordered flex w-full items-center gap-2 rounded-lg px-3 py-2.5',
|
||||
'text-start text-sm transition-colors duration-150',
|
||||
'border-base-300 bg-base-200/40 hover:bg-base-200/70',
|
||||
'focus-visible:ring-primary/40 focus-visible:outline-none focus-visible:ring-2',
|
||||
picking && 'opacity-60',
|
||||
)}
|
||||
title={directory || _('Choose a folder')}
|
||||
aria-label={_('Choose a folder')}
|
||||
>
|
||||
<MdFolderOpen className='text-base-content/70 h-5 w-5 flex-shrink-0' />
|
||||
<span className={clsx('min-w-0 flex-1 truncate', !directory && 'text-base-content/50')}>
|
||||
{directory || _('Choose a folder')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Format checkboxes — laid out as a 2-column grid so 6 entries
|
||||
fit in three rows on phones without horizontal scrolling. */}
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='text-base-content/70 text-xs'>{_('File Formats')}</span>
|
||||
<div className='grid grid-cols-2 gap-x-3 gap-y-2'>
|
||||
{DEFAULT_FORMAT_GROUPS.map((group) => {
|
||||
const checked = selectedGroups.has(group.id);
|
||||
return (
|
||||
<label
|
||||
key={group.id}
|
||||
className={clsx(
|
||||
'flex cursor-pointer items-center gap-2',
|
||||
'rounded-md px-1 py-1 text-sm',
|
||||
'hover:bg-base-200/50',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='checkbox checkbox-sm'
|
||||
checked={checked}
|
||||
onChange={() => toggleGroup(group.id)}
|
||||
/>
|
||||
<span className='select-none'>{group.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Min-size filter — number input with the KB suffix nested
|
||||
inside the field so it reads as a single unit. The native
|
||||
number-spinner arrows are hidden because they overlapped
|
||||
the rounded input border and looked broken on macOS / WebKit. */}
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='text-sm'>{_('File size larger than')}</span>
|
||||
<div
|
||||
className={clsx(
|
||||
'eink-bordered flex items-center',
|
||||
'border-base-300 bg-base-200/40',
|
||||
'h-9 w-24 rounded-lg',
|
||||
'focus-within:ring-primary/40 focus-within:ring-2',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type='number'
|
||||
inputMode='numeric'
|
||||
min={0}
|
||||
step={1}
|
||||
value={Number.isFinite(minSizeKB) ? minSizeKB : 0}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
setMinSizeKB(Number.isFinite(v) && v >= 0 ? v : 0);
|
||||
}}
|
||||
className={clsx(
|
||||
'no-spinner',
|
||||
'h-full min-w-0 flex-1 rounded-s-lg bg-transparent',
|
||||
'ps-2 pe-1 text-end text-sm',
|
||||
'focus:outline-none',
|
||||
)}
|
||||
aria-label={_('Minimum file size (KB)')}
|
||||
/>
|
||||
<span className='text-base-content/70 select-none pe-2 text-xs'>{_('KB')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Folder-structure mode — radios let the user choose between
|
||||
mirroring subfolders as nested library groups (legacy) or
|
||||
flattening everything straight into the library. */}
|
||||
<div className='flex flex-col gap-1.5' role='radiogroup' aria-label={_('Folder Structure')}>
|
||||
<span className='text-base-content/70 text-xs'>{_('Folder Structure')}</span>
|
||||
<label
|
||||
className={clsx(
|
||||
'flex cursor-pointer items-start gap-2 rounded-md px-1 py-1 text-sm',
|
||||
'hover:bg-base-200/50',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type='radio'
|
||||
name='import-folder-mode'
|
||||
className='radio radio-sm mt-0.5'
|
||||
checked={folderMode === 'keep'}
|
||||
onChange={() => setFolderMode('keep')}
|
||||
/>
|
||||
<span className='select-none'>
|
||||
<span className='block'>{_('Create groups from subfolders')}</span>
|
||||
<span className='text-base-content/60 block text-xs'>
|
||||
{_('Each first-level subfolder becomes a library group.')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className={clsx(
|
||||
'flex cursor-pointer items-start gap-2 rounded-md px-1 py-1 text-sm',
|
||||
'hover:bg-base-200/50',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type='radio'
|
||||
name='import-folder-mode'
|
||||
className='radio radio-sm mt-0.5'
|
||||
checked={folderMode === 'flatten'}
|
||||
onChange={() => setFolderMode('flatten')}
|
||||
/>
|
||||
<span className='select-none'>
|
||||
<span className='block'>{_('Import all into library')}</span>
|
||||
<span className='text-base-content/60 block text-xs'>
|
||||
{_('Recursively add every matching file directly to the library.')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='mt-1 flex justify-end gap-2 pb-2'>
|
||||
<button type='button' className='btn btn-ghost btn-sm' onClick={onCancel}>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className={clsx('btn btn-primary btn-sm', confirmDisabled && 'btn-disabled')}
|
||||
disabled={confirmDisabled}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{_('OK')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportFromFolderDialog;
|
||||
@@ -81,6 +81,9 @@ import LibraryHeader from './components/LibraryHeader';
|
||||
import Bookshelf from './components/Bookshelf';
|
||||
import LibraryEmptyState from './components/LibraryEmptyState';
|
||||
import GroupHeader from './components/GroupHeader';
|
||||
import ImportFromFolderDialog, {
|
||||
ImportFromFolderResult,
|
||||
} from './components/ImportFromFolderDialog';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
import { useReplicaPull } from '@/hooks/useReplicaPull';
|
||||
import { useCustomFonts } from '@/hooks/useCustomFonts';
|
||||
@@ -89,6 +92,31 @@ import SettingsDialog from '@/components/settings/SettingsDialog';
|
||||
import ModalPortal from '@/components/ModalPortal';
|
||||
import TransferQueuePanel from './components/TransferQueuePanel';
|
||||
|
||||
/**
|
||||
* Key used to persist the last directory the user imported books from.
|
||||
* Stored in localStorage so re-opening the dialog (even across app
|
||||
* restarts) seeds the path field with their previous choice — this
|
||||
* mirrors the behaviour of native file pickers on most desktop OSes.
|
||||
*/
|
||||
const LAST_IMPORT_FOLDER_KEY = 'readest:lastImportFolder';
|
||||
/**
|
||||
* Key used to persist the user's last "Folder Structure" choice
|
||||
* ('keep' vs 'flatten'). Restored as the default radio selection on
|
||||
* the next dialog open.
|
||||
*/
|
||||
const LAST_IMPORT_FOLDER_MODE_KEY = 'readest:lastImportFolderMode';
|
||||
/**
|
||||
* Key used to persist the comma-separated list of FormatGroup ids the
|
||||
* user last ticked, e.g. "epub,pdf". Empty / missing falls back to the
|
||||
* dialog's built-in default ("epub,pdf").
|
||||
*/
|
||||
const LAST_IMPORT_FOLDER_FORMATS_KEY = 'readest:lastImportFolderFormats';
|
||||
/**
|
||||
* Key used to persist the last "File size larger than" threshold (KB).
|
||||
* Stored as a stringified non-negative integer.
|
||||
*/
|
||||
const LAST_IMPORT_FOLDER_MIN_SIZE_KEY = 'readest:lastImportFolderMinSizeKB';
|
||||
|
||||
const LibraryPageWithSearchParams = () => {
|
||||
const searchParams = useSearchParams();
|
||||
return <LibraryPageContent searchParams={searchParams} />;
|
||||
@@ -141,6 +169,16 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const [isSelectAll, setIsSelectAll] = useState(false);
|
||||
const [isSelectNone, setIsSelectNone] = useState(false);
|
||||
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
|
||||
// "Import from folder" dialog state. Held as a small object rather
|
||||
// than a boolean because we need a default starting directory to seed
|
||||
// the path field, and we want the dialog to remain mounted long
|
||||
// enough for the platform's folder picker to overlay it.
|
||||
const [importFromFolderState, setImportFromFolderState] = useState<{
|
||||
initialDirectory: string;
|
||||
initialFolderMode: 'keep' | 'flatten';
|
||||
initialSelectedGroupIds?: string[];
|
||||
initialMinSizeKB?: number;
|
||||
} | null>(null);
|
||||
const [currentGroupPath, setCurrentGroupPath] = useState<string | undefined>(undefined);
|
||||
const [currentSeriesAuthorGroup, setCurrentSeriesAuthorGroup] = useState<{
|
||||
groupBy: typeof LibraryGroupByType.Series | typeof LibraryGroupByType.Author;
|
||||
@@ -595,9 +633,18 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
if (!appService) return null;
|
||||
try {
|
||||
const { path, basePath } = selectedFile;
|
||||
// `groupId` is treated as a tri-state:
|
||||
// - undefined → caller didn't specify; derive grouping from
|
||||
// basePath (Import-from-Folder "keep" mode).
|
||||
// - '' (empty) → caller explicitly wants the library root.
|
||||
// - any string → caller explicitly wants that group.
|
||||
// Distinguishing '' from undefined matters for re-imports of an
|
||||
// already-known book: without it, a falsy check would silently
|
||||
// keep the existingBook's stale groupId/groupName from a prior
|
||||
// import instead of moving the book to the root.
|
||||
let resolvedGroupId = groupId;
|
||||
let resolvedGroupName = groupId ? getGroupName(groupId) : undefined;
|
||||
if (!resolvedGroupId && path && basePath) {
|
||||
let resolvedGroupName = groupId !== undefined ? getGroupName(groupId) : undefined;
|
||||
if (resolvedGroupId === undefined && path && basePath) {
|
||||
const rootPath = getDirPath(basePath);
|
||||
resolvedGroupName = getDirPath(path).replace(rootPath, '').replace(/^\//, '');
|
||||
resolvedGroupId = getGroupId(resolvedGroupName);
|
||||
@@ -841,36 +888,133 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
if (!appService || !isTauriAppPlatform()) return;
|
||||
|
||||
setIsSelectMode(false);
|
||||
console.log('Importing books from directory...');
|
||||
let importDirectory: string | undefined = dirPath;
|
||||
if (!importDirectory) {
|
||||
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');
|
||||
|
||||
// When a path is supplied (e.g. URL ingress / drag-drop replay) we
|
||||
// honour the legacy "import everything" behaviour without opening
|
||||
// the dialog. Manual menu invocations always go through the dialog
|
||||
// so users can pick formats and a size threshold before scanning.
|
||||
if (dirPath) {
|
||||
await runFolderImport({
|
||||
directory: dirPath,
|
||||
extensions: SUPPORTED_BOOK_EXTS.slice(),
|
||||
// The non-dialog path is invoked by URL ingress / drag-drop
|
||||
// replay, where the user never picked any filter — keep the
|
||||
// synthetic values minimal and non-restrictive.
|
||||
selectedGroupIds: [],
|
||||
minSizeKB: 0,
|
||||
flatten: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const files = await appService.readDirectory(importDirectory, 'None');
|
||||
const supportedFiles = files.filter((file) => {
|
||||
|
||||
// Restore both the last-used folder and the last folder-structure
|
||||
// mode from localStorage. Anything else (or first-time use) falls
|
||||
// back to the dialog's built-in defaults.
|
||||
const ls = typeof window !== 'undefined' ? window.localStorage : null;
|
||||
const storedDirectory = ls?.getItem(LAST_IMPORT_FOLDER_KEY) || '';
|
||||
const storedMode = ls?.getItem(LAST_IMPORT_FOLDER_MODE_KEY);
|
||||
const storedFormats = ls?.getItem(LAST_IMPORT_FOLDER_FORMATS_KEY);
|
||||
const storedMinSize = ls?.getItem(LAST_IMPORT_FOLDER_MIN_SIZE_KEY);
|
||||
const parsedFormats = storedFormats
|
||||
? storedFormats
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
const parsedMinSize =
|
||||
storedMinSize !== null && storedMinSize !== undefined
|
||||
? Number.parseInt(storedMinSize, 10)
|
||||
: undefined;
|
||||
setImportFromFolderState({
|
||||
initialDirectory: storedDirectory,
|
||||
initialFolderMode: storedMode === 'flatten' ? 'flatten' : 'keep',
|
||||
initialSelectedGroupIds: parsedFormats,
|
||||
initialMinSizeKB:
|
||||
parsedMinSize !== undefined && Number.isFinite(parsedMinSize) && parsedMinSize >= 0
|
||||
? parsedMinSize
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Pop the platform's native folder picker. Wrapped here (rather than
|
||||
* inlined into the dialog) so the same Android-permission / Tauri
|
||||
* dialog dance is shared between the dialog's "change folder" button
|
||||
* and any future programmatic import paths.
|
||||
*/
|
||||
const pickImportDirectory = async (): Promise<string | undefined> => {
|
||||
if (!appService) return undefined;
|
||||
if (appService.isAndroidApp) {
|
||||
if (!(await requestStoragePermission())) return undefined;
|
||||
const response = await selectDirectory();
|
||||
return response.path || undefined;
|
||||
}
|
||||
return (await appService.selectDirectory?.('read')) || undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively scan {@link result.directory}, keep files matching one
|
||||
* of {@link result.extensions} that are at least
|
||||
* {@link result.minSizeKB} KB, and feed them through {@link importBooks}.
|
||||
*
|
||||
* Two cooperating signals carry "where should the imported books
|
||||
* end up" downstream:
|
||||
* 1. Each {@link SelectedFile}'s `basePath` — when present,
|
||||
* {@link importBooks}' `processFile` derives a nested groupName
|
||||
* relative to it (`<sub>` / `<sub>/<deeper>`).
|
||||
* 2. The `groupId` argument passed to {@link importBooks} —
|
||||
* tri-state per the comment in `processFile`. An explicit
|
||||
* string (including '') wins over basePath-derived grouping.
|
||||
*
|
||||
* The two flatten/keep modes use these signals as follows:
|
||||
* - keep → omit basePath? no, *include* basePath; pass
|
||||
* groupId=undefined so basePath wins.
|
||||
* - flatten → omit basePath AND pass an explicit groupId equal to
|
||||
* the user's currently-viewed group ('' = root). The
|
||||
* omitted basePath alone wouldn't be enough on a
|
||||
* re-import, since deduped books carry stale groupIds
|
||||
* from prior sessions; the explicit groupId is what
|
||||
* actually reseats them. Dropping basePath in flatten
|
||||
* mode is therefore belt-and-suspenders.
|
||||
*/
|
||||
const runFolderImport = async (result: ImportFromFolderResult) => {
|
||||
if (!appService || !result.directory) return;
|
||||
// Re-grant scopes for the directory before scanning. This matters
|
||||
// when `result.directory` came from somewhere the dialog plugin
|
||||
// didn't authorise — typically the persisted "last import folder"
|
||||
// restored from localStorage when the user just hit OK without
|
||||
// re-picking. Without this, `RemoteFile` reads through the asset
|
||||
// protocol later in `importBook` would fail with
|
||||
// "asset protocol not configured to allow the path".
|
||||
await appService.allowPathsInScopes?.([result.directory], true);
|
||||
const exts = result.extensions.map((e) => e.toLowerCase());
|
||||
const minSizeBytes = Math.max(0, Math.floor(result.minSizeKB)) * 1024;
|
||||
const files = await appService.readDirectory(result.directory, 'None');
|
||||
const filtered = files.filter((file) => {
|
||||
const ext = file.path.split('.').pop()?.toLowerCase() || '';
|
||||
return SUPPORTED_BOOK_EXTS.includes(ext);
|
||||
if (!exts.includes(ext)) return false;
|
||||
if (minSizeBytes > 0 && file.size < minSizeBytes) return false;
|
||||
return true;
|
||||
});
|
||||
const toImportFiles = await Promise.all(
|
||||
supportedFiles.map(async (file) => {
|
||||
return {
|
||||
path: await joinPaths(importDirectory, file.path),
|
||||
basePath: importDirectory,
|
||||
};
|
||||
filtered.map(async (file) => {
|
||||
const fullPath = await joinPaths(result.directory, file.path);
|
||||
return result.flatten ? { path: fullPath } : { path: fullPath, basePath: result.directory };
|
||||
}),
|
||||
);
|
||||
importBooks(toImportFiles, undefined);
|
||||
if (toImportFiles.length === 0) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
message: _('No matching books found in the selected folder.'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// When flattening, route the books into whichever group the user
|
||||
// is currently viewing (empty string == library root). When
|
||||
// preserving structure we leave groupId undefined so importBooks
|
||||
// derives nested groupNames from each file's basePath.
|
||||
const targetGroupId = result.flatten ? searchParams?.get('group') || '' : undefined;
|
||||
importBooks(toImportFiles, targetGroupId);
|
||||
};
|
||||
|
||||
const handleSetSelectMode = (selectMode: boolean) => {
|
||||
@@ -1058,6 +1202,43 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
<BackupWindow onPullLibrary={pullLibrary} />
|
||||
{isSettingsDialogOpen && <SettingsDialog bookKey={''} />}
|
||||
{showCatalogManager && <CatalogDialog onClose={handleDismissOPDSDialog} />}
|
||||
{importFromFolderState && (
|
||||
<ImportFromFolderDialog
|
||||
initialDirectory={importFromFolderState.initialDirectory}
|
||||
initialFolderMode={importFromFolderState.initialFolderMode}
|
||||
initialSelectedGroupIds={importFromFolderState.initialSelectedGroupIds}
|
||||
initialMinSizeKB={importFromFolderState.initialMinSizeKB}
|
||||
onPickDirectory={pickImportDirectory}
|
||||
onCancel={() => setImportFromFolderState(null)}
|
||||
onConfirm={(result) => {
|
||||
setImportFromFolderState(null);
|
||||
// Remember the folder + filters for next time. Done here
|
||||
// (rather than inside pickImportDirectory) so we only
|
||||
// persist values the user actually committed to, not
|
||||
// ones they cancelled out of.
|
||||
if (typeof window !== 'undefined') {
|
||||
if (result.directory) {
|
||||
window.localStorage.setItem(LAST_IMPORT_FOLDER_KEY, result.directory);
|
||||
}
|
||||
window.localStorage.setItem(
|
||||
LAST_IMPORT_FOLDER_MODE_KEY,
|
||||
result.flatten ? 'flatten' : 'keep',
|
||||
);
|
||||
if (result.selectedGroupIds.length > 0) {
|
||||
window.localStorage.setItem(
|
||||
LAST_IMPORT_FOLDER_FORMATS_KEY,
|
||||
result.selectedGroupIds.join(','),
|
||||
);
|
||||
}
|
||||
window.localStorage.setItem(
|
||||
LAST_IMPORT_FOLDER_MIN_SIZE_KEY,
|
||||
String(result.minSizeKB),
|
||||
);
|
||||
}
|
||||
void runFolderImport(result);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -49,7 +49,13 @@ export async function ingestFile(
|
||||
});
|
||||
if (!book) return null;
|
||||
|
||||
if (opts.groupId) {
|
||||
// Tri-state: undefined leaves whatever group the existing
|
||||
// (deduped) book already had untouched; an explicit string —
|
||||
// including the empty string — replaces it. The empty-string case
|
||||
// is what library imports use to "demote" a book back to the root
|
||||
// when the user picks Import-from-Folder → flatten on a previously
|
||||
// grouped book.
|
||||
if (opts.groupId !== undefined) {
|
||||
book.groupId = opts.groupId;
|
||||
book.groupName = opts.groupName;
|
||||
}
|
||||
|
||||
@@ -546,6 +546,14 @@ export class NativeAppService extends BaseAppService {
|
||||
multiple: false,
|
||||
recursive: true,
|
||||
});
|
||||
if (selected) {
|
||||
// Tauri's dialog plugin only auto-grants fs_scope; the asset
|
||||
// protocol scope still needs an explicit allow before
|
||||
// RemoteFile / convertFileSrc-based reads can succeed against
|
||||
// arbitrary user paths. Persisted-scope plugin makes this
|
||||
// sticky across restarts.
|
||||
await this.allowPathsInScopes([selected as string], true);
|
||||
}
|
||||
return selected as string;
|
||||
}
|
||||
|
||||
@@ -555,7 +563,26 @@ export class NativeAppService extends BaseAppService {
|
||||
filters: [{ name, extensions }],
|
||||
});
|
||||
const files = Array.isArray(selected) ? selected : selected ? [selected] : [];
|
||||
return OS_TYPE === 'ios' ? files.map((f) => safeDecodePath(f)) : files;
|
||||
const decoded = OS_TYPE === 'ios' ? files.map((f) => safeDecodePath(f)) : files;
|
||||
if (decoded.length > 0) {
|
||||
// See the note in selectDirectory above.
|
||||
await this.allowPathsInScopes(decoded, false);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: ask the Rust side to extend `fs_scope` and
|
||||
* `asset_protocol_scope` to cover the given paths. Errors are logged
|
||||
* and swallowed because the import path can still succeed via the
|
||||
* NativeFile fallback even when scope extension fails.
|
||||
*/
|
||||
async allowPathsInScopes(paths: string[], isDirectory: boolean): Promise<void> {
|
||||
try {
|
||||
await invoke('allow_paths_in_scopes', { paths, isDirectory });
|
||||
} catch (e) {
|
||||
console.warn('allow_paths_in_scopes failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async saveFile(
|
||||
|
||||
@@ -59,6 +59,18 @@ body {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Hide the native number-input spinner arrows. WebKit on macOS draws
|
||||
them on top of rounded input borders, which looks broken; Firefox
|
||||
respects -moz-appearance: textfield. */
|
||||
.no-spinner {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.no-spinner::-webkit-outer-spin-button,
|
||||
.no-spinner::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.full-height {
|
||||
|
||||
@@ -118,6 +118,15 @@ export interface AppService {
|
||||
selectDirectory(mode: SelectDirectoryMode): Promise<string>;
|
||||
selectFiles(name: string, extensions: string[]): Promise<string[]>;
|
||||
readDirectory(path: string, base: BaseDir): Promise<FileItem[]>;
|
||||
/**
|
||||
* Best-effort: extend the Tauri `fs_scope` and `asset_protocol_scope`
|
||||
* to cover the given paths. No-op on web. Used after a directory or
|
||||
* file path is recovered from somewhere other than the native picker
|
||||
* (e.g. localStorage of the last-used import folder), since the
|
||||
* dialog plugin only auto-allows `fs_scope` for paths it returned in
|
||||
* the current session.
|
||||
*/
|
||||
allowPathsInScopes?(paths: string[], isDirectory: boolean): Promise<void>;
|
||||
saveFile(
|
||||
filename: string,
|
||||
content: string | ArrayBuffer,
|
||||
|
||||
Reference in New Issue
Block a user