feat(webdav): sort and filter the WebDAV browser (#4724) (#4786)

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:
Huang Xin
2026-06-25 16:43:57 +08:00
committed by GitHub
parent fb943987eb
commit 13e0fb814f
39 changed files with 727 additions and 47 deletions
@@ -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,
});
}
+13
View File
@@ -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