forked from akai/readest
feat(library): add secondary "Then by" sort with smart defaults (#4347)
Adds a primary/secondary sort pair so users can group by author and have each author's books drilled-in list sort by series, without touching the sort menu each time. Closes #4307. - New "Then by..." picker in the library view menu (None + same keys as primary). Secondary acts as tiebreaker for the global sort, and as the in-group ordering when the user drills into a non-series group. - Smart defaults derived from groupBy, surfaced as "(Auto)" in the menu and resolved at sort time so user picks are never overwritten: - groupBy=Author + secondary=none -> Series - groupBy=Series + librarySortByAuto -> primary becomes Series - librarySortByAuto flips off as soon as the user makes any explicit primary pick; subsequent groupBy changes then respect that choice. Settings: librarySortBy2, librarySortByAuto. URL: ?sort2 syncs the secondary; auto is settings-only (no URL representation). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1778,5 +1778,6 @@
|
||||
"No book content or reading data": "بدون محتوى كتب أو بيانات قراءة",
|
||||
"Share anonymous data": "مشاركة بيانات مجهولة",
|
||||
"Not now": "ليس الآن",
|
||||
"You can change this anytime in Settings.": "يمكنك تغيير هذا في أي وقت من الإعدادات."
|
||||
"You can change this anytime in Settings.": "يمكنك تغيير هذا في أي وقت من الإعدادات.",
|
||||
"Then by...": "ثم حسب..."
|
||||
}
|
||||
|
||||
@@ -1654,5 +1654,6 @@
|
||||
"No book content or reading data": "কোনো বইয়ের বিষয়বস্তু বা পড়ার ডেটা নেই",
|
||||
"Share anonymous data": "বেনামি ডেটা শেয়ার করুন",
|
||||
"Not now": "এখন নয়",
|
||||
"You can change this anytime in Settings.": "আপনি যেকোনো সময় সেটিংসে এটি পরিবর্তন করতে পারেন।"
|
||||
"You can change this anytime in Settings.": "আপনি যেকোনো সময় সেটিংসে এটি পরিবর্তন করতে পারেন।",
|
||||
"Then by...": "তারপর..."
|
||||
}
|
||||
|
||||
@@ -1623,5 +1623,6 @@
|
||||
"No book content or reading data": "དཔེ་ཆའི་ནང་དོན་ནམ་ཀློག་པའི་གནས་ཚུལ་མེད།",
|
||||
"Share anonymous data": "མིང་མེད་གནས་ཚུལ་མཉམ་སྤྱོད།",
|
||||
"Not now": "ད་ལྟ་མིན།",
|
||||
"You can change this anytime in Settings.": "ཁྱེད་ཀྱིས་འདི་སྒྲིག་འགོད་ནང་དུས་ནམ་ཡང་བཟོ་བཅོས་གནང་ཆོག།"
|
||||
"You can change this anytime in Settings.": "ཁྱེད་ཀྱིས་འདི་སྒྲིག་འགོད་ནང་དུས་ནམ་ཡང་བཟོ་བཅོས་གནང་ཆོག།",
|
||||
"Then by...": "དེ་ནས..."
|
||||
}
|
||||
|
||||
@@ -1654,5 +1654,6 @@
|
||||
"No book content or reading data": "Keine Buchinhalte oder Lesedaten",
|
||||
"Share anonymous data": "Anonyme Daten teilen",
|
||||
"Not now": "Nicht jetzt",
|
||||
"You can change this anytime in Settings.": "Du kannst dies jederzeit in den Einstellungen ändern."
|
||||
"You can change this anytime in Settings.": "Du kannst dies jederzeit in den Einstellungen ändern.",
|
||||
"Then by...": "Dann nach..."
|
||||
}
|
||||
|
||||
@@ -1654,5 +1654,6 @@
|
||||
"No book content or reading data": "Κανένα περιεχόμενο βιβλίου ή δεδομένα ανάγνωσης",
|
||||
"Share anonymous data": "Κοινοποίηση ανώνυμων δεδομένων",
|
||||
"Not now": "Όχι τώρα",
|
||||
"You can change this anytime in Settings.": "Μπορείτε να το αλλάξετε ανά πάσα στιγμή από τις Ρυθμίσεις."
|
||||
"You can change this anytime in Settings.": "Μπορείτε να το αλλάξετε ανά πάσα στιγμή από τις Ρυθμίσεις.",
|
||||
"Then by...": "Στη συνέχεια κατά..."
|
||||
}
|
||||
|
||||
@@ -1685,5 +1685,6 @@
|
||||
"No book content or reading data": "Sin contenido de libros ni datos de lectura",
|
||||
"Share anonymous data": "Compartir datos anónimos",
|
||||
"Not now": "Ahora no",
|
||||
"You can change this anytime in Settings.": "Puedes cambiar esto en cualquier momento en Ajustes."
|
||||
"You can change this anytime in Settings.": "Puedes cambiar esto en cualquier momento en Ajustes.",
|
||||
"Then by...": "Después por..."
|
||||
}
|
||||
|
||||
@@ -1654,5 +1654,6 @@
|
||||
"No book content or reading data": "بدون محتوای کتاب یا دادههای مطالعه",
|
||||
"Share anonymous data": "اشتراک داده ناشناس",
|
||||
"Not now": "اکنون نه",
|
||||
"You can change this anytime in Settings.": "میتوانید این تنظیم را هر زمان از تنظیمات تغییر دهید."
|
||||
"You can change this anytime in Settings.": "میتوانید این تنظیم را هر زمان از تنظیمات تغییر دهید.",
|
||||
"Then by...": "سپس بر اساس..."
|
||||
}
|
||||
|
||||
@@ -1685,5 +1685,6 @@
|
||||
"No book content or reading data": "Aucun contenu de livre ni donnée de lecture",
|
||||
"Share anonymous data": "Partager les données anonymes",
|
||||
"Not now": "Pas maintenant",
|
||||
"You can change this anytime in Settings.": "Vous pouvez modifier ce choix à tout moment dans les Paramètres."
|
||||
"You can change this anytime in Settings.": "Vous pouvez modifier ce choix à tout moment dans les Paramètres.",
|
||||
"Then by...": "Puis par..."
|
||||
}
|
||||
|
||||
@@ -1685,5 +1685,6 @@
|
||||
"No book content or reading data": "ללא תוכן ספרים או נתוני קריאה",
|
||||
"Share anonymous data": "שתף נתונים אנונימיים",
|
||||
"Not now": "לא עכשיו",
|
||||
"You can change this anytime in Settings.": "אפשר לשנות זאת בכל עת בהגדרות."
|
||||
"You can change this anytime in Settings.": "אפשר לשנות זאת בכל עת בהגדרות.",
|
||||
"Then by...": "ואז לפי..."
|
||||
}
|
||||
|
||||
@@ -1654,5 +1654,6 @@
|
||||
"No book content or reading data": "कोई पुस्तक सामग्री या पठन डेटा नहीं",
|
||||
"Share anonymous data": "गुमनाम डेटा साझा करें",
|
||||
"Not now": "अभी नहीं",
|
||||
"You can change this anytime in Settings.": "आप इसे कभी भी सेटिंग्स में बदल सकते हैं।"
|
||||
"You can change this anytime in Settings.": "आप इसे कभी भी सेटिंग्स में बदल सकते हैं।",
|
||||
"Then by...": "फिर इसके अनुसार..."
|
||||
}
|
||||
|
||||
@@ -1654,5 +1654,6 @@
|
||||
"No book content or reading data": "Nincs könyvtartalom vagy olvasási adat",
|
||||
"Share anonymous data": "Anonim adatok megosztása",
|
||||
"Not now": "Most nem",
|
||||
"You can change this anytime in Settings.": "Ezt bármikor megváltoztathatod a Beállításokban."
|
||||
"You can change this anytime in Settings.": "Ezt bármikor megváltoztathatod a Beállításokban.",
|
||||
"Then by...": "Másodlagosan..."
|
||||
}
|
||||
|
||||
@@ -1623,5 +1623,6 @@
|
||||
"No book content or reading data": "Tidak ada konten buku atau data baca",
|
||||
"Share anonymous data": "Bagikan data anonim",
|
||||
"Not now": "Nanti saja",
|
||||
"You can change this anytime in Settings.": "Anda dapat mengubahnya kapan saja di Pengaturan."
|
||||
"You can change this anytime in Settings.": "Anda dapat mengubahnya kapan saja di Pengaturan.",
|
||||
"Then by...": "Lalu berdasarkan..."
|
||||
}
|
||||
|
||||
@@ -1685,5 +1685,6 @@
|
||||
"No book content or reading data": "Nessun contenuto dei libri o dato di lettura",
|
||||
"Share anonymous data": "Condividi dati anonimi",
|
||||
"Not now": "Non ora",
|
||||
"You can change this anytime in Settings.": "Puoi modificarlo in qualsiasi momento dalle Impostazioni."
|
||||
"You can change this anytime in Settings.": "Puoi modificarlo in qualsiasi momento dalle Impostazioni.",
|
||||
"Then by...": "Poi per..."
|
||||
}
|
||||
|
||||
@@ -1623,5 +1623,6 @@
|
||||
"No book content or reading data": "書籍の内容や読書データは含まれません",
|
||||
"Share anonymous data": "匿名データを共有",
|
||||
"Not now": "後で",
|
||||
"You can change this anytime in Settings.": "この設定はいつでも設定画面から変更できます。"
|
||||
"You can change this anytime in Settings.": "この設定はいつでも設定画面から変更できます。",
|
||||
"Then by...": "次に並び替え..."
|
||||
}
|
||||
|
||||
@@ -1623,5 +1623,6 @@
|
||||
"No book content or reading data": "도서 내용이나 독서 데이터 없음",
|
||||
"Share anonymous data": "익명 데이터 공유",
|
||||
"Not now": "나중에",
|
||||
"You can change this anytime in Settings.": "설정에서 언제든지 변경할 수 있습니다."
|
||||
"You can change this anytime in Settings.": "설정에서 언제든지 변경할 수 있습니다.",
|
||||
"Then by...": "다음 기준..."
|
||||
}
|
||||
|
||||
@@ -1623,5 +1623,6 @@
|
||||
"No book content or reading data": "Tiada kandungan buku atau data bacaan",
|
||||
"Share anonymous data": "Kongsi data tanpa nama",
|
||||
"Not now": "Bukan sekarang",
|
||||
"You can change this anytime in Settings.": "Anda boleh menukar ini pada bila-bila masa dalam Tetapan."
|
||||
"You can change this anytime in Settings.": "Anda boleh menukar ini pada bila-bila masa dalam Tetapan.",
|
||||
"Then by...": "Kemudian mengikut..."
|
||||
}
|
||||
|
||||
@@ -1654,5 +1654,6 @@
|
||||
"No book content or reading data": "Geen boekinhoud of leesgegevens",
|
||||
"Share anonymous data": "Anonieme gegevens delen",
|
||||
"Not now": "Niet nu",
|
||||
"You can change this anytime in Settings.": "Je kunt dit op elk moment wijzigen in Instellingen."
|
||||
"You can change this anytime in Settings.": "Je kunt dit op elk moment wijzigen in Instellingen.",
|
||||
"Then by...": "Vervolgens op..."
|
||||
}
|
||||
|
||||
@@ -1716,5 +1716,6 @@
|
||||
"No book content or reading data": "Bez treści książek i danych o czytaniu",
|
||||
"Share anonymous data": "Udostępnij anonimowe dane",
|
||||
"Not now": "Nie teraz",
|
||||
"You can change this anytime in Settings.": "Możesz to zmienić w dowolnej chwili w Ustawieniach."
|
||||
"You can change this anytime in Settings.": "Możesz to zmienić w dowolnej chwili w Ustawieniach.",
|
||||
"Then by...": "Następnie..."
|
||||
}
|
||||
|
||||
@@ -1685,5 +1685,6 @@
|
||||
"No book content or reading data": "Sem conteúdo de livros ou dados de leitura",
|
||||
"Share anonymous data": "Compartilhar dados anônimos",
|
||||
"Not now": "Agora não",
|
||||
"You can change this anytime in Settings.": "Você pode alterar isso a qualquer momento em Configurações."
|
||||
"You can change this anytime in Settings.": "Você pode alterar isso a qualquer momento em Configurações.",
|
||||
"Then by...": "Depois por..."
|
||||
}
|
||||
|
||||
@@ -1685,5 +1685,6 @@
|
||||
"No book content or reading data": "Sem conteúdo de livros ou dados de leitura",
|
||||
"Share anonymous data": "Partilhar dados anónimos",
|
||||
"Not now": "Agora não",
|
||||
"You can change this anytime in Settings.": "Pode alterar isto a qualquer momento nas Definições."
|
||||
"You can change this anytime in Settings.": "Pode alterar isto a qualquer momento nas Definições.",
|
||||
"Then by...": "Depois por..."
|
||||
}
|
||||
|
||||
@@ -1685,5 +1685,6 @@
|
||||
"No book content or reading data": "Fără conținut din cărți sau date despre citire",
|
||||
"Share anonymous data": "Partajează date anonime",
|
||||
"Not now": "Nu acum",
|
||||
"You can change this anytime in Settings.": "Poți modifica această opțiune oricând din Setări."
|
||||
"You can change this anytime in Settings.": "Poți modifica această opțiune oricând din Setări.",
|
||||
"Then by...": "Apoi după..."
|
||||
}
|
||||
|
||||
@@ -1716,5 +1716,6 @@
|
||||
"No book content or reading data": "Без содержимого книг и данных о чтении",
|
||||
"Share anonymous data": "Поделиться анонимными данными",
|
||||
"Not now": "Не сейчас",
|
||||
"You can change this anytime in Settings.": "Это можно изменить в любое время в Настройках."
|
||||
"You can change this anytime in Settings.": "Это можно изменить в любое время в Настройках.",
|
||||
"Then by...": "Затем по..."
|
||||
}
|
||||
|
||||
@@ -1654,5 +1654,6 @@
|
||||
"No book content or reading data": "පොත් අන්තර්ගතය හෝ කියවීමේ දත්ත නැත",
|
||||
"Share anonymous data": "අනාමික දත්ත බෙදාගන්න",
|
||||
"Not now": "දැන් නොවේ",
|
||||
"You can change this anytime in Settings.": "ඔබට මෙය ඕනෑම විටක සැකසුම් තුළ වෙනස් කළ හැකිය."
|
||||
"You can change this anytime in Settings.": "ඔබට මෙය ඕනෑම විටක සැකසුම් තුළ වෙනස් කළ හැකිය.",
|
||||
"Then by...": "ඉන්පසු..."
|
||||
}
|
||||
|
||||
@@ -1716,5 +1716,6 @@
|
||||
"No book content or reading data": "Brez vsebine knjig ali bralnih podatkov",
|
||||
"Share anonymous data": "Deli anonimne podatke",
|
||||
"Not now": "Ne zdaj",
|
||||
"You can change this anytime in Settings.": "To lahko kadar koli spremenite v Nastavitvah."
|
||||
"You can change this anytime in Settings.": "To lahko kadar koli spremenite v Nastavitvah.",
|
||||
"Then by...": "Nato po..."
|
||||
}
|
||||
|
||||
@@ -1654,5 +1654,6 @@
|
||||
"No book content or reading data": "Inget bokinnehåll eller läsdata",
|
||||
"Share anonymous data": "Dela anonyma data",
|
||||
"Not now": "Inte nu",
|
||||
"You can change this anytime in Settings.": "Du kan ändra detta när som helst i Inställningar."
|
||||
"You can change this anytime in Settings.": "Du kan ändra detta när som helst i Inställningar.",
|
||||
"Then by...": "Sedan efter..."
|
||||
}
|
||||
|
||||
@@ -1654,5 +1654,6 @@
|
||||
"No book content or reading data": "புத்தக உள்ளடக்கம் அல்லது வாசிப்புத் தரவு இல்லை",
|
||||
"Share anonymous data": "அநாமதேய தரவைப் பகிர்",
|
||||
"Not now": "இப்போது வேண்டாம்",
|
||||
"You can change this anytime in Settings.": "அமைப்புகளில் எப்போது வேண்டுமானாலும் இதை மாற்றலாம்."
|
||||
"You can change this anytime in Settings.": "அமைப்புகளில் எப்போது வேண்டுமானாலும் இதை மாற்றலாம்.",
|
||||
"Then by...": "பிறகு..."
|
||||
}
|
||||
|
||||
@@ -1623,5 +1623,6 @@
|
||||
"No book content or reading data": "ไม่มีเนื้อหาหนังสือหรือข้อมูลการอ่าน",
|
||||
"Share anonymous data": "แชร์ข้อมูลแบบไม่ระบุตัวตน",
|
||||
"Not now": "ไม่ใช่ตอนนี้",
|
||||
"You can change this anytime in Settings.": "คุณสามารถเปลี่ยนแปลงสิ่งนี้ได้ทุกเมื่อในการตั้งค่า"
|
||||
"You can change this anytime in Settings.": "คุณสามารถเปลี่ยนแปลงสิ่งนี้ได้ทุกเมื่อในการตั้งค่า",
|
||||
"Then by...": "จากนั้น..."
|
||||
}
|
||||
|
||||
@@ -1654,5 +1654,6 @@
|
||||
"No book content or reading data": "Kitap içeriği veya okuma verisi yok",
|
||||
"Share anonymous data": "Anonim veri paylaş",
|
||||
"Not now": "Şimdi değil",
|
||||
"You can change this anytime in Settings.": "Bunu istediğiniz zaman Ayarlar'dan değiştirebilirsiniz."
|
||||
"You can change this anytime in Settings.": "Bunu istediğiniz zaman Ayarlar'dan değiştirebilirsiniz.",
|
||||
"Then by...": "Sonra..."
|
||||
}
|
||||
|
||||
@@ -1716,5 +1716,6 @@
|
||||
"No book content or reading data": "Жодного вмісту книг чи даних читання",
|
||||
"Share anonymous data": "Поділитися анонімними даними",
|
||||
"Not now": "Не зараз",
|
||||
"You can change this anytime in Settings.": "Ви можете змінити це будь-коли в Налаштуваннях."
|
||||
"You can change this anytime in Settings.": "Ви можете змінити це будь-коли в Налаштуваннях.",
|
||||
"Then by...": "Потім за..."
|
||||
}
|
||||
|
||||
@@ -1654,5 +1654,6 @@
|
||||
"No book content or reading data": "Kitob mazmuni yoki oʻqish maʼlumotlari yoʻq",
|
||||
"Share anonymous data": "Anonim maʼlumotlarni ulashish",
|
||||
"Not now": "Hozir emas",
|
||||
"You can change this anytime in Settings.": "Buni xohlagan vaqtda Sozlamalarda oʻzgartirishingiz mumkin."
|
||||
"You can change this anytime in Settings.": "Buni xohlagan vaqtda Sozlamalarda oʻzgartirishingiz mumkin.",
|
||||
"Then by...": "Keyin..."
|
||||
}
|
||||
|
||||
@@ -1623,5 +1623,6 @@
|
||||
"No book content or reading data": "Không có nội dung sách hoặc dữ liệu đọc",
|
||||
"Share anonymous data": "Chia sẻ dữ liệu ẩn danh",
|
||||
"Not now": "Để sau",
|
||||
"You can change this anytime in Settings.": "Bạn có thể thay đổi tùy chọn này bất cứ lúc nào trong Cài đặt."
|
||||
"You can change this anytime in Settings.": "Bạn có thể thay đổi tùy chọn này bất cứ lúc nào trong Cài đặt.",
|
||||
"Then by...": "Sau đó theo..."
|
||||
}
|
||||
|
||||
@@ -1623,5 +1623,6 @@
|
||||
"No book content or reading data": "不包含书籍内容或阅读数据",
|
||||
"Share anonymous data": "分享匿名数据",
|
||||
"Not now": "暂不",
|
||||
"You can change this anytime in Settings.": "您可以随时在“设置”中更改此选项。"
|
||||
"You can change this anytime in Settings.": "您可以随时在“设置”中更改此选项。",
|
||||
"Then by...": "其次按..."
|
||||
}
|
||||
|
||||
@@ -1623,5 +1623,6 @@
|
||||
"No book content or reading data": "不包含書籍內容或閱讀資料",
|
||||
"Share anonymous data": "分享匿名資料",
|
||||
"Not now": "暫不",
|
||||
"You can change this anytime in Settings.": "您可以隨時在「設定」中變更此項目。"
|
||||
"You can change this anytime in Settings.": "您可以隨時在「設定」中變更此項目。",
|
||||
"Then by...": "其次按..."
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
getGroupSortValue,
|
||||
createBookSorter,
|
||||
ensureLibrarySortByType,
|
||||
ensureLibrarySecondarySortByType,
|
||||
ensureLibraryGroupByType,
|
||||
resolveEffectivePrimarySort,
|
||||
resolveEffectiveSecondarySort,
|
||||
findGroupById,
|
||||
getGroupDisplayName,
|
||||
expandBookshelfSelection,
|
||||
@@ -1114,3 +1117,179 @@ describe('buildGroupNameUpdatedAt', () => {
|
||||
expect(groups.map((g) => g.name)).toEqual(['Newest', 'Newer', 'Old']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('secondary sort - createBookSorter tiebreaker', () => {
|
||||
it('uses secondary key as tiebreaker when primary keys are equal', () => {
|
||||
const books = [
|
||||
createMockBook({ hash: 'a', title: 'Zebra', author: 'Same Author' }),
|
||||
createMockBook({ hash: 'b', title: 'Apple', author: 'Same Author' }),
|
||||
];
|
||||
const sorter = createBookSorter(LibrarySortByType.Author, 'en', LibrarySortByType.Title);
|
||||
const sorted = [...books].sort(sorter);
|
||||
expect(sorted[0]!.title).toBe('Apple');
|
||||
expect(sorted[1]!.title).toBe('Zebra');
|
||||
});
|
||||
|
||||
it('falls back to default when primary differs (secondary not used)', () => {
|
||||
const books = [
|
||||
createMockBook({ hash: 'a', title: 'Zebra', author: 'B Author' }),
|
||||
createMockBook({ hash: 'b', title: 'Apple', author: 'A Author' }),
|
||||
];
|
||||
const sorter = createBookSorter(LibrarySortByType.Author, 'en', LibrarySortByType.Title);
|
||||
const sorted = [...books].sort(sorter);
|
||||
expect(sorted[0]!.author).toBe('A Author');
|
||||
expect(sorted[1]!.author).toBe('B Author');
|
||||
});
|
||||
|
||||
it('ignores secondary when set to none', () => {
|
||||
const books = [
|
||||
createMockBook({ hash: 'a', title: 'Zebra', author: 'Same' }),
|
||||
createMockBook({ hash: 'b', title: 'Apple', author: 'Same' }),
|
||||
];
|
||||
const sorter = createBookSorter(LibrarySortByType.Author, 'en', 'none');
|
||||
const sorted = [...books].sort(sorter);
|
||||
// Stable: both equal, original order preserved
|
||||
expect(sorted[0]!.hash).toBe('a');
|
||||
expect(sorted[1]!.hash).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('secondary sort - createWithinGroupSorter (drilled-in group)', () => {
|
||||
it('inside an author group, sorts by series index when secondary is series', () => {
|
||||
const books = [
|
||||
createMockBook({
|
||||
hash: '1',
|
||||
title: 'Zebra Book',
|
||||
metadata: { series: 'Series A', seriesIndex: 3 },
|
||||
}),
|
||||
createMockBook({
|
||||
hash: '2',
|
||||
title: 'Apple Book',
|
||||
metadata: { series: 'Series A', seriesIndex: 1 },
|
||||
}),
|
||||
createMockBook({
|
||||
hash: '3',
|
||||
title: 'Mango Book',
|
||||
metadata: { series: 'Series A', seriesIndex: 2 },
|
||||
}),
|
||||
];
|
||||
const sorter = createWithinGroupSorter(
|
||||
LibraryGroupByType.Author,
|
||||
LibrarySortByType.Title,
|
||||
'en',
|
||||
true,
|
||||
LibrarySortByType.Series,
|
||||
);
|
||||
const sorted = [...books].sort(sorter);
|
||||
expect(sorted.map((b) => b.metadata?.seriesIndex)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('falls back to primary when secondary ties (same series index)', () => {
|
||||
const books = [
|
||||
createMockBook({
|
||||
hash: '1',
|
||||
title: 'Zebra',
|
||||
metadata: { seriesIndex: 1 },
|
||||
}),
|
||||
createMockBook({
|
||||
hash: '2',
|
||||
title: 'Apple',
|
||||
metadata: { seriesIndex: 1 },
|
||||
}),
|
||||
];
|
||||
const sorter = createWithinGroupSorter(
|
||||
LibraryGroupByType.Author,
|
||||
LibrarySortByType.Title,
|
||||
'en',
|
||||
true,
|
||||
LibrarySortByType.Series,
|
||||
);
|
||||
const sorted = [...books].sort(sorter);
|
||||
expect(sorted[0]!.title).toBe('Apple');
|
||||
expect(sorted[1]!.title).toBe('Zebra');
|
||||
});
|
||||
|
||||
it('without secondary, keeps existing primary behavior in author group', () => {
|
||||
const books = [
|
||||
createMockBook({ hash: '1', title: 'Zebra' }),
|
||||
createMockBook({ hash: '2', title: 'Apple' }),
|
||||
];
|
||||
const sorter = createWithinGroupSorter(
|
||||
LibraryGroupByType.Author,
|
||||
LibrarySortByType.Title,
|
||||
'en',
|
||||
true,
|
||||
'none',
|
||||
);
|
||||
const sorted = [...books].sort(sorter);
|
||||
expect(sorted[0]!.title).toBe('Apple');
|
||||
expect(sorted[1]!.title).toBe('Zebra');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveEffectiveSecondarySort smart defaults', () => {
|
||||
it('returns explicit secondary when not none', () => {
|
||||
expect(resolveEffectiveSecondarySort(LibrarySortByType.Title, LibraryGroupByType.Author)).toBe(
|
||||
LibrarySortByType.Title,
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to series when groupBy=Author and stored=none', () => {
|
||||
expect(resolveEffectiveSecondarySort('none', LibraryGroupByType.Author)).toBe(
|
||||
LibrarySortByType.Series,
|
||||
);
|
||||
});
|
||||
|
||||
it('stays none for groupBy=Series (series-index handles ordering)', () => {
|
||||
expect(resolveEffectiveSecondarySort('none', LibraryGroupByType.Series)).toBe('none');
|
||||
});
|
||||
|
||||
it('stays none for groupBy=None/Group', () => {
|
||||
expect(resolveEffectiveSecondarySort('none', LibraryGroupByType.None)).toBe('none');
|
||||
expect(resolveEffectiveSecondarySort('none', LibraryGroupByType.Group)).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveEffectivePrimarySort smart defaults', () => {
|
||||
it('returns stored when isAuto=false, regardless of groupBy', () => {
|
||||
expect(
|
||||
resolveEffectivePrimarySort(LibrarySortByType.Updated, LibraryGroupByType.Series, false),
|
||||
).toBe(LibrarySortByType.Updated);
|
||||
expect(
|
||||
resolveEffectivePrimarySort(LibrarySortByType.Title, LibraryGroupByType.Author, false),
|
||||
).toBe(LibrarySortByType.Title);
|
||||
});
|
||||
|
||||
it('defaults to Series when isAuto=true and groupBy=Series', () => {
|
||||
expect(
|
||||
resolveEffectivePrimarySort(LibrarySortByType.Updated, LibraryGroupByType.Series, true),
|
||||
).toBe(LibrarySortByType.Series);
|
||||
});
|
||||
|
||||
it('returns stored for non-series groupBy when isAuto=true', () => {
|
||||
expect(
|
||||
resolveEffectivePrimarySort(LibrarySortByType.Updated, LibraryGroupByType.Author, true),
|
||||
).toBe(LibrarySortByType.Updated);
|
||||
expect(
|
||||
resolveEffectivePrimarySort(LibrarySortByType.Updated, LibraryGroupByType.Group, true),
|
||||
).toBe(LibrarySortByType.Updated);
|
||||
expect(
|
||||
resolveEffectivePrimarySort(LibrarySortByType.Updated, LibraryGroupByType.None, true),
|
||||
).toBe(LibrarySortByType.Updated);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureLibrarySecondarySortByType', () => {
|
||||
it('accepts none', () => {
|
||||
expect(ensureLibrarySecondarySortByType('none', 'none')).toBe('none');
|
||||
});
|
||||
it('accepts any valid LibrarySortByType', () => {
|
||||
expect(ensureLibrarySecondarySortByType('series', 'none')).toBe(LibrarySortByType.Series);
|
||||
});
|
||||
it('falls back when invalid', () => {
|
||||
expect(ensureLibrarySecondarySortByType('bogus', LibrarySortByType.Title)).toBe(
|
||||
LibrarySortByType.Title,
|
||||
);
|
||||
expect(ensureLibrarySecondarySortByType(null, 'none')).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,10 +36,13 @@ import {
|
||||
createWithinGroupSorter,
|
||||
ensureLibraryGroupByType,
|
||||
ensureLibrarySortByType,
|
||||
ensureLibrarySecondarySortByType,
|
||||
expandBookshelfSelection,
|
||||
getBookSortValue,
|
||||
getGroupSortValue,
|
||||
compareSortValues,
|
||||
resolveEffectivePrimarySort,
|
||||
resolveEffectiveSecondarySort,
|
||||
} from '../utils/libraryUtils';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
@@ -155,9 +158,16 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const groupId = searchParams?.get('group') || '';
|
||||
const queryTerm = searchParams?.get('q') || null;
|
||||
const viewMode = searchParams?.get('view') || settings.libraryViewMode;
|
||||
const sortBy = ensureLibrarySortByType(searchParams?.get('sort'), settings.librarySortBy);
|
||||
const storedSortBy = ensureLibrarySortByType(searchParams?.get('sort'), settings.librarySortBy);
|
||||
const sortOrder = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc');
|
||||
const groupBy = ensureLibraryGroupByType(searchParams?.get('groupBy'), settings.libraryGroupBy);
|
||||
const sortByAuto = settings.librarySortByAuto ?? true;
|
||||
const sortBy = resolveEffectivePrimarySort(storedSortBy, groupBy, sortByAuto);
|
||||
const sortBy2Raw = ensureLibrarySecondarySortByType(
|
||||
searchParams?.get('sort2'),
|
||||
settings.librarySortBy2 ?? 'none',
|
||||
);
|
||||
const sortBy2 = resolveEffectiveSecondarySort(sortBy2Raw, groupBy);
|
||||
const coverFit = searchParams?.get('cover') || settings.libraryCoverFit;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -260,14 +270,20 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
// Sort books within each group
|
||||
// For series groups, series index is always ascending; sort direction applies to fallback only
|
||||
const sortAscending = sortOrder === 'asc';
|
||||
const withinGroupSorter = createWithinGroupSorter(groupBy, sortBy, uiLanguage, sortAscending);
|
||||
const withinGroupSorter = createWithinGroupSorter(
|
||||
groupBy,
|
||||
sortBy,
|
||||
uiLanguage,
|
||||
sortAscending,
|
||||
sortBy2,
|
||||
);
|
||||
groups.forEach((group) => {
|
||||
group.books.sort(withinGroupSorter);
|
||||
});
|
||||
|
||||
// Sort ungrouped books - use within-group sorter if we're inside a group
|
||||
// (for series, this ensures books are sorted by series index)
|
||||
const bookSorter = createBookSorter(sortBy, uiLanguage);
|
||||
const bookSorter = createBookSorter(sortBy, uiLanguage, sortBy2);
|
||||
if (groupId && groupBy !== LibraryGroupByType.Group && groupBy !== LibraryGroupByType.None) {
|
||||
ungroupedBooks.sort(withinGroupSorter);
|
||||
// When inside a group, books are already sorted correctly — return directly
|
||||
@@ -309,7 +325,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
});
|
||||
|
||||
return allItems;
|
||||
}, [sortOrder, sortBy, groupBy, groupId, uiLanguage, currentBookshelfItems]);
|
||||
}, [sortOrder, sortBy, sortBy2, groupBy, groupId, uiLanguage, currentBookshelfItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isImportingBook.current) return;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
LibraryCoverFitType,
|
||||
LibraryViewModeType,
|
||||
LibraryGroupByType,
|
||||
LibrarySecondarySortByType,
|
||||
LibrarySortByType,
|
||||
} from '@/types/settings';
|
||||
import { saveSysSettings } from '@/helpers/settings';
|
||||
@@ -33,6 +34,22 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const groupBy = settings.libraryGroupBy;
|
||||
const sortBy = settings.librarySortBy;
|
||||
const isAscending = settings.librarySortAscending;
|
||||
const sortByAuto = settings.librarySortByAuto ?? true;
|
||||
// Primary smart default: when auto is on, grouping by Series implies Series as
|
||||
// the primary sort. The stored value is left alone — that way turning auto off
|
||||
// later restores the user's previous explicit pick.
|
||||
const primaryEffective: LibrarySortByType =
|
||||
sortByAuto && groupBy === LibraryGroupByType.Series ? LibrarySortByType.Series : sortBy;
|
||||
const primaryIsImplicit = sortByAuto && primaryEffective !== sortBy;
|
||||
const sortBy2: LibrarySecondarySortByType = settings.librarySortBy2 ?? 'none';
|
||||
// Smart default: when grouping by Author and the user hasn't picked an explicit
|
||||
// secondary, Series is implied. Surface this in the menu so the highlighted row
|
||||
// matches the actual sort behavior.
|
||||
const secondaryEffective: LibrarySecondarySortByType =
|
||||
sortBy2 === 'none' && groupBy === LibraryGroupByType.Author
|
||||
? LibrarySortByType.Series
|
||||
: sortBy2;
|
||||
const secondaryIsImplicit = sortBy2 === 'none' && secondaryEffective !== 'none';
|
||||
|
||||
const viewOptions = [
|
||||
{ label: _('List'), value: 'list' },
|
||||
@@ -61,6 +78,11 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
{ label: _('Date Published'), value: LibrarySortByType.Published },
|
||||
];
|
||||
|
||||
const sortBy2Options: { label: string; value: LibrarySecondarySortByType }[] = [
|
||||
{ label: _('None'), value: 'none' },
|
||||
...sortByOptions,
|
||||
];
|
||||
|
||||
const sortingOptions = [
|
||||
{ label: _('Ascending'), value: true },
|
||||
{ label: _('Descending'), value: false },
|
||||
@@ -108,6 +130,9 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
|
||||
const handleSetSortBy = async (value: LibrarySortByType) => {
|
||||
await saveSysSettings(envConfig, 'librarySortBy', value);
|
||||
// Any explicit primary pick locks in the choice and disables the auto
|
||||
// smart-default so future groupBy changes don't override the user.
|
||||
await saveSysSettings(envConfig, 'librarySortByAuto', false);
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
params.set('sort', value);
|
||||
@@ -122,6 +147,18 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleSetSortBy2 = async (value: LibrarySecondarySortByType) => {
|
||||
await saveSysSettings(envConfig, 'librarySortBy2', value);
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
if (value === 'none') {
|
||||
params.delete('sort2');
|
||||
} else {
|
||||
params.set('sort2', value);
|
||||
}
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className='view-menu dropdown-content no-triangle z-20 mt-2 shadow-2xl'
|
||||
@@ -202,16 +239,20 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
<hr aria-hidden='true' className='border-base-200 my-1' />
|
||||
<MenuItem label={_('Sort by...')} detailsOpen={false} buttonClass='py-[4px]'>
|
||||
<ul className='ms-0 flex flex-col ps-0 before:hidden'>
|
||||
{sortByOptions.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
buttonClass='h-8'
|
||||
toggled={sortBy === option.value}
|
||||
onClick={() => handleSetSortBy(option.value as LibrarySortByType)}
|
||||
transient
|
||||
/>
|
||||
))}
|
||||
{sortByOptions.map((option) => {
|
||||
const isImplicit = primaryIsImplicit && option.value === primaryEffective;
|
||||
const toggled = isImplicit || (!primaryIsImplicit && sortBy === option.value);
|
||||
return (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
label={isImplicit ? `${option.label} (${_('Auto')})` : option.label}
|
||||
buttonClass='h-8'
|
||||
toggled={toggled}
|
||||
onClick={() => handleSetSortBy(option.value as LibrarySortByType)}
|
||||
transient
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<hr aria-hidden='true' className='border-base-200 my-1' />
|
||||
{sortingOptions.map((option) => (
|
||||
<MenuItem
|
||||
@@ -225,6 +266,27 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
))}
|
||||
</ul>
|
||||
</MenuItem>
|
||||
|
||||
{/* Then by - secondary sort, collapsible */}
|
||||
<hr aria-hidden='true' className='border-base-200 my-1' />
|
||||
<MenuItem label={_('Then by...')} detailsOpen={false} buttonClass='py-[4px]'>
|
||||
<ul className='ms-0 flex flex-col ps-0 before:hidden'>
|
||||
{sortBy2Options.map((option) => {
|
||||
const isImplicit = secondaryIsImplicit && option.value === secondaryEffective;
|
||||
const isExplicit = sortBy2 === option.value;
|
||||
return (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
label={isImplicit ? `${option.label} (${_('Auto')})` : option.label}
|
||||
buttonClass='h-8'
|
||||
toggled={isExplicit || isImplicit}
|
||||
onClick={() => handleSetSortBy2(option.value)}
|
||||
transient
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Book, BooksGroup } from '@/types/book';
|
||||
import { LibraryGroupByType, LibrarySortByType } from '@/types/settings';
|
||||
import {
|
||||
LibraryGroupByType,
|
||||
LibrarySecondarySortByType,
|
||||
LibrarySortByType,
|
||||
} from '@/types/settings';
|
||||
import { formatAuthors, formatTitle } from '@/utils/book';
|
||||
import { md5Fingerprint } from '@/utils/md5';
|
||||
|
||||
@@ -23,6 +27,65 @@ export const ensureLibrarySortByType = (
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely cast a query parameter to LibrarySecondarySortByType with fallback.
|
||||
* Accepts any valid primary sort type plus the literal 'none'.
|
||||
*/
|
||||
export const ensureLibrarySecondarySortByType = (
|
||||
value: string | null | undefined,
|
||||
fallback: LibrarySecondarySortByType,
|
||||
): LibrarySecondarySortByType => {
|
||||
if (value === 'none') return 'none';
|
||||
if (value && VALID_SORT_TYPES.includes(value as LibrarySortByType)) {
|
||||
return value as LibrarySortByType;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the *effective* primary sort key, applying smart defaults derived
|
||||
* from the current `groupBy`. The stored `librarySortBy` is left unchanged;
|
||||
* this only substitutes a sensible implicit default when the user is still on
|
||||
* auto, so grouping by Series lands an alphabetical-by-series-name listing
|
||||
* without any extra clicks.
|
||||
*
|
||||
* - !isAuto -> use stored as-is
|
||||
* - groupBy=Series and isAuto -> Series
|
||||
* - everything else -> stored
|
||||
*/
|
||||
export const resolveEffectivePrimarySort = (
|
||||
stored: LibrarySortByType,
|
||||
groupBy: LibraryGroupByType,
|
||||
isAuto: boolean,
|
||||
): LibrarySortByType => {
|
||||
if (!isAuto) return stored;
|
||||
if (groupBy === LibraryGroupByType.Series) return LibrarySortByType.Series;
|
||||
return stored;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the *effective* secondary sort key, applying smart defaults derived
|
||||
* from the current `groupBy`. The stored secondary stays whatever the user
|
||||
* picked; this only substitutes 'none' with a sensible implicit default so
|
||||
* users get useful behavior out of the box (e.g. drilling into an Author
|
||||
* group lands a series-ordered list without any extra clicks).
|
||||
*
|
||||
* - explicit secondary (any non-'none') -> use as-is
|
||||
* - groupBy=Author and stored='none' -> Series
|
||||
* - everything else -> 'none'
|
||||
*
|
||||
* groupBy=Series doesn't default to anything because `createWithinGroupSorter`
|
||||
* already orders by `seriesIndex` for series groups.
|
||||
*/
|
||||
export const resolveEffectiveSecondarySort = (
|
||||
secondary: LibrarySecondarySortByType,
|
||||
groupBy: LibraryGroupByType,
|
||||
): LibrarySecondarySortByType => {
|
||||
if (secondary !== 'none') return secondary;
|
||||
if (groupBy === LibraryGroupByType.Author) return LibrarySortByType.Series;
|
||||
return 'none';
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely cast a query parameter to LibraryGroupByType with fallback.
|
||||
* Returns the value if valid, otherwise returns the fallback.
|
||||
@@ -115,16 +178,18 @@ export const createBookFilter = (queryTerm: string | null) => (item: Book) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const createBookSorter = (sortBy: string, uiLanguage: string) => (a: Book, b: Book) => {
|
||||
const compareBookByKey = (a: Book, b: Book, sortBy: string, uiLanguage: string): number => {
|
||||
switch (sortBy) {
|
||||
case LibrarySortByType.Title:
|
||||
case LibrarySortByType.Title: {
|
||||
const aTitle = formatTitle(a.title);
|
||||
const bTitle = formatTitle(b.title);
|
||||
return aTitle.localeCompare(bTitle, uiLanguage || navigator.language);
|
||||
case LibrarySortByType.Author:
|
||||
}
|
||||
case LibrarySortByType.Author: {
|
||||
const aAuthors = formatAuthors(a.author, a?.primaryLanguage || 'en', true);
|
||||
const bAuthors = formatAuthors(b.author, b?.primaryLanguage || 'en', true);
|
||||
return aAuthors.localeCompare(bAuthors, uiLanguage || navigator.language);
|
||||
}
|
||||
case LibrarySortByType.Updated:
|
||||
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||
case LibrarySortByType.Created:
|
||||
@@ -133,7 +198,7 @@ export const createBookSorter = (sortBy: string, uiLanguage: string) => (a: Book
|
||||
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
|
||||
case LibrarySortByType.Series:
|
||||
return (a.metadata?.seriesIndex || 0) - (b.metadata?.seriesIndex || 0);
|
||||
case LibrarySortByType.Published:
|
||||
case LibrarySortByType.Published: {
|
||||
const aPublished = a.metadata?.published || '0001-01-01';
|
||||
const bPublished = b.metadata?.published || '0001-01-01';
|
||||
|
||||
@@ -154,11 +219,25 @@ export const createBookSorter = (sortBy: string, uiLanguage: string) => (a: Book
|
||||
if (isNaN(bDate)) return -1;
|
||||
|
||||
return aDate - bDate;
|
||||
}
|
||||
default:
|
||||
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param secondarySortBy - Optional tiebreaker key applied when the primary
|
||||
* comparison returns 0. Pass `'none'` (or omit) to disable. Series-index ties
|
||||
* on a Series secondary fall through to the underlying primary tie.
|
||||
*/
|
||||
export const createBookSorter =
|
||||
(sortBy: string, uiLanguage: string, secondarySortBy: LibrarySecondarySortByType = 'none') =>
|
||||
(a: Book, b: Book): number => {
|
||||
const primary = compareBookByKey(a, b, sortBy, uiLanguage);
|
||||
if (primary !== 0 || secondarySortBy === 'none') return primary;
|
||||
return compareBookByKey(a, b, secondarySortBy, uiLanguage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a `groupName -> max(book.updatedAt)` map for all groups touched by
|
||||
* the given books. Each book bumps both its direct group and every ancestor
|
||||
@@ -319,10 +398,14 @@ const createAuthorGroups = (books: Book[]): (Book | BooksGroup)[] => {
|
||||
/**
|
||||
* Create a sorter for books within a group.
|
||||
* For series groups: sort by seriesIndex first (always ascending), then by global sort for items without index.
|
||||
* For author groups: follow global sort setting.
|
||||
* @param sortAscending - When true (default), sort direction is ascending. Series index is always
|
||||
* ascending regardless of this flag; the flag only affects the fallback sort for books without
|
||||
* series index and non-series groupings.
|
||||
* For other groupings: when a secondary key is supplied, sort by secondary key first (always ascending),
|
||||
* with the primary global sort as tiebreaker. Without secondary, follow global sort setting.
|
||||
* @param sortAscending - When true (default), sort direction is ascending. Series index and the
|
||||
* secondary key are always ascending regardless of this flag; the flag affects the fallback /
|
||||
* primary tiebreaker only.
|
||||
* @param secondarySortBy - When non-'none', acts as the *primary* within-group ordering for
|
||||
* non-series groupings (matches the user's mental model: "group by author, then sort by series"
|
||||
* should land series order inside each author).
|
||||
*/
|
||||
export const createWithinGroupSorter =
|
||||
(
|
||||
@@ -330,6 +413,7 @@ export const createWithinGroupSorter =
|
||||
sortBy: LibrarySortByType,
|
||||
uiLanguage: string,
|
||||
sortAscending: boolean = true,
|
||||
secondarySortBy: LibrarySecondarySortByType = 'none',
|
||||
) =>
|
||||
(a: Book, b: Book): number => {
|
||||
const sortDirection = sortAscending ? 1 : -1;
|
||||
@@ -351,7 +435,14 @@ export const createWithinGroupSorter =
|
||||
return createBookSorter(sortBy, uiLanguage)(a, b) * sortDirection;
|
||||
}
|
||||
|
||||
// For author and other groupings, use global sort with direction
|
||||
// For author and other non-series groupings: when a secondary key is provided,
|
||||
// use it as the within-group primary order with the global key as tiebreaker.
|
||||
if (secondarySortBy !== 'none') {
|
||||
const bySecondary = compareBookByKey(a, b, secondarySortBy, uiLanguage);
|
||||
if (bySecondary !== 0) return bySecondary;
|
||||
return createBookSorter(sortBy, uiLanguage)(a, b) * sortDirection;
|
||||
}
|
||||
|
||||
return createBookSorter(sortBy, uiLanguage)(a, b) * sortDirection;
|
||||
};
|
||||
|
||||
|
||||
@@ -121,6 +121,8 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||||
libraryViewMode: 'grid',
|
||||
librarySortBy: LibrarySortByType.Updated,
|
||||
librarySortAscending: false,
|
||||
librarySortByAuto: true,
|
||||
librarySortBy2: 'none',
|
||||
libraryGroupBy: LibraryGroupByType.Group,
|
||||
libraryCoverFit: 'crop',
|
||||
libraryAutoColumns: true,
|
||||
|
||||
@@ -22,6 +22,15 @@ export const LibrarySortByType = {
|
||||
|
||||
export type LibrarySortByType = (typeof LibrarySortByType)[keyof typeof LibrarySortByType];
|
||||
|
||||
/**
|
||||
* Secondary sort key. Same options as the primary sort key plus `'none'` which
|
||||
* disables the secondary sort. When set to `'none'` and a smart default applies
|
||||
* (e.g. groupBy=Author -> series), the resolver in `libraryUtils` substitutes
|
||||
* the implicit default at sort time without persisting it. See
|
||||
* `resolveEffectiveSecondarySort`.
|
||||
*/
|
||||
export type LibrarySecondarySortByType = LibrarySortByType | 'none';
|
||||
|
||||
export type LibraryCoverFitType = 'crop' | 'fit';
|
||||
|
||||
export const LibraryGroupByType = {
|
||||
@@ -288,6 +297,15 @@ export interface SystemSettings {
|
||||
libraryViewMode: LibraryViewModeType;
|
||||
librarySortBy: LibrarySortByType;
|
||||
librarySortAscending: boolean;
|
||||
/**
|
||||
* Whether the primary sort uses a smart default derived from `libraryGroupBy`.
|
||||
* When `true` and grouping by Series, the effective primary sort becomes
|
||||
* Series at sort time (the stored `librarySortBy` is left unchanged so users
|
||||
* who later turn auto off keep their previous explicit pick). Flipped to
|
||||
* `false` the moment the user picks any primary sort in the menu.
|
||||
*/
|
||||
librarySortByAuto: boolean;
|
||||
librarySortBy2: LibrarySecondarySortByType;
|
||||
libraryGroupBy: LibraryGroupByType;
|
||||
libraryCoverFit: LibraryCoverFitType;
|
||||
libraryAutoColumns: boolean;
|
||||
|
||||
Reference in New Issue
Block a user