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:
Huang Xin
2026-05-29 02:24:49 +08:00
committed by GitHub
parent 10a223b0e2
commit ce0ab5cc61
39 changed files with 458 additions and 57 deletions
@@ -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,
+18
View File
@@ -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;