Add per-folder sort and search to the WebDAV browse pane in Settings, Integrations, WebDAV. - Sort by name, date modified, date created, or size, ascending or descending; the choice persists in WebDAV settings so a chosen "recent first" order survives across sessions. - Filter the current folder by file name or matched book title. - Request and parse the WebDAV creationdate property; servers that omit it fall back gracefully to a stable name order with no broken dates. - Sort and search resolve a per-hash book directory to its library title so they operate on what the user actually sees. Sort and filter are pure, unit-tested helpers in webdavBrowseUtils; creationdate parsing is covered by a listDirectory test. Verified on a Xiaomi device against a live WebDAV server (675 books): name, modified asc/desc, title filter, and persistence across an app restart. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1926,5 +1926,11 @@
|
||||
"Background Image (Reader)": "صورة الخلفية (واجهة القراءة)",
|
||||
"updated metadata for {{n}} book(s)": "تم تحديث البيانات الوصفية لـ {{n}} كتاب",
|
||||
"{{n}} metadata": "{{n}} بيانات وصفية",
|
||||
"Metadata updated": "تم تحديث البيانات الوصفية"
|
||||
"Metadata updated": "تم تحديث البيانات الوصفية",
|
||||
"Filter": "تصفية",
|
||||
"Sort by": "ترتيب حسب",
|
||||
"Date modified": "تاريخ التعديل",
|
||||
"Date created": "تاريخ الإنشاء",
|
||||
"Sort ascending": "ترتيب تصاعدي",
|
||||
"Sort descending": "ترتيب تنازلي"
|
||||
}
|
||||
|
||||
@@ -1794,5 +1794,11 @@
|
||||
"Background Image (Reader)": "ব্যাকগ্রাউন্ড ছবি (রিডার)",
|
||||
"updated metadata for {{n}} book(s)": "{{n}}টি বইয়ের মেটাডেটা আপডেট হয়েছে",
|
||||
"{{n}} metadata": "{{n}} মেটাডেটা",
|
||||
"Metadata updated": "মেটাডেটা আপডেট হয়েছে"
|
||||
"Metadata updated": "মেটাডেটা আপডেট হয়েছে",
|
||||
"Filter": "ফিল্টার",
|
||||
"Sort by": "অনুসারে সাজান",
|
||||
"Date modified": "পরিবর্তনের তারিখ",
|
||||
"Date created": "তৈরির তারিখ",
|
||||
"Sort ascending": "ঊর্ধ্বক্রমে সাজান",
|
||||
"Sort descending": "অধঃক্রমে সাজান"
|
||||
}
|
||||
|
||||
@@ -1794,5 +1794,11 @@
|
||||
"Background Image (Reader)": "Hintergrundbild (Leseansicht)",
|
||||
"updated metadata for {{n}} book(s)": "Metadaten für {{n}} Buch/Bücher aktualisiert",
|
||||
"{{n}} metadata": "{{n}} Metadaten",
|
||||
"Metadata updated": "Metadaten aktualisiert"
|
||||
"Metadata updated": "Metadaten aktualisiert",
|
||||
"Filter": "Filtern",
|
||||
"Sort by": "Sortieren nach",
|
||||
"Date modified": "Änderungsdatum",
|
||||
"Date created": "Erstellungsdatum",
|
||||
"Sort ascending": "Aufsteigend sortieren",
|
||||
"Sort descending": "Absteigend sortieren"
|
||||
}
|
||||
|
||||
@@ -1794,5 +1794,11 @@
|
||||
"Background Image (Reader)": "Εικόνα φόντου (Προβολή ανάγνωσης)",
|
||||
"updated metadata for {{n}} book(s)": "ενημερώθηκαν τα μεταδεδομένα για {{n}} βιβλίο/α",
|
||||
"{{n}} metadata": "{{n}} μεταδεδομένα",
|
||||
"Metadata updated": "Ενημερωμένα μεταδεδομένα"
|
||||
"Metadata updated": "Ενημερωμένα μεταδεδομένα",
|
||||
"Filter": "Φιλτράρισμα",
|
||||
"Sort by": "Ταξινόμηση κατά",
|
||||
"Date modified": "Ημερομηνία τροποποίησης",
|
||||
"Date created": "Ημερομηνία δημιουργίας",
|
||||
"Sort ascending": "Αύξουσα ταξινόμηση",
|
||||
"Sort descending": "Φθίνουσα ταξινόμηση"
|
||||
}
|
||||
|
||||
@@ -1827,5 +1827,11 @@
|
||||
"Background Image (Reader)": "Imagen de fondo (Lector)",
|
||||
"updated metadata for {{n}} book(s)": "metadatos actualizados de {{n}} libro(s)",
|
||||
"{{n}} metadata": "{{n}} metadatos",
|
||||
"Metadata updated": "Metadatos actualizados"
|
||||
"Metadata updated": "Metadatos actualizados",
|
||||
"Filter": "Filtrar",
|
||||
"Sort by": "Ordenar por",
|
||||
"Date modified": "Fecha de modificación",
|
||||
"Date created": "Fecha de creación",
|
||||
"Sort ascending": "Orden ascendente",
|
||||
"Sort descending": "Orden descendente"
|
||||
}
|
||||
|
||||
@@ -1794,5 +1794,11 @@
|
||||
"Background Image (Reader)": "تصویر پسزمینه (صفحه مطالعه)",
|
||||
"updated metadata for {{n}} book(s)": "متادیتای {{n}} کتاب بهروزرسانی شد",
|
||||
"{{n}} metadata": "{{n}} متادیتا",
|
||||
"Metadata updated": "متادیتا بهروزرسانی شد"
|
||||
"Metadata updated": "متادیتا بهروزرسانی شد",
|
||||
"Filter": "فیلتر",
|
||||
"Sort by": "مرتبسازی بر اساس",
|
||||
"Date modified": "تاریخ تغییر",
|
||||
"Date created": "تاریخ ایجاد",
|
||||
"Sort ascending": "مرتبسازی صعودی",
|
||||
"Sort descending": "مرتبسازی نزولی"
|
||||
}
|
||||
|
||||
@@ -1827,5 +1827,11 @@
|
||||
"Background Image (Reader)": "Image de Fond (Vue de lecture)",
|
||||
"updated metadata for {{n}} book(s)": "métadonnées mises à jour pour {{n}} livre(s)",
|
||||
"{{n}} metadata": "{{n}} métadonnées",
|
||||
"Metadata updated": "Métadonnées mises à jour"
|
||||
"Metadata updated": "Métadonnées mises à jour",
|
||||
"Filter": "Filtrer",
|
||||
"Sort by": "Trier par",
|
||||
"Date modified": "Date de modification",
|
||||
"Date created": "Date de création",
|
||||
"Sort ascending": "Tri croissant",
|
||||
"Sort descending": "Tri décroissant"
|
||||
}
|
||||
|
||||
@@ -1827,5 +1827,11 @@
|
||||
"Background Image (Reader)": "תמונת רקע (מצב קריאה)",
|
||||
"updated metadata for {{n}} book(s)": "המטא-נתונים עודכנו עבור {{n}} ספרים",
|
||||
"{{n}} metadata": "{{n}} מטא-נתונים",
|
||||
"Metadata updated": "המטא-נתונים עודכנו"
|
||||
"Metadata updated": "המטא-נתונים עודכנו",
|
||||
"Filter": "סינון",
|
||||
"Sort by": "מיין לפי",
|
||||
"Date modified": "תאריך שינוי",
|
||||
"Date created": "תאריך יצירה",
|
||||
"Sort ascending": "מיון עולה",
|
||||
"Sort descending": "מיון יורד"
|
||||
}
|
||||
|
||||
@@ -1794,5 +1794,11 @@
|
||||
"Background Image (Reader)": "पृष्ठभूमि छवि (रीडर)",
|
||||
"updated metadata for {{n}} book(s)": "{{n}} पुस्तक(ों) के मेटाडेटा अपडेट किए गए",
|
||||
"{{n}} metadata": "{{n}} मेटाडेटा",
|
||||
"Metadata updated": "मेटाडेटा अपडेट किया गया"
|
||||
"Metadata updated": "मेटाडेटा अपडेट किया गया",
|
||||
"Filter": "फ़िल्टर",
|
||||
"Sort by": "इसके अनुसार क्रमित करें",
|
||||
"Date modified": "संशोधन तिथि",
|
||||
"Date created": "निर्माण तिथि",
|
||||
"Sort ascending": "आरोही क्रम",
|
||||
"Sort descending": "अवरोही क्रम"
|
||||
}
|
||||
|
||||
@@ -1794,5 +1794,11 @@
|
||||
"Background Image (Reader)": "Háttérkép (Olvasónézet)",
|
||||
"updated metadata for {{n}} book(s)": "{{n}} könyv metaadatai frissítve",
|
||||
"{{n}} metadata": "{{n}} metaadat",
|
||||
"Metadata updated": "Metaadatok frissítve"
|
||||
"Metadata updated": "Metaadatok frissítve",
|
||||
"Filter": "Szűrés",
|
||||
"Sort by": "Rendezés alapja",
|
||||
"Date modified": "Módosítás dátuma",
|
||||
"Date created": "Létrehozás dátuma",
|
||||
"Sort ascending": "Növekvő sorrend",
|
||||
"Sort descending": "Csökkenő sorrend"
|
||||
}
|
||||
|
||||
@@ -1761,5 +1761,11 @@
|
||||
"Background Image (Reader)": "Gambar Latar Belakang (Tampilan Baca)",
|
||||
"updated metadata for {{n}} book(s)": "metadata diperbarui untuk {{n}} buku",
|
||||
"{{n}} metadata": "{{n}} metadata",
|
||||
"Metadata updated": "Metadata diperbarui"
|
||||
"Metadata updated": "Metadata diperbarui",
|
||||
"Filter": "Filter",
|
||||
"Sort by": "Urutkan menurut",
|
||||
"Date modified": "Tanggal diubah",
|
||||
"Date created": "Tanggal dibuat",
|
||||
"Sort ascending": "Urutkan menaik",
|
||||
"Sort descending": "Urutkan menurun"
|
||||
}
|
||||
|
||||
@@ -1827,5 +1827,11 @@
|
||||
"Background Image (Reader)": "Immagine di sfondo (Vista di lettura)",
|
||||
"updated metadata for {{n}} book(s)": "metadati aggiornati per {{n}} libro/i",
|
||||
"{{n}} metadata": "{{n}} metadati",
|
||||
"Metadata updated": "Metadati aggiornati"
|
||||
"Metadata updated": "Metadati aggiornati",
|
||||
"Filter": "Filtra",
|
||||
"Sort by": "Ordina per",
|
||||
"Date modified": "Data di modifica",
|
||||
"Date created": "Data di creazione",
|
||||
"Sort ascending": "Ordine crescente",
|
||||
"Sort descending": "Ordine decrescente"
|
||||
}
|
||||
|
||||
@@ -1761,5 +1761,11 @@
|
||||
"Background Image (Reader)": "背景画像(リーダー)",
|
||||
"updated metadata for {{n}} book(s)": "{{n}} 冊分のメタデータを更新",
|
||||
"{{n}} metadata": "{{n}} 件メタデータ",
|
||||
"Metadata updated": "更新したメタデータ"
|
||||
"Metadata updated": "更新したメタデータ",
|
||||
"Filter": "フィルター",
|
||||
"Sort by": "並べ替え",
|
||||
"Date modified": "更新日",
|
||||
"Date created": "作成日",
|
||||
"Sort ascending": "昇順で並べ替え",
|
||||
"Sort descending": "降順で並べ替え"
|
||||
}
|
||||
|
||||
@@ -1761,5 +1761,11 @@
|
||||
"Background Image (Reader)": "배경 이미지 (리더)",
|
||||
"updated metadata for {{n}} book(s)": "{{n}}권의 메타데이터 업데이트됨",
|
||||
"{{n}} metadata": "{{n}} 메타데이터",
|
||||
"Metadata updated": "메타데이터 업데이트됨"
|
||||
"Metadata updated": "메타데이터 업데이트됨",
|
||||
"Filter": "필터",
|
||||
"Sort by": "정렬 기준",
|
||||
"Date modified": "수정한 날짜",
|
||||
"Date created": "만든 날짜",
|
||||
"Sort ascending": "오름차순 정렬",
|
||||
"Sort descending": "내림차순 정렬"
|
||||
}
|
||||
|
||||
@@ -1761,5 +1761,11 @@
|
||||
"Background Image (Reader)": "Imej Latar Belakang (Paparan Bacaan)",
|
||||
"updated metadata for {{n}} book(s)": "metadata dikemas kini untuk {{n}} buku",
|
||||
"{{n}} metadata": "{{n}} metadata",
|
||||
"Metadata updated": "Metadata dikemas kini"
|
||||
"Metadata updated": "Metadata dikemas kini",
|
||||
"Filter": "Tapis",
|
||||
"Sort by": "Isih mengikut",
|
||||
"Date modified": "Tarikh diubah suai",
|
||||
"Date created": "Tarikh dicipta",
|
||||
"Sort ascending": "Isih menaik",
|
||||
"Sort descending": "Isih menurun"
|
||||
}
|
||||
|
||||
@@ -1794,5 +1794,11 @@
|
||||
"Background Image (Reader)": "Achtergrondafbeelding (Leesweergave)",
|
||||
"updated metadata for {{n}} book(s)": "metadata bijgewerkt voor {{n}} boek(en)",
|
||||
"{{n}} metadata": "{{n}} metadata",
|
||||
"Metadata updated": "Metadata bijgewerkt"
|
||||
"Metadata updated": "Metadata bijgewerkt",
|
||||
"Filter": "Filteren",
|
||||
"Sort by": "Sorteren op",
|
||||
"Date modified": "Wijzigingsdatum",
|
||||
"Date created": "Aanmaakdatum",
|
||||
"Sort ascending": "Oplopend sorteren",
|
||||
"Sort descending": "Aflopend sorteren"
|
||||
}
|
||||
|
||||
@@ -1860,5 +1860,11 @@
|
||||
"Background Image (Reader)": "Obraz tła (Widok czytania)",
|
||||
"updated metadata for {{n}} book(s)": "zaktualizowano metadane dla {{n}} książek",
|
||||
"{{n}} metadata": "{{n}} metadane",
|
||||
"Metadata updated": "Zaktualizowane metadane"
|
||||
"Metadata updated": "Zaktualizowane metadane",
|
||||
"Filter": "Filtruj",
|
||||
"Sort by": "Sortuj według",
|
||||
"Date modified": "Data modyfikacji",
|
||||
"Date created": "Data utworzenia",
|
||||
"Sort ascending": "Sortuj rosnąco",
|
||||
"Sort descending": "Sortuj malejąco"
|
||||
}
|
||||
|
||||
@@ -1827,5 +1827,11 @@
|
||||
"Background Image (Reader)": "Imagem de fundo (Vista de leitura)",
|
||||
"updated metadata for {{n}} book(s)": "metadados atualizados de {{n}} livro(s)",
|
||||
"{{n}} metadata": "{{n}} metadados",
|
||||
"Metadata updated": "Metadados atualizados"
|
||||
"Metadata updated": "Metadados atualizados",
|
||||
"Filter": "Filtrar",
|
||||
"Sort by": "Ordenar por",
|
||||
"Date modified": "Data de modificação",
|
||||
"Date created": "Data de criação",
|
||||
"Sort ascending": "Ordem crescente",
|
||||
"Sort descending": "Ordem decrescente"
|
||||
}
|
||||
|
||||
@@ -1827,5 +1827,11 @@
|
||||
"Background Image (Reader)": "Imagem de Fundo (Vista de leitura)",
|
||||
"updated metadata for {{n}} book(s)": "metadados atualizados de {{n}} livro(s)",
|
||||
"{{n}} metadata": "{{n}} metadados",
|
||||
"Metadata updated": "Metadados atualizados"
|
||||
"Metadata updated": "Metadados atualizados",
|
||||
"Filter": "Filtrar",
|
||||
"Sort by": "Ordenar por",
|
||||
"Date modified": "Data de modificação",
|
||||
"Date created": "Data de criação",
|
||||
"Sort ascending": "Ordem crescente",
|
||||
"Sort descending": "Ordem decrescente"
|
||||
}
|
||||
|
||||
@@ -1827,5 +1827,11 @@
|
||||
"Background Image (Reader)": "Imagine de fundal (Vizualizare de lectură)",
|
||||
"updated metadata for {{n}} book(s)": "metadate actualizate pentru {{n}} carte/cărți",
|
||||
"{{n}} metadata": "{{n}} metadate",
|
||||
"Metadata updated": "Metadate actualizate"
|
||||
"Metadata updated": "Metadate actualizate",
|
||||
"Filter": "Filtrează",
|
||||
"Sort by": "Sortează după",
|
||||
"Date modified": "Data modificării",
|
||||
"Date created": "Data creării",
|
||||
"Sort ascending": "Sortare crescătoare",
|
||||
"Sort descending": "Sortare descrescătoare"
|
||||
}
|
||||
|
||||
@@ -1860,5 +1860,11 @@
|
||||
"Background Image (Reader)": "Фоновое изображение (Режим чтения)",
|
||||
"updated metadata for {{n}} book(s)": "обновлены метаданные для {{n}} книг",
|
||||
"{{n}} metadata": "{{n}} метаданные",
|
||||
"Metadata updated": "Метаданные обновлены"
|
||||
"Metadata updated": "Метаданные обновлены",
|
||||
"Filter": "Фильтр",
|
||||
"Sort by": "Сортировать по",
|
||||
"Date modified": "Дата изменения",
|
||||
"Date created": "Дата создания",
|
||||
"Sort ascending": "По возрастанию",
|
||||
"Sort descending": "По убыванию"
|
||||
}
|
||||
|
||||
@@ -1794,5 +1794,11 @@
|
||||
"Background Image (Reader)": "පසුබිම් රූපය (කියවීමේ දසුන)",
|
||||
"updated metadata for {{n}} book(s)": "පොත් {{n}}ක මෙටාඩේටා යාවත්කාලීන කරන ලදී",
|
||||
"{{n}} metadata": "{{n}} මෙටාඩේටා",
|
||||
"Metadata updated": "මෙටාඩේටා යාවත්කාලීන කරන ලදී"
|
||||
"Metadata updated": "මෙටාඩේටා යාවත්කාලීන කරන ලදී",
|
||||
"Filter": "පෙරහන",
|
||||
"Sort by": "අනුව සකසන්න",
|
||||
"Date modified": "වෙනස් කළ දිනය",
|
||||
"Date created": "සෑදූ දිනය",
|
||||
"Sort ascending": "ආරෝහණව සකසන්න",
|
||||
"Sort descending": "අවරෝහණව සකසන්න"
|
||||
}
|
||||
|
||||
@@ -1860,5 +1860,11 @@
|
||||
"Background Image (Reader)": "Slikovno ozadje (Bralni pogled)",
|
||||
"updated metadata for {{n}} book(s)": "posodobljeni metapodatki za {{n}} knjig",
|
||||
"{{n}} metadata": "{{n}} metapodatki",
|
||||
"Metadata updated": "Metapodatki posodobljeni"
|
||||
"Metadata updated": "Metapodatki posodobljeni",
|
||||
"Filter": "Filtriraj",
|
||||
"Sort by": "Razvrsti po",
|
||||
"Date modified": "Datum spremembe",
|
||||
"Date created": "Datum ustvarjanja",
|
||||
"Sort ascending": "Naraščajoče razvrščanje",
|
||||
"Sort descending": "Padajoče razvrščanje"
|
||||
}
|
||||
|
||||
@@ -1794,5 +1794,11 @@
|
||||
"Background Image (Reader)": "Bakgrundsbild (Läsvy)",
|
||||
"updated metadata for {{n}} book(s)": "uppdaterade metadata för {{n}} bok/böcker",
|
||||
"{{n}} metadata": "{{n}} metadata",
|
||||
"Metadata updated": "Metadata uppdaterad"
|
||||
"Metadata updated": "Metadata uppdaterad",
|
||||
"Filter": "Filtrera",
|
||||
"Sort by": "Sortera efter",
|
||||
"Date modified": "Ändringsdatum",
|
||||
"Date created": "Skapandedatum",
|
||||
"Sort ascending": "Stigande sortering",
|
||||
"Sort descending": "Fallande sortering"
|
||||
}
|
||||
|
||||
@@ -1794,5 +1794,11 @@
|
||||
"Background Image (Reader)": "பின்னணி படம் (வாசிப்புப் பக்கம்)",
|
||||
"updated metadata for {{n}} book(s)": "{{n}} புத்தகத்தின் மெட்டாடேட்டா புதுப்பிக்கப்பட்டது",
|
||||
"{{n}} metadata": "{{n}} மெட்டாடேட்டா",
|
||||
"Metadata updated": "மெட்டாடேட்டா புதுப்பிக்கப்பட்டது"
|
||||
"Metadata updated": "மெட்டாடேட்டா புதுப்பிக்கப்பட்டது",
|
||||
"Filter": "வடிகட்டு",
|
||||
"Sort by": "வரிசைப்படுத்து",
|
||||
"Date modified": "மாற்றிய தேதி",
|
||||
"Date created": "உருவாக்கிய தேதி",
|
||||
"Sort ascending": "ஏறுவரிசை",
|
||||
"Sort descending": "இறங்குவரிசை"
|
||||
}
|
||||
|
||||
@@ -1761,5 +1761,11 @@
|
||||
"Background Image (Reader)": "ภาพพื้นหลัง (มุมมองการอ่าน)",
|
||||
"updated metadata for {{n}} book(s)": "อัปเดตเมทาดาตาสำหรับ {{n}} เล่ม",
|
||||
"{{n}} metadata": "{{n}} เมทาดาตา",
|
||||
"Metadata updated": "อัปเดตเมทาดาตาแล้ว"
|
||||
"Metadata updated": "อัปเดตเมทาดาตาแล้ว",
|
||||
"Filter": "กรอง",
|
||||
"Sort by": "เรียงตาม",
|
||||
"Date modified": "วันที่แก้ไข",
|
||||
"Date created": "วันที่สร้าง",
|
||||
"Sort ascending": "เรียงจากน้อยไปมาก",
|
||||
"Sort descending": "เรียงจากมากไปน้อย"
|
||||
}
|
||||
|
||||
@@ -1794,5 +1794,11 @@
|
||||
"Background Image (Reader)": "Arka Plan Görüntüsü (Okuma Görünümü)",
|
||||
"updated metadata for {{n}} book(s)": "{{n}} kitabın meta verileri güncellendi",
|
||||
"{{n}} metadata": "{{n}} meta veri",
|
||||
"Metadata updated": "Meta veriler güncellendi"
|
||||
"Metadata updated": "Meta veriler güncellendi",
|
||||
"Filter": "Filtrele",
|
||||
"Sort by": "Sıralama ölçütü",
|
||||
"Date modified": "Değiştirilme tarihi",
|
||||
"Date created": "Oluşturulma tarihi",
|
||||
"Sort ascending": "Artan sıralama",
|
||||
"Sort descending": "Azalan sıralama"
|
||||
}
|
||||
|
||||
@@ -1860,5 +1860,11 @@
|
||||
"Background Image (Reader)": "Фонове зображення (Режим читання)",
|
||||
"updated metadata for {{n}} book(s)": "оновлено метадані для {{n}} книг",
|
||||
"{{n}} metadata": "{{n}} метадані",
|
||||
"Metadata updated": "Метадані оновлено"
|
||||
"Metadata updated": "Метадані оновлено",
|
||||
"Filter": "Фільтр",
|
||||
"Sort by": "Сортувати за",
|
||||
"Date modified": "Дата зміни",
|
||||
"Date created": "Дата створення",
|
||||
"Sort ascending": "За зростанням",
|
||||
"Sort descending": "За спаданням"
|
||||
}
|
||||
|
||||
@@ -1794,5 +1794,11 @@
|
||||
"Background Image (Reader)": "Fon rasmi (Oʻqish koʻrinishi)",
|
||||
"updated metadata for {{n}} book(s)": "{{n}} ta kitob metamaʼlumotlari yangilandi",
|
||||
"{{n}} metadata": "{{n}} metamaʼlumot",
|
||||
"Metadata updated": "Metamaʼlumotlar yangilandi"
|
||||
"Metadata updated": "Metamaʼlumotlar yangilandi",
|
||||
"Filter": "Filtr",
|
||||
"Sort by": "Saralash tartibi",
|
||||
"Date modified": "Oʻzgartirilgan sana",
|
||||
"Date created": "Yaratilgan sana",
|
||||
"Sort ascending": "Oʻsish boʻyicha",
|
||||
"Sort descending": "Kamayish boʻyicha"
|
||||
}
|
||||
|
||||
@@ -1761,5 +1761,11 @@
|
||||
"Background Image (Reader)": "Ảnh nền (Trình đọc)",
|
||||
"updated metadata for {{n}} book(s)": "đã cập nhật siêu dữ liệu cho {{n}} sách",
|
||||
"{{n}} metadata": "{{n}} siêu dữ liệu",
|
||||
"Metadata updated": "Đã cập nhật siêu dữ liệu"
|
||||
"Metadata updated": "Đã cập nhật siêu dữ liệu",
|
||||
"Filter": "Lọc",
|
||||
"Sort by": "Sắp xếp theo",
|
||||
"Date modified": "Ngày sửa đổi",
|
||||
"Date created": "Ngày tạo",
|
||||
"Sort ascending": "Sắp xếp tăng dần",
|
||||
"Sort descending": "Sắp xếp giảm dần"
|
||||
}
|
||||
|
||||
@@ -1761,5 +1761,11 @@
|
||||
"Background Image (Reader)": "背景图片(阅读界面)",
|
||||
"updated metadata for {{n}} book(s)": "已更新 {{n}} 本书的元数据",
|
||||
"{{n}} metadata": "{{n}} 元数据",
|
||||
"Metadata updated": "已更新元数据"
|
||||
"Metadata updated": "已更新元数据",
|
||||
"Filter": "筛选",
|
||||
"Sort by": "排序方式",
|
||||
"Date modified": "修改日期",
|
||||
"Date created": "创建日期",
|
||||
"Sort ascending": "升序排列",
|
||||
"Sort descending": "降序排列"
|
||||
}
|
||||
|
||||
@@ -1761,5 +1761,11 @@
|
||||
"Background Image (Reader)": "背景圖片(閱讀介面)",
|
||||
"updated metadata for {{n}} book(s)": "已更新 {{n}} 本書的中繼資料",
|
||||
"{{n}} metadata": "{{n}} 中繼資料",
|
||||
"Metadata updated": "已更新中繼資料"
|
||||
"Metadata updated": "已更新中繼資料",
|
||||
"Filter": "篩選",
|
||||
"Sort by": "排序方式",
|
||||
"Date modified": "修改日期",
|
||||
"Date created": "建立日期",
|
||||
"Sort ascending": "升序排列",
|
||||
"Sort descending": "降序排列"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
filterWebDAVEntries,
|
||||
sortWebDAVEntries,
|
||||
} from '@/components/settings/integrations/webdavBrowseUtils';
|
||||
import type { WebDAVEntry } from '@/services/sync/providers/webdav/client';
|
||||
|
||||
/**
|
||||
* Pure sort/filter helpers backing the WebDAV browser's sort + search
|
||||
* controls. They operate on already-fetched `WebDAVEntry[]` (the pane
|
||||
* lists a whole directory in one PROPFIND, so there's no pagination to
|
||||
* worry about) which keeps them trivially unit-testable here.
|
||||
*/
|
||||
|
||||
const dir = (name: string, extra: Partial<WebDAVEntry> = {}): WebDAVEntry => ({
|
||||
name,
|
||||
path: `/${name}`,
|
||||
isDirectory: true,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const file = (name: string, extra: Partial<WebDAVEntry> = {}): WebDAVEntry => ({
|
||||
name,
|
||||
path: `/${name}`,
|
||||
isDirectory: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const names = (entries: WebDAVEntry[]): string[] => entries.map((e) => e.name);
|
||||
|
||||
describe('sortWebDAVEntries', () => {
|
||||
test('keeps directories grouped before files regardless of field/direction', () => {
|
||||
const entries = [file('b.epub', { size: 10 }), dir('z-folder'), file('a.epub', { size: 20 })];
|
||||
const sorted = sortWebDAVEntries(entries, 'size', false);
|
||||
// Directories first even though the field is size descending and the
|
||||
// dir has no size; files follow, ordered by the field.
|
||||
expect(sorted[0]!.isDirectory).toBe(true);
|
||||
expect(names(sorted.filter((e) => !e.isDirectory))).toEqual(['a.epub', 'b.epub']);
|
||||
});
|
||||
|
||||
test('sorts by name ascending and descending', () => {
|
||||
const entries = [file('Charlie.epub'), file('alpha.epub'), file('Bravo.epub')];
|
||||
expect(names(sortWebDAVEntries(entries, 'name', true))).toEqual([
|
||||
'alpha.epub',
|
||||
'Bravo.epub',
|
||||
'Charlie.epub',
|
||||
]);
|
||||
expect(names(sortWebDAVEntries(entries, 'name', false))).toEqual([
|
||||
'Charlie.epub',
|
||||
'Bravo.epub',
|
||||
'alpha.epub',
|
||||
]);
|
||||
});
|
||||
|
||||
test('sorts by last modified date, newest-first when descending', () => {
|
||||
const entries = [
|
||||
file('old.epub', { lastModified: 'Mon, 01 Jan 2024 00:00:00 GMT' }),
|
||||
file('new.epub', { lastModified: 'Wed, 01 Jan 2025 00:00:00 GMT' }),
|
||||
file('mid.epub', { lastModified: 'Sat, 01 Jun 2024 00:00:00 GMT' }),
|
||||
];
|
||||
expect(names(sortWebDAVEntries(entries, 'modified', false))).toEqual([
|
||||
'new.epub',
|
||||
'mid.epub',
|
||||
'old.epub',
|
||||
]);
|
||||
expect(names(sortWebDAVEntries(entries, 'modified', true))).toEqual([
|
||||
'old.epub',
|
||||
'mid.epub',
|
||||
'new.epub',
|
||||
]);
|
||||
});
|
||||
|
||||
test('sorts by creation date', () => {
|
||||
const entries = [
|
||||
file('second.epub', { created: '2024-05-01T00:00:00Z' }),
|
||||
file('first.epub', { created: '2024-01-01T00:00:00Z' }),
|
||||
];
|
||||
expect(names(sortWebDAVEntries(entries, 'created', true))).toEqual([
|
||||
'first.epub',
|
||||
'second.epub',
|
||||
]);
|
||||
});
|
||||
|
||||
test('sorts by size', () => {
|
||||
const entries = [file('big.epub', { size: 5000 }), file('small.epub', { size: 10 })];
|
||||
expect(names(sortWebDAVEntries(entries, 'size', true))).toEqual(['small.epub', 'big.epub']);
|
||||
expect(names(sortWebDAVEntries(entries, 'size', false))).toEqual(['big.epub', 'small.epub']);
|
||||
});
|
||||
|
||||
test('entries missing the sort field sort last in both directions, tie-broken by name', () => {
|
||||
const entries = [
|
||||
file('has-date.epub', { lastModified: 'Wed, 01 Jan 2025 00:00:00 GMT' }),
|
||||
file('no-date-b.epub'),
|
||||
file('no-date-a.epub'),
|
||||
];
|
||||
// Ascending: dated entry first, then the undated pair alphabetically.
|
||||
expect(names(sortWebDAVEntries(entries, 'modified', true))).toEqual([
|
||||
'has-date.epub',
|
||||
'no-date-a.epub',
|
||||
'no-date-b.epub',
|
||||
]);
|
||||
// Descending: dated entry still first; undated remain last (not flipped
|
||||
// to the top) and stay name-ordered.
|
||||
expect(names(sortWebDAVEntries(entries, 'modified', false))).toEqual([
|
||||
'has-date.epub',
|
||||
'no-date-a.epub',
|
||||
'no-date-b.epub',
|
||||
]);
|
||||
});
|
||||
|
||||
test('name sort follows the resolved display name when provided', () => {
|
||||
// Under Readest/books the entry name is a content hash; the resolver
|
||||
// maps it to the human title so "sort by name" matches what's shown.
|
||||
const entries = [dir('hashZ'), dir('hashA')];
|
||||
const titles: Record<string, string> = { hashZ: 'Alpha', hashA: 'Zulu' };
|
||||
const getName = (e: WebDAVEntry) => titles[e.name] ?? e.name;
|
||||
expect(names(sortWebDAVEntries(entries, 'name', true, getName))).toEqual(['hashZ', 'hashA']);
|
||||
});
|
||||
|
||||
test('does not mutate the input array', () => {
|
||||
const entries = [file('b.epub'), file('a.epub')];
|
||||
const snapshot = names(entries);
|
||||
sortWebDAVEntries(entries, 'name', true);
|
||||
expect(names(entries)).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterWebDAVEntries', () => {
|
||||
const entries = [file('The Great Gatsby.epub'), file('mobydick.epub'), dir('hash123')];
|
||||
|
||||
test('returns every entry for an empty/whitespace query', () => {
|
||||
expect(filterWebDAVEntries(entries, '')).toHaveLength(3);
|
||||
expect(filterWebDAVEntries(entries, ' ')).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('matches file name case-insensitively as a substring', () => {
|
||||
expect(names(filterWebDAVEntries(entries, 'GREAT'))).toEqual(['The Great Gatsby.epub']);
|
||||
expect(names(filterWebDAVEntries(entries, 'dick'))).toEqual(['mobydick.epub']);
|
||||
});
|
||||
|
||||
test('matches the resolved display title for hashed book directories', () => {
|
||||
const getName = (e: WebDAVEntry) => (e.name === 'hash123' ? 'Crime and Punishment' : e.name);
|
||||
expect(names(filterWebDAVEntries(entries, 'punishment', getName))).toEqual(['hash123']);
|
||||
});
|
||||
|
||||
test('returns an empty array when nothing matches', () => {
|
||||
expect(filterWebDAVEntries(entries, 'zzz-nope')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { listDirectory, type WebDAVConfig } from '@/services/sync/providers/webdav/client';
|
||||
|
||||
/**
|
||||
* Tests for the PROPFIND listing parser, focused on the metadata fields
|
||||
* that drive the browser's sort/search. `getcontentlength` /
|
||||
* `getlastmodified` were already parsed; `creationdate` was added so the
|
||||
* browser can offer "sort by date created". Servers that omit the
|
||||
* property must still produce a valid entry with `created === undefined`.
|
||||
*/
|
||||
|
||||
const ORIGINAL_FETCH = globalThis.fetch;
|
||||
|
||||
const config: WebDAVConfig = {
|
||||
serverUrl: 'https://dav.example.com',
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
};
|
||||
|
||||
const multistatus = (inner: string): string =>
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="DAV:">
|
||||
<D:response>
|
||||
<D:href>/books/</D:href>
|
||||
<D:propstat>
|
||||
<D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
${inner}
|
||||
</D:multistatus>`;
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('listDirectory metadata parsing', () => {
|
||||
test('requests creationdate in the PROPFIND body', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(multistatus(''), { status: 207 }));
|
||||
await listDirectory(config, '/books');
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
expect(init?.method).toBe('PROPFIND');
|
||||
expect(String(init?.body)).toContain('creationdate');
|
||||
});
|
||||
|
||||
test('parses creationdate, getlastmodified and getcontentlength for a file', async () => {
|
||||
const fileResponse = `
|
||||
<D:response>
|
||||
<D:href>/books/novel.epub</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:getcontentlength>1234</D:getcontentlength>
|
||||
<D:getlastmodified>Mon, 15 Jan 2024 10:30:00 GMT</D:getlastmodified>
|
||||
<D:creationdate>2023-12-01T08:00:00Z</D:creationdate>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>`;
|
||||
fetchMock.mockResolvedValueOnce(new Response(multistatus(fileResponse), { status: 207 }));
|
||||
|
||||
const entries = await listDirectory(config, '/books');
|
||||
expect(entries).toHaveLength(1);
|
||||
const entry = entries[0]!;
|
||||
expect(entry.name).toBe('novel.epub');
|
||||
expect(entry.isDirectory).toBe(false);
|
||||
expect(entry.size).toBe(1234);
|
||||
expect(entry.lastModified).toBe('Mon, 15 Jan 2024 10:30:00 GMT');
|
||||
expect(entry.created).toBe('2023-12-01T08:00:00Z');
|
||||
});
|
||||
|
||||
test('leaves created undefined when the server omits creationdate', async () => {
|
||||
const dirResponse = `
|
||||
<D:response>
|
||||
<D:href>/books/sub/</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype><D:collection/></D:resourcetype>
|
||||
<D:getlastmodified>Mon, 15 Jan 2024 10:30:00 GMT</D:getlastmodified>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>`;
|
||||
fetchMock.mockResolvedValueOnce(new Response(multistatus(dirResponse), { status: 207 }));
|
||||
|
||||
const entries = await listDirectory(config, '/books');
|
||||
expect(entries).toHaveLength(1);
|
||||
const entry = entries[0]!;
|
||||
expect(entry.name).toBe('sub');
|
||||
expect(entry.isDirectory).toBe(true);
|
||||
expect(entry.created).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
MdCheck,
|
||||
MdDeleteSweep,
|
||||
MdClose,
|
||||
MdSearch,
|
||||
MdArrowUpward,
|
||||
MdArrowDownward,
|
||||
} from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
@@ -30,15 +33,17 @@ import { buildBasePath, SYNC_BOOKS_DIR } from '@/services/sync/file/layout';
|
||||
import { createWebDAVProvider } from '@/services/sync/providers/webdav/WebDAVProvider';
|
||||
import { deleteRemoteBookDir } from '@/services/sync/file/engine';
|
||||
import { FileSyncError } from '@/services/sync/file/provider';
|
||||
import { WebDAVSettings } from '@/types/settings';
|
||||
import { WebDAVBrowseSortByType, WebDAVSettings } from '@/types/settings';
|
||||
import { Book } from '@/types/book';
|
||||
import { SettingLabel } from '../primitives';
|
||||
import {
|
||||
filterWebDAVEntries,
|
||||
formatLastModified,
|
||||
formatShortHash,
|
||||
formatSize,
|
||||
getEntryIcon,
|
||||
isSupportedBookExt,
|
||||
sortWebDAVEntries,
|
||||
} from './webdavBrowseUtils';
|
||||
|
||||
/**
|
||||
@@ -50,9 +55,15 @@ import {
|
||||
*/
|
||||
export interface WebDAVBrowsePaneProps {
|
||||
settings: WebDAVSettings;
|
||||
/**
|
||||
* Persist a partial WebDAV settings patch. Used to remember the sort
|
||||
* field + direction across sessions. Optional so the pane still renders
|
||||
* (sort/search just stay session-local) when a caller omits it.
|
||||
*/
|
||||
onUpdateSettings?: (patch: Partial<WebDAVSettings>) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({ settings }) => {
|
||||
const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({ settings, onUpdateSettings }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { user } = useAuth();
|
||||
@@ -95,6 +106,15 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({ settings }) => {
|
||||
Record<string, 'downloading' | 'done' | 'error'>
|
||||
>({});
|
||||
|
||||
// —— Sort & filter ——
|
||||
// Free-text filter over the current folder (transient: reset on every
|
||||
// navigation/refresh below). Sort field + direction seed from the
|
||||
// persisted settings — default name/ascending reproduces the legacy
|
||||
// directories-first alphabetical order — and write back on change.
|
||||
const [query, setQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState<WebDAVBrowseSortByType>(settings.browseSortBy ?? 'name');
|
||||
const [ascending, setAscending] = useState<boolean>(settings.browseSortAscending ?? true);
|
||||
|
||||
// —— Cleanup mode ——
|
||||
// GC surface for remote orphans (per-hash dirs whose local Book
|
||||
// has `deletedAt` set). When on, the listing is pinned to
|
||||
@@ -148,13 +168,24 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({ settings }) => {
|
||||
return !!bookByHash.get(entry.name)?.deletedAt;
|
||||
};
|
||||
|
||||
// Cleanup mode shows only the orphan rows; the toolbar uses the
|
||||
// count too, so materialise the filter once here.
|
||||
const displayedEntries = useMemo(
|
||||
() => (cleanupMode ? entries.filter(isEntryLocallyDeleted) : entries),
|
||||
// The listing the UI renders: cleanup mode pins to orphan rows (search
|
||||
// is hidden there); normal mode applies the free-text filter. Both then
|
||||
// run through the chosen sort. Sort/search resolve a hashed book dir to
|
||||
// its library title so they operate on what the user actually sees.
|
||||
const displayedEntries = useMemo(() => {
|
||||
const resolveDisplayName = (entry: WebDAVEntry): string => {
|
||||
if (entry.isDirectory && currentPath === booksDirPath) {
|
||||
const matched = bookByHash.get(entry.name);
|
||||
if (matched) return matched.title || entry.name;
|
||||
}
|
||||
return entry.name;
|
||||
};
|
||||
const base = cleanupMode
|
||||
? entries.filter(isEntryLocallyDeleted)
|
||||
: filterWebDAVEntries(entries, query, resolveDisplayName);
|
||||
return sortWebDAVEntries(base, sortBy, ascending, resolveDisplayName);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[entries, cleanupMode, bookByHash, currentPath, booksDirPath],
|
||||
);
|
||||
}, [entries, cleanupMode, bookByHash, currentPath, booksDirPath, query, sortBy, ascending]);
|
||||
|
||||
/**
|
||||
* Sequentially DELETE the selected per-hash directories from the
|
||||
@@ -301,8 +332,11 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({ settings }) => {
|
||||
if (!currentPath) return;
|
||||
let cancelled = false;
|
||||
// Reset per-entry download status on every (re)load so stale
|
||||
// "done" badges don't carry across folders.
|
||||
// "done" badges don't carry across folders. The filter is
|
||||
// per-folder too — clear it so a query typed in one directory
|
||||
// doesn't silently hide rows in the next.
|
||||
setDownloadStatus({});
|
||||
setQuery('');
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
@@ -353,6 +387,18 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({ settings }) => {
|
||||
setReloadTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handleSortByChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const next = e.target.value as WebDAVBrowseSortByType;
|
||||
setSortBy(next);
|
||||
void onUpdateSettings?.({ browseSortBy: next });
|
||||
};
|
||||
|
||||
const handleToggleDirection = () => {
|
||||
const next = !ascending;
|
||||
setAscending(next);
|
||||
void onUpdateSettings?.({ browseSortAscending: next });
|
||||
};
|
||||
|
||||
/**
|
||||
* Download a single remote file and ingest it into the library.
|
||||
* Streams via tauriDownload to avoid the WebView IPC limit on
|
||||
@@ -495,6 +541,67 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({ settings }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort + filter controls. Normal browse only — cleanup keeps its
|
||||
own focused toolbar. Gated on a non-empty listing so empty /
|
||||
unloaded folders stay uncluttered; `entries` is the pre-filter
|
||||
length, so the row stays put (and the box clearable) even when a
|
||||
query matches nothing. */}
|
||||
{!cleanupMode && !loadError && entries.length > 0 && (
|
||||
<div className='flex items-center gap-2 px-1'>
|
||||
<div className='eink-bordered bg-base-100 flex h-8 min-w-0 flex-1 items-center rounded-lg'>
|
||||
<MdSearch className='text-base-content/50 ms-2 h-4 w-4 flex-shrink-0' />
|
||||
<input
|
||||
type='text'
|
||||
value={query}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
// Keep keystrokes (Esc/Backspace) from bubbling to the
|
||||
// Settings dialog's key handlers while typing a filter.
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder={_('Filter')}
|
||||
aria-label={_('Filter')}
|
||||
className='min-w-0 flex-1 bg-transparent px-2 text-sm focus:outline-none'
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setQuery('')}
|
||||
className='btn btn-ghost h-8 min-h-8 w-8 flex-shrink-0 rounded-none rounded-e-lg p-0'
|
||||
title={_('Clear')}
|
||||
aria-label={_('Clear')}
|
||||
>
|
||||
<MdClose className='text-base-content/50 h-3.5 w-3.5' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={handleSortByChange}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
aria-label={_('Sort by')}
|
||||
className='select select-bordered select-sm eink-bordered bg-base-100 text-base-content h-8 min-h-8 flex-shrink-0'
|
||||
>
|
||||
<option value='name'>{_('Name')}</option>
|
||||
<option value='modified'>{_('Date modified')}</option>
|
||||
<option value='created'>{_('Date created')}</option>
|
||||
<option value='size'>{_('Size')}</option>
|
||||
</select>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleToggleDirection}
|
||||
className='btn btn-ghost btn-sm eink-bordered h-8 min-h-8 w-8 flex-shrink-0 px-0'
|
||||
title={ascending ? _('Sort ascending') : _('Sort descending')}
|
||||
aria-label={ascending ? _('Sort ascending') : _('Sort descending')}
|
||||
>
|
||||
{ascending ? (
|
||||
<MdArrowUpward className='h-4 w-4' />
|
||||
) : (
|
||||
<MdArrowDownward className='h-4 w-4' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
|
||||
{isLoading ? (
|
||||
<div className='flex min-h-32 items-center justify-center py-8'>
|
||||
@@ -534,6 +641,11 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({ settings }) => {
|
||||
const rowTitle = isLocallyDeleted
|
||||
? _('Deleted locally · still on server')
|
||||
: undefined;
|
||||
// Surface whichever date the active sort orders by, so a
|
||||
// "Date created" sort is legible at a glance; every other
|
||||
// sort keeps showing the modified time. Undefined (server
|
||||
// didn't report it) simply omits the date from the row.
|
||||
const activeDateRaw = sortBy === 'created' ? entry.created : entry.lastModified;
|
||||
return (
|
||||
<li key={entry.path}>
|
||||
<div
|
||||
@@ -623,7 +735,7 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({ settings }) => {
|
||||
so directories don't render an empty span. */}
|
||||
{(matchedBook ||
|
||||
(!entry.isDirectory && typeof entry.size === 'number') ||
|
||||
entry.lastModified) && (
|
||||
activeDateRaw) && (
|
||||
<span className='text-base-content/60 flex flex-wrap gap-x-2 text-[0.75em]'>
|
||||
{matchedBook && (
|
||||
<span title={entry.name} className='font-mono'>
|
||||
@@ -633,10 +745,8 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({ settings }) => {
|
||||
{!entry.isDirectory && typeof entry.size === 'number' && (
|
||||
<span>{formatSize(entry.size)}</span>
|
||||
)}
|
||||
{entry.lastModified && (
|
||||
<span title={entry.lastModified}>
|
||||
{formatLastModified(entry.lastModified)}
|
||||
</span>
|
||||
{activeDateRaw && (
|
||||
<span title={activeDateRaw}>{formatLastModified(activeDateRaw)}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -392,7 +392,7 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
</SettingsRow>
|
||||
</BoxedList>
|
||||
|
||||
<WebDAVBrowsePane settings={stored} />
|
||||
<WebDAVBrowsePane settings={stored} onUpdateSettings={persistWebdav} />
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
|
||||
@@ -15,6 +15,8 @@ import { LuBookImage } from 'react-icons/lu';
|
||||
import { MdInsertDriveFile } from 'react-icons/md';
|
||||
import React from 'react';
|
||||
import { SUPPORTED_BOOK_EXTS } from '@/services/constants';
|
||||
import type { WebDAVEntry } from '@/services/sync/providers/webdav/client';
|
||||
import type { WebDAVBrowseSortByType } from '@/types/settings';
|
||||
|
||||
/**
|
||||
* Pure presentational helpers for WebDAVBrowsePane. Lives apart from the
|
||||
@@ -137,3 +139,106 @@ export const getEntryIcon = (filename: string): React.ComponentType<{ className?
|
||||
return MdInsertDriveFile;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the comparable/searchable display name for an entry. Defaults
|
||||
* to the raw `entry.name`; the pane passes a resolver that maps a
|
||||
* per-book hash directory to its local library title so sort + search
|
||||
* operate on what the user actually sees.
|
||||
*/
|
||||
export type WebDAVEntryNameResolver = (entry: WebDAVEntry) => string;
|
||||
|
||||
const resolveName = (entry: WebDAVEntry, getName: WebDAVEntryNameResolver): string =>
|
||||
getName(entry) || entry.name;
|
||||
|
||||
/**
|
||||
* Parse a WebDAV timestamp to epoch milliseconds. `getlastmodified` is
|
||||
* RFC 1123 and `creationdate` is ISO 8601; `Date.parse` handles both.
|
||||
* Missing or unparseable values return `null` so a date sort can sink
|
||||
* them to the bottom rather than scattering "Invalid Date" rows.
|
||||
*/
|
||||
const parseTimestamp = (raw: string | undefined): number | null => {
|
||||
if (!raw) return null;
|
||||
const ts = Date.parse(raw);
|
||||
return Number.isFinite(ts) ? ts : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare two optional numbers for sorting. A `null` (unknown size /
|
||||
* undated entry) always sinks below known values — in BOTH directions —
|
||||
* so toggling ascending/descending never lifts "unknown" to the top.
|
||||
*/
|
||||
const compareNullableNumber = (a: number | null, b: number | null, ascending: boolean): number => {
|
||||
if (a === null && b === null) return 0;
|
||||
if (a === null) return 1;
|
||||
if (b === null) return -1;
|
||||
const diff = a - b;
|
||||
return ascending ? diff : -diff;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sort a directory listing for display. Directories are always grouped
|
||||
* before files (conventional file-browser behaviour); within each group
|
||||
* entries are ordered by the chosen field and direction. Ties — and the
|
||||
* "unknown field" rows that sink to the bottom of date/size sorts — fall
|
||||
* back to ascending display name so the order stays stable and
|
||||
* predictable. Pure: returns a new array, never mutates the input.
|
||||
*/
|
||||
export const sortWebDAVEntries = (
|
||||
entries: WebDAVEntry[],
|
||||
sortBy: WebDAVBrowseSortByType,
|
||||
ascending: boolean,
|
||||
getName: WebDAVEntryNameResolver = (e) => e.name,
|
||||
): WebDAVEntry[] => {
|
||||
const byNameAsc = (a: WebDAVEntry, b: WebDAVEntry): number =>
|
||||
resolveName(a, getName).localeCompare(resolveName(b, getName), undefined, {
|
||||
sensitivity: 'base',
|
||||
});
|
||||
|
||||
const compareField = (a: WebDAVEntry, b: WebDAVEntry): number => {
|
||||
switch (sortBy) {
|
||||
case 'modified':
|
||||
return compareNullableNumber(
|
||||
parseTimestamp(a.lastModified),
|
||||
parseTimestamp(b.lastModified),
|
||||
ascending,
|
||||
);
|
||||
case 'created':
|
||||
return compareNullableNumber(
|
||||
parseTimestamp(a.created),
|
||||
parseTimestamp(b.created),
|
||||
ascending,
|
||||
);
|
||||
case 'size':
|
||||
return compareNullableNumber(a.size ?? null, b.size ?? null, ascending);
|
||||
case 'name':
|
||||
default:
|
||||
return ascending ? byNameAsc(a, b) : -byNameAsc(a, b);
|
||||
}
|
||||
};
|
||||
|
||||
return [...entries].sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
const primary = compareField(a, b);
|
||||
return primary !== 0 ? primary : byNameAsc(a, b);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter a listing by a free-text query, matching case-insensitively as a
|
||||
* substring against both the raw entry name and its resolved display name
|
||||
* (so a hashed book directory matches on its library title). An empty or
|
||||
* whitespace-only query returns the input unchanged.
|
||||
*/
|
||||
export const filterWebDAVEntries = (
|
||||
entries: WebDAVEntry[],
|
||||
query: string,
|
||||
getName: WebDAVEntryNameResolver = (e) => e.name,
|
||||
): WebDAVEntry[] => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return entries;
|
||||
return entries.filter((entry) => {
|
||||
if (entry.name.toLowerCase().includes(q)) return true;
|
||||
return resolveName(entry, getName).toLowerCase().includes(q);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -30,6 +30,13 @@ export interface WebDAVEntry {
|
||||
size?: number;
|
||||
/** Server-provided modification timestamp, if any. */
|
||||
lastModified?: string;
|
||||
/**
|
||||
* Server-provided creation timestamp (`<creationdate>`), if any. Many
|
||||
* servers (NextCloud, sabre/dav, Synology) report it; some omit it, so
|
||||
* callers must treat `undefined` as "unknown" and not assume it equals
|
||||
* {@link lastModified}.
|
||||
*/
|
||||
created?: string;
|
||||
}
|
||||
|
||||
export interface WebDAVConfig {
|
||||
@@ -63,6 +70,7 @@ const PROPFIND_BODY = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:resourcetype/>
|
||||
<D:getcontentlength/>
|
||||
<D:getlastmodified/>
|
||||
<D:creationdate/>
|
||||
</D:prop>
|
||||
</D:propfind>`;
|
||||
|
||||
@@ -355,12 +363,14 @@ export const listDirectory = async (
|
||||
const name = segments[segments.length - 1] ?? trimmedPath;
|
||||
const sizeStr = extractTagText(block, 'getcontentlength');
|
||||
const lastModified = extractTagText(block, 'getlastmodified');
|
||||
const created = extractTagText(block, 'creationdate');
|
||||
entries.push({
|
||||
name,
|
||||
path: trimmedPath,
|
||||
isDirectory: isDir,
|
||||
size: sizeStr && !isDir ? Number(sizeStr) : undefined,
|
||||
lastModified,
|
||||
created,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -104,12 +104,25 @@ export interface HardcoverSettings {
|
||||
autoSync?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort field for the WebDAV browser listing. 'name' reproduces the
|
||||
* legacy directories-first/alphabetical default; the date fields drive
|
||||
* the "pull up recent books" use case ('created' relies on the server
|
||||
* reporting `<creationdate>`, which not all do). 'size' orders files.
|
||||
*/
|
||||
export type WebDAVBrowseSortByType = 'name' | 'modified' | 'created' | 'size';
|
||||
|
||||
export interface WebDAVSettings {
|
||||
enabled: boolean;
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
rootPath: string;
|
||||
// Browser sort preference, persisted so a chosen "recent first" order
|
||||
// survives across sessions. Both optional: absent => name/ascending,
|
||||
// matching the pre-feature default (no migration needed).
|
||||
browseSortBy?: WebDAVBrowseSortByType;
|
||||
browseSortAscending?: boolean;
|
||||
// Sync sub-toggles. WebDAV sync runs as a parallel channel alongside the
|
||||
// native cloud sync, KOSync, Readwise, and Hardcover; each sub-toggle
|
||||
// gates a category independently so a user can e.g. mirror progress to
|
||||
|
||||
Reference in New Issue
Block a user