From 52c49ddef11398e7c212b456955553c167db2145 Mon Sep 17 00:00:00 2001 From: Adam Charron Date: Tue, 3 Feb 2026 08:51:50 -0500 Subject: [PATCH] Add enhanced grouping and sorting functionality to the library view (#3146) * feat(library): implement grouping of books by series or author - Added functionality to group books in the library by series or author. - Introduced new utility functions for creating book groups and parsing author strings. - Updated the LibraryPageContent to track the current group for navigation. - Created a new GroupHeader component to display the current group context. - Enhanced the Bookshelf component to support displaying grouped books. - Updated settings to include grouping options in the view menu. New files: - src/__tests__/utils/libraryUtils.test.ts - Unit tests for new utility functions. - src/app/library/components/GroupHeader.tsx - Component for displaying group context. - src/app/library/utils/libraryUtils.ts - Utility functions for grouping and parsing authors. * Use group by and sort by constants, and split sort/group by menus into their own elements * Format code with autoformatter * Remove any casting from tests * Translate missing strings * refactor: cleaner layout with collapsible view menu options --------- Co-authored-by: Huang Xin --- .../public/locales/ar/translation.json | 5 +- .../public/locales/bn/translation.json | 5 +- .../public/locales/bo/translation.json | 5 +- .../public/locales/de/translation.json | 7 +- .../public/locales/el/translation.json | 5 +- .../public/locales/es/translation.json | 5 +- .../public/locales/fa/translation.json | 5 +- .../public/locales/fr/translation.json | 5 +- .../public/locales/hi/translation.json | 5 +- .../public/locales/id/translation.json | 5 +- .../public/locales/it/translation.json | 5 +- .../public/locales/ja/translation.json | 5 +- .../public/locales/ko/translation.json | 5 +- .../public/locales/ms/translation.json | 5 +- .../public/locales/nl/translation.json | 5 +- .../public/locales/pl/translation.json | 5 +- .../public/locales/pt/translation.json | 5 +- .../public/locales/ru/translation.json | 5 +- .../public/locales/si/translation.json | 5 +- .../public/locales/sv/translation.json | 5 +- .../public/locales/ta/translation.json | 5 +- .../public/locales/th/translation.json | 5 +- .../public/locales/tr/translation.json | 5 +- .../public/locales/uk/translation.json | 5 +- .../public/locales/vi/translation.json | 5 +- .../public/locales/zh-CN/translation.json | 5 +- .../public/locales/zh-TW/translation.json | 5 +- .../src/__tests__/utils/libraryUtils.test.ts | 843 ++++++++++++++++++ apps/readest-app/src/app/auth/page.tsx | 2 +- .../src/app/library/components/Bookshelf.tsx | 100 ++- .../app/library/components/GroupHeader.tsx | 59 ++ .../app/library/components/SettingsMenu.tsx | 14 +- .../src/app/library/components/ViewMenu.tsx | 123 ++- apps/readest-app/src/app/library/page.tsx | 49 +- .../src/app/library/utils/libraryUtils.ts | 298 ++++++- .../reader/components/sidebar/BookMenu.tsx | 10 +- .../components/sidebar/SearchOptions.tsx | 2 +- .../src/components/AboutWindow.tsx | 2 +- apps/readest-app/src/components/MenuItem.tsx | 4 +- apps/readest-app/src/services/constants.ts | 11 +- apps/readest-app/src/types/settings.ts | 30 +- 41 files changed, 1563 insertions(+), 121 deletions(-) create mode 100644 apps/readest-app/src/__tests__/utils/libraryUtils.test.ts create mode 100644 apps/readest-app/src/app/library/components/GroupHeader.tsx diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 410765c4..0d34fed3 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1057,5 +1057,8 @@ "Speed Reading Mode": "وضع القراءة السريعة", "Scroll left": "تمرير لليسار", "Scroll right": "تمرير لليمين", - "Library Sync Progress": "تقدم تزامن المكتبة" + "Library Sync Progress": "تقدم تزامن المكتبة", + "Back to library": "العودة إلى المكتبة", + "Manual": "يدوي", + "Group by...": "تجميع حسب..." } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index b34ca12c..200e21b3 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1009,5 +1009,8 @@ "Speed Reading Mode": "দ্রুত পাঠ্য মোড", "Scroll left": "বামে স্ক্রোল করুন", "Scroll right": "ডানে স্ক্রোল করুন", - "Library Sync Progress": "লাইব্রেরি সিঙ্ক অগ্রগতি" + "Library Sync Progress": "লাইব্রেরি সিঙ্ক অগ্রগতি", + "Back to library": "লাইব্রেরিতে ফিরে যান", + "Manual": "ম্যানুয়াল", + "Group by...": "গ্রুপ অনুযায়ী..." } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index eeb3b6aa..c2c578c6 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -997,5 +997,8 @@ "Speed Reading Mode": "མྱུར་ཀློག་རྣམ་པ།", "Scroll left": "གཡོན་ལ་བཤུད་དགོས།", "Scroll right": "གཡས་ལ་བཤུད་དགོས།", - "Library Sync Progress": "དཔེ་མཛོད་མཉམ་འབྱུང་གི་རིམ་པ།" + "Library Sync Progress": "དཔེ་མཛོད་མཉམ་འབྱུང་གི་རིམ་པ།", + "Back to library": "དཔེ་མཛོད་ལ་ལོག་པ།", + "Manual": "ལག་བཟོས།", + "Group by...": "དབྱེ་བ་འབྱེད་སྟངས།..." } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 29260b11..fabe9ea4 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1009,5 +1009,10 @@ "Speed Reading Mode": "Schnelllesemodus", "Scroll left": "Nach links scrollen", "Scroll right": "Nach rechts scrollen", - "Library Sync Progress": "Bibliotheks-Synchronisierungsfortschritt" + "Library Sync Progress": "Bibliotheks-Synchronisierungsfortschritt", + "Back to library": "Zurück zur Bibliothek", + "Manual": "Manuell", + "Group by...": "Gruppieren nach...", + "Group By": "Gruppieren nach", + "Sort": "Sortieren" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 42d785ba..d92c281e 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1009,5 +1009,8 @@ "Speed Reading Mode": "Λειτουργία ταχείας ανάγνωσης", "Scroll left": "Κύλιση αριστερά", "Scroll right": "Κύλιση δεξιά", - "Library Sync Progress": "Πρόοδος συγχρονισμού βιβλιοθήκης" + "Library Sync Progress": "Πρόοδος συγχρονισμού βιβλιοθήκης", + "Back to library": "Επιστροφή στη βιβλιοθήκη", + "Manual": "Χειροκίνητα", + "Group by...": "Ομαδοποίηση κατά..." } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index c6006152..e37b9b5c 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1021,5 +1021,8 @@ "Speed Reading Mode": "Modo de lectura rápida", "Scroll left": "Desplazar a la izquierda", "Scroll right": "Desplazar a la derecha", - "Library Sync Progress": "Progreso de sincronización de la biblioteca" + "Library Sync Progress": "Progreso de sincronización de la biblioteca", + "Back to library": "Volver a la biblioteca", + "Manual": "Manual", + "Group by...": "Agrupar por..." } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 8e565dbe..a85ff299 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1009,5 +1009,8 @@ "Speed Reading Mode": "حالت مطالعه سریع", "Scroll left": "اسکرول به چپ", "Scroll right": "اسکرول به راست", - "Library Sync Progress": "پیشرفت همگام‌سازی کتابخانه" + "Library Sync Progress": "پیشرفت همگام‌سازی کتابخانه", + "Back to library": "بازگشت به کتابخانه", + "Manual": "دستی", + "Group by...": "گروه‌بندی بر اساس..." } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 1b797648..8c8b4baf 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1021,5 +1021,8 @@ "Speed Reading Mode": "Mode lecture rapide", "Scroll left": "Défiler vers la gauche", "Scroll right": "Défiler vers la droite", - "Library Sync Progress": "Progression de la synchronisation de la bibliothèque" + "Library Sync Progress": "Progression de la synchronisation de la bibliothèque", + "Back to library": "Retour à la bibliothèque", + "Manual": "Manuel", + "Group by...": "Grouper par..." } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 6ef93ebf..397d5b4e 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1009,5 +1009,8 @@ "Speed Reading Mode": "त्वरित पठन मोड", "Scroll left": "बाएं स्क्रॉल करें", "Scroll right": "दाएं स्क्रॉल करें", - "Library Sync Progress": "पुस्तकालय सिंक प्रगति" + "Library Sync Progress": "पुस्तकालय सिंक प्रगति", + "Back to library": "लाइब्रेरी पर वापस जाएं", + "Manual": "मैनुअल", + "Group by...": "इसके द्वारा समूहबद्ध करें..." } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 15a43adb..55603b13 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -997,5 +997,8 @@ "Speed Reading Mode": "Mode Baca Cepat", "Scroll left": "Gulir ke kiri", "Scroll right": "Gulir ke kanan", - "Library Sync Progress": "Kemajuan Sinkronisasi Pustaka" + "Library Sync Progress": "Kemajuan Sinkronisasi Pustaka", + "Back to library": "Kembali ke perpustakaan", + "Manual": "Manual", + "Group by...": "Kelompokkan menurut..." } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 4e785a0e..1eabadd9 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1021,5 +1021,8 @@ "Speed Reading Mode": "Modalità lettura veloce", "Scroll left": "Scorri a sinistra", "Scroll right": "Scorri a destra", - "Library Sync Progress": "Avanzamento sincronizzazione libreria" + "Library Sync Progress": "Avanzamento sincronizzazione libreria", + "Back to library": "Torna alla libreria", + "Manual": "Manuale", + "Group by...": "Raggruppa per..." } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 3188b022..b16410b3 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -997,5 +997,8 @@ "Speed Reading Mode": "速読モード", "Scroll left": "左にスクロール", "Scroll right": "右にスクロール", - "Library Sync Progress": "ライブラリ同期の進捗" + "Library Sync Progress": "ライブラリ同期の進捗", + "Back to library": "ライブラリに戻る", + "Manual": "手動", + "Group by...": "グループ化..." } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 7f0153b3..9c7d5118 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -997,5 +997,8 @@ "Speed Reading Mode": "속독 모드", "Scroll left": "왼쪽으로 스크롤", "Scroll right": "오른쪽으로 스크롤", - "Library Sync Progress": "라이브러리 동기화 진행 상황" + "Library Sync Progress": "라이브러리 동기화 진행 상황", + "Back to library": "라이브러리로 돌아가기", + "Manual": "수동", + "Group by...": "그룹화 기준..." } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index d8315df1..dc77a673 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -997,5 +997,8 @@ "Speed Reading Mode": "Mod Bacaan Pantas", "Scroll left": "Tatal ke kiri", "Scroll right": "Tatal ke kanan", - "Library Sync Progress": "Kemajuan Segerak Perpustakaan" + "Library Sync Progress": "Kemajuan Segerak Perpustakaan", + "Back to library": "Kembali ke perpustakaan", + "Manual": "Manual", + "Group by...": "Kumpulan mengikut..." } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index f224ffbe..58d7c0a6 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1009,5 +1009,8 @@ "Speed Reading Mode": "Snelle leesmodus", "Scroll left": "Naar links scrollen", "Scroll right": "Naar rechts scrollen", - "Library Sync Progress": "Voortgang bibliotheeksynchronisatie" + "Library Sync Progress": "Voortgang bibliotheeksynchronisatie", + "Back to library": "Terug naar bibliotheek", + "Manual": "Handmatig", + "Group by...": "Groeperen op..." } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 2a925af6..ed1b0d0b 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1033,5 +1033,8 @@ "Speed Reading Mode": "Tryb szybkiego czytania", "Scroll left": "Przewiń w lewo", "Scroll right": "Przewiń w prawo", - "Library Sync Progress": "Postęp synchronizacji biblioteki" + "Library Sync Progress": "Postęp synchronizacji biblioteki", + "Back to library": "Powrót do biblioteki", + "Manual": "Ręcznie", + "Group by...": "Grupuj według..." } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index c6ed60e8..b1a4f52a 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1021,5 +1021,8 @@ "Speed Reading Mode": "Modo de leitura rápida", "Scroll left": "Rolar para a esquerda", "Scroll right": "Rolar para a direita", - "Library Sync Progress": "Progresso de sincronização da biblioteca" + "Library Sync Progress": "Progresso de sincronização da biblioteca", + "Back to library": "Voltar à biblioteca", + "Manual": "Manual", + "Group by...": "Agrupar por..." } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index f6b37165..ee886ce7 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1033,5 +1033,8 @@ "Speed Reading Mode": "Режим быстрого чтения", "Scroll left": "Прокрутить влево", "Scroll right": "Прокрутить вправо", - "Library Sync Progress": "Прогресс синхронизации библиотеки" + "Library Sync Progress": "Прогресс синхронизации библиотеки", + "Back to library": "Назад в библиотеку", + "Manual": "Вручную", + "Group by...": "Группировать по..." } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 1a9e7c55..277b17a8 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1009,5 +1009,8 @@ "Speed Reading Mode": "වේගයෙන් කියවීමේ ක්‍රමය", "Scroll left": "වමට අනුචලනය කරන්න", "Scroll right": "දකුණට අනුචලනය කරන්න", - "Library Sync Progress": "පුස්තකාල සමමුහුර්ත කිරීමේ ප්‍රගතිය" + "Library Sync Progress": "පුස්තකාල සමමුහුර්ත කිරීමේ ප්‍රගතිය", + "Back to library": "පුස්තකාලයට ආපසු", + "Manual": "අත්පොත", + "Group by...": "මගින් සමූහනය කරන්න..." } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index aea7fa61..e0d26bff 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1009,5 +1009,8 @@ "Speed Reading Mode": "Snabbläsningsläge", "Scroll left": "Scrolla åt vänster", "Scroll right": "Scrolla åt höger", - "Library Sync Progress": "Synkroniseringsframsteg för bibliotek" + "Library Sync Progress": "Synkroniseringsframsteg för bibliotek", + "Back to library": "Tillbaka till biblioteket", + "Manual": "Manuellt", + "Group by...": "Gruppera efter..." } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 0fa85239..59239543 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1009,5 +1009,8 @@ "Speed Reading Mode": "வேக வாசிப்பு முறை", "Scroll left": "இடதுபுறம் உருட்டவும்", "Scroll right": "வலதுபுறம் உருட்டவும்", - "Library Sync Progress": "நூலக ஒத்திசைவு நிலை" + "Library Sync Progress": "நூலக ஒத்திசைவு நிலை", + "Back to library": "நூலகத்திற்குத் திரும்பு", + "Manual": "கையேடு", + "Group by...": "இதன் படி குழுவாக்கு..." } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 88260c24..adf43733 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -997,5 +997,8 @@ "Speed Reading Mode": "โหมดการอ่านเร็ว", "Scroll left": "เลื่อนไปทางซ้าย", "Scroll right": "เลื่อนไปทางขวา", - "Library Sync Progress": "ความคืบหน้าการซิงค์ห้องสมุด" + "Library Sync Progress": "ความคืบหน้าการซิงค์ห้องสมุด", + "Back to library": "กลับไปที่ห้องสมุด", + "Manual": "ทำเอง", + "Group by...": "จัดกลุ่มตาม..." } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 4ad7aa2b..af9c26e1 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1009,5 +1009,8 @@ "Speed Reading Mode": "Hızlı Okuma Modu", "Scroll left": "Sola kaydır", "Scroll right": "Sağa kaydır", - "Library Sync Progress": "Kütüphane Senkronizasyon İlerlemesi" + "Library Sync Progress": "Kütüphane Senkronizasyon İlerlemesi", + "Back to library": "Kütüphaneye dön", + "Manual": "Manuel", + "Group by...": "Şuna göre grupla..." } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 6dc0eea2..59b26950 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1033,5 +1033,8 @@ "Speed Reading Mode": "Режим швидкого читання", "Scroll left": "Прокрутити вліво", "Scroll right": "Прокрутити вправо", - "Library Sync Progress": "Прогрес синхронізації бібліотеки" + "Library Sync Progress": "Прогрес синхронізації бібліотеки", + "Back to library": "Назад до бібліотеки", + "Manual": "Вручну", + "Group by...": "Групувати за..." } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 504cdc79..1ce2c795 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -997,5 +997,8 @@ "Speed Reading Mode": "Chế độ đọc nhanh", "Scroll left": "Cuộn sang trái", "Scroll right": "Cuộn sang phải", - "Library Sync Progress": "Tiến trình đồng bộ hóa thư viện" + "Library Sync Progress": "Tiến trình đồng bộ hóa thư viện", + "Back to library": "Quay lại thư viện", + "Manual": "Thủ công", + "Group by...": "Nhóm theo..." } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index fc67a345..bc13edc1 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -997,5 +997,8 @@ "Speed Reading Mode": "快读模式", "Scroll left": "向左滚动", "Scroll right": "向右滚动", - "Library Sync Progress": "库同步进度" + "Library Sync Progress": "库同步进度", + "Back to library": "返回书库", + "Manual": "手动", + "Group by...": "分组方式..." } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 668a13cc..00fa2043 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -997,5 +997,8 @@ "Speed Reading Mode": "快讀模式", "Scroll left": "向左捲動", "Scroll right": "向右捲動", - "Library Sync Progress": "資料庫同步進度" + "Library Sync Progress": "資料庫同步進度", + "Back to library": "返回書庫", + "Manual": "手動", + "Group by...": "分組方式..." } diff --git a/apps/readest-app/src/__tests__/utils/libraryUtils.test.ts b/apps/readest-app/src/__tests__/utils/libraryUtils.test.ts new file mode 100644 index 00000000..c82985d5 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/libraryUtils.test.ts @@ -0,0 +1,843 @@ +import { describe, it, expect } from 'vitest'; +import { + parseAuthors, + createBookGroups, + createWithinGroupSorter, + createGroupSorter, + getGroupSortValue, + createBookSorter, + ensureLibrarySortByType, + ensureLibraryGroupByType, + findGroupById, + getGroupDisplayName, +} from '../../app/library/utils/libraryUtils'; +import { Book, BooksGroup } from '../../types/book'; +import { LibraryGroupByType, LibrarySortByType } from '../../types/settings'; +import { BookMetadata } from '@/libs/document'; + +// Helper to create mock books with minimal required fields +const createMockBook = ( + overrides: Partial & { metadata?: Partial }> = {}, +): Book => ({ + hash: `hash-${Math.random().toString(36).substr(2, 9)}`, + format: 'EPUB', + title: 'Test Book', + author: 'Test Author', + createdAt: Date.now(), + updatedAt: Date.now(), + ...overrides, + metadata: { ...overrides.metadata } as BookMetadata, +}); + +describe('parseAuthors', () => { + it('should return single author as array', () => { + expect(parseAuthors('John Smith')).toEqual(['John Smith']); + }); + + it('should split authors by comma', () => { + expect(parseAuthors('John Smith, Jane Doe')).toEqual(['John Smith', 'Jane Doe']); + }); + + it('should split authors by ampersand', () => { + expect(parseAuthors('John Smith & Jane Doe')).toEqual(['John Smith', 'Jane Doe']); + }); + + it('should split authors by "and"', () => { + expect(parseAuthors('John Smith and Jane Doe')).toEqual(['John Smith', 'Jane Doe']); + }); + + it('should handle mixed separators', () => { + expect(parseAuthors('John Smith, Jane Doe & Bob Wilson')).toEqual([ + 'John Smith', + 'Jane Doe', + 'Bob Wilson', + ]); + }); + + it('should handle "and" with commas', () => { + expect(parseAuthors('John Smith, Jane Doe, and Bob Wilson')).toEqual([ + 'John Smith', + 'Jane Doe', + 'Bob Wilson', + ]); + }); + + it('should trim whitespace from author names', () => { + expect(parseAuthors(' John Smith , Jane Doe ')).toEqual(['John Smith', 'Jane Doe']); + }); + + it('should return empty array for empty string', () => { + expect(parseAuthors('')).toEqual([]); + }); + + it('should return empty array for whitespace only', () => { + expect(parseAuthors(' ')).toEqual([]); + }); + + it('should handle single author with extra spaces', () => { + expect(parseAuthors(' John Smith ')).toEqual(['John Smith']); + }); +}); + +describe('createBookGroups', () => { + describe('groupBy: none', () => { + it('should return all books as flat list', () => { + const books = [ + createMockBook({ hash: '1', title: 'Book 1' }), + createMockBook({ hash: '2', title: 'Book 2' }), + ]; + + const result = createBookGroups(books, LibraryGroupByType.None); + + expect(result).toHaveLength(2); + expect(result.every((item) => 'format' in item)).toBe(true); + }); + + it('should filter out deleted books', () => { + const books = [ + createMockBook({ hash: '1', title: 'Book 1' }), + createMockBook({ hash: '2', title: 'Book 2', deletedAt: Date.now() }), + ]; + + const result = createBookGroups(books, LibraryGroupByType.None); + + expect(result).toHaveLength(1); + }); + }); + + describe('groupBy: series', () => { + it('should group books by series name', () => { + const books = [ + createMockBook({ hash: '1', title: 'Book 1', metadata: { series: 'Series A' } }), + createMockBook({ hash: '2', title: 'Book 2', metadata: { series: 'Series A' } }), + createMockBook({ hash: '3', title: 'Book 3', metadata: { series: 'Series B' } }), + ]; + + const result = createBookGroups(books, LibraryGroupByType.Series); + + const groups = result.filter((item): item is BooksGroup => 'books' in item); + expect(groups).toHaveLength(2); + + const seriesA = groups.find((g) => g.name === 'Series A'); + expect(seriesA?.books).toHaveLength(2); + + const seriesB = groups.find((g) => g.name === 'Series B'); + expect(seriesB?.books).toHaveLength(1); + }); + + it('should leave books without series as ungrouped items', () => { + const books = [ + createMockBook({ hash: '1', title: 'Book 1', metadata: { series: 'Series A' } }), + createMockBook({ hash: '2', title: 'Book 2' }), // No series + createMockBook({ hash: '3', title: 'Book 3', metadata: {} }), // Empty metadata + ]; + + const result = createBookGroups(books, LibraryGroupByType.Series); + + const groups = result.filter((item): item is BooksGroup => 'books' in item); + const ungrouped = result.filter((item): item is Book => 'format' in item); + + expect(groups).toHaveLength(1); + expect(ungrouped).toHaveLength(2); + }); + + it('should handle empty series string as ungrouped', () => { + const books = [ + createMockBook({ hash: '1', title: 'Book 1', metadata: { series: '' } }), + createMockBook({ hash: '2', title: 'Book 2', metadata: { series: ' ' } }), + ]; + + const result = createBookGroups(books, LibraryGroupByType.Series); + + const groups = result.filter((item): item is BooksGroup => 'books' in item); + const ungrouped = result.filter((item): item is Book => 'format' in item); + + expect(groups).toHaveLength(0); + expect(ungrouped).toHaveLength(2); + }); + + it('should set group updatedAt to most recent book', () => { + const books = [ + createMockBook({ + hash: '1', + title: 'Book 1', + metadata: { series: 'Series A' }, + updatedAt: 1000, + }), + createMockBook({ + hash: '2', + title: 'Book 2', + metadata: { series: 'Series A' }, + updatedAt: 2000, + }), + ]; + + const result = createBookGroups(books, LibraryGroupByType.Series); + const group = result.find((item): item is BooksGroup => 'books' in item); + + expect(group?.updatedAt).toBe(2000); + }); + }); + + describe('groupBy: author', () => { + it('should group books by author', () => { + const books = [ + createMockBook({ hash: '1', title: 'Book 1', author: 'Author A' }), + createMockBook({ hash: '2', title: 'Book 2', author: 'Author A' }), + createMockBook({ hash: '3', title: 'Book 3', author: 'Author B' }), + ]; + + const result = createBookGroups(books, LibraryGroupByType.Author); + + const groups = result.filter((item): item is BooksGroup => 'books' in item); + expect(groups).toHaveLength(2); + + const authorA = groups.find((g) => g.name === 'Author A'); + expect(authorA?.books).toHaveLength(2); + }); + + it('should place book in multiple groups for multiple authors', () => { + const books = [ + createMockBook({ hash: '1', title: 'Book 1', author: 'John Smith, Jane Doe' }), + ]; + + const result = createBookGroups(books, LibraryGroupByType.Author); + + const groups = result.filter((item): item is BooksGroup => 'books' in item); + expect(groups).toHaveLength(2); + + const john = groups.find((g) => g.name === 'John Smith'); + const jane = groups.find((g) => g.name === 'Jane Doe'); + + expect(john?.books).toHaveLength(1); + expect(jane?.books).toHaveLength(1); + expect(john?.books[0]!.hash).toBe(jane?.books[0]!.hash); + }); + + it('should leave books without author as ungrouped', () => { + const books = [ + createMockBook({ hash: '1', title: 'Book 1', author: 'Author A' }), + createMockBook({ hash: '2', title: 'Book 2', author: '' }), + createMockBook({ hash: '3', title: 'Book 3', author: ' ' }), + ]; + + const result = createBookGroups(books, LibraryGroupByType.Author); + + const groups = result.filter((item): item is BooksGroup => 'books' in item); + const ungrouped = result.filter((item): item is Book => 'format' in item); + + expect(groups).toHaveLength(1); + expect(ungrouped).toHaveLength(2); + }); + }); + + describe('groupBy: manual', () => { + it('should return books as-is (manual mode handled elsewhere)', () => { + const books = [ + createMockBook({ hash: '1', title: 'Book 1' }), + createMockBook({ hash: '2', title: 'Book 2' }), + ]; + + const result = createBookGroups(books, LibraryGroupByType.Manual); + + // Manual mode just returns filtered books - actual grouping is in generateBookshelfItems + expect(result).toHaveLength(2); + }); + }); +}); + +describe('createWithinGroupSorter', () => { + describe('series grouping', () => { + it('should sort by seriesIndex ascending', () => { + const books = [ + createMockBook({ + hash: '1', + title: 'Book 3', + metadata: { seriesIndex: 3 }, + }), + createMockBook({ + hash: '2', + title: 'Book 1', + metadata: { seriesIndex: 1 }, + }), + createMockBook({ + hash: '3', + title: 'Book 2', + metadata: { seriesIndex: 2 }, + }), + ]; + + const sorter = createWithinGroupSorter( + LibraryGroupByType.Series, + LibrarySortByType.Title, + 'en', + ); + const sorted = [...books].sort(sorter); + + expect(sorted[0]!.metadata?.seriesIndex).toBe(1); + expect(sorted[1]!.metadata?.seriesIndex).toBe(2); + expect(sorted[2]!.metadata?.seriesIndex).toBe(3); + }); + + it('should place books without seriesIndex after those with index', () => { + const books = [ + createMockBook({ hash: '1', title: 'Book A', metadata: {} }), + createMockBook({ hash: '2', title: 'Book B', metadata: { seriesIndex: 1 } }), + ]; + + const sorter = createWithinGroupSorter( + LibraryGroupByType.Series, + LibrarySortByType.Title, + 'en', + ); + const sorted = [...books].sort(sorter); + + expect(sorted[0]!.hash).toBe('2'); // Has index + expect(sorted[1]!.hash).toBe('1'); // No index + }); + + it('should sort books without seriesIndex by global sort criteria', () => { + const books = [ + createMockBook({ hash: '1', title: 'Zebra', metadata: {} }), + createMockBook({ hash: '2', title: 'Apple', metadata: {} }), + ]; + + const sorter = createWithinGroupSorter( + LibraryGroupByType.Series, + LibrarySortByType.Title, + 'en', + ); + const sorted = [...books].sort(sorter); + + expect(sorted[0]!.title).toBe('Apple'); + expect(sorted[1]!.title).toBe('Zebra'); + }); + + it('should handle decimal seriesIndex values', () => { + const books = [ + createMockBook({ hash: '1', title: 'Book 2', metadata: { seriesIndex: 2 } }), + createMockBook({ hash: '2', title: 'Book 1.5', metadata: { seriesIndex: 1.5 } }), + createMockBook({ hash: '3', title: 'Book 1', metadata: { seriesIndex: 1 } }), + ]; + + const sorter = createWithinGroupSorter( + LibraryGroupByType.Series, + LibrarySortByType.Title, + 'en', + ); + const sorted = [...books].sort(sorter); + + expect(sorted[0]!.metadata?.seriesIndex).toBe(1); + expect(sorted[1]!.metadata?.seriesIndex).toBe(1.5); + expect(sorted[2]!.metadata?.seriesIndex).toBe(2); + }); + }); + + describe('author grouping', () => { + it('should sort by global sort criteria (title)', () => { + const books = [ + createMockBook({ hash: '1', title: 'Zebra' }), + createMockBook({ hash: '2', title: 'Apple' }), + ]; + + const sorter = createWithinGroupSorter( + LibraryGroupByType.Author, + LibrarySortByType.Title, + 'en', + ); + const sorted = [...books].sort(sorter); + + expect(sorted[0]!.title).toBe('Apple'); + expect(sorted[1]!.title).toBe('Zebra'); + }); + + it('should sort by global sort criteria (updated)', () => { + const books = [ + createMockBook({ hash: '1', title: 'Book 1', updatedAt: 2000 }), + createMockBook({ hash: '2', title: 'Book 2', updatedAt: 1000 }), + ]; + + const sorter = createWithinGroupSorter( + LibraryGroupByType.Author, + LibrarySortByType.Updated, + 'en', + ); + const sorted = [...books].sort(sorter); + + expect(sorted[0]!.updatedAt).toBe(1000); + expect(sorted[1]!.updatedAt).toBe(2000); + }); + }); +}); + +describe('getGroupSortValue', () => { + const createMockGroup = (overrides: Partial = {}): BooksGroup => ({ + id: 'test-group', + name: 'Test Group', + displayName: 'Test Group', + books: [], + updatedAt: Date.now(), + ...overrides, + }); + + it('should return group name for title sort', () => { + const group = createMockGroup({ name: 'My Series' }); + expect(getGroupSortValue(group, LibrarySortByType.Title)).toBe('My Series'); + }); + + it('should return group name for author sort', () => { + const group = createMockGroup({ name: 'John Smith' }); + expect(getGroupSortValue(group, LibrarySortByType.Author)).toBe('John Smith'); + }); + + it('should return group name for format sort', () => { + const group = createMockGroup({ name: 'Test Group' }); + expect(getGroupSortValue(group, LibrarySortByType.Format)).toBe('Test Group'); + }); + + it('should return max updatedAt for date read sort', () => { + const group = createMockGroup({ + books: [ + createMockBook({ updatedAt: 1000 }), + createMockBook({ updatedAt: 3000 }), + createMockBook({ updatedAt: 2000 }), + ], + }); + + expect(getGroupSortValue(group, LibrarySortByType.Updated)).toBe(3000); + }); + + it('should return max createdAt for date added sort', () => { + const group = createMockGroup({ + books: [ + createMockBook({ createdAt: 1000 }), + createMockBook({ createdAt: 3000 }), + createMockBook({ createdAt: 2000 }), + ], + }); + + expect(getGroupSortValue(group, LibrarySortByType.Created)).toBe(3000); + }); + + it('should return max published date for published sort', () => { + const group = createMockGroup({ + books: [ + createMockBook({ metadata: { published: '2020-01-01' } }), + createMockBook({ metadata: { published: '2023-06-15' } }), + createMockBook({ metadata: { published: '2021-12-31' } }), + ], + }); + + const result = getGroupSortValue(group, LibrarySortByType.Published); + expect(result).toBe(new Date('2023-06-15').getTime()); + }); + + it('should handle missing published dates', () => { + const group = createMockGroup({ + books: [createMockBook({ metadata: {} }), createMockBook({})], + }); + + expect(getGroupSortValue(group, LibrarySortByType.Published)).toBe(0); + }); + + it('should handle empty groups gracefully', () => { + const group = createMockGroup({ books: [] }); + + // Text-based sorts return group name + expect(getGroupSortValue(group, LibrarySortByType.Title)).toBe('Test Group'); + // Numeric sorts return 0 for empty groups + expect(getGroupSortValue(group, LibrarySortByType.Updated)).toBe(0); + }); +}); + +describe('createGroupSorter', () => { + const createMockGroup = (overrides: Partial = {}): BooksGroup => ({ + id: 'test-group', + name: 'Test Group', + displayName: 'Test Group', + books: [], + updatedAt: Date.now(), + ...overrides, + }); + + it('should sort groups alphabetically by name for title sort', () => { + const groups = [ + createMockGroup({ name: 'Zebra Series' }), + createMockGroup({ name: 'Apple Series' }), + createMockGroup({ name: 'Mango Series' }), + ]; + + const sorter = createGroupSorter(LibrarySortByType.Title, 'en'); + const sorted = [...groups].sort(sorter); + + expect(sorted[0]!.name).toBe('Apple Series'); + expect(sorted[1]!.name).toBe('Mango Series'); + expect(sorted[2]!.name).toBe('Zebra Series'); + }); + + it('should sort groups by most recent updatedAt for date read sort', () => { + const groups = [ + createMockGroup({ + name: 'Group A', + books: [createMockBook({ updatedAt: 1000 })], + }), + createMockGroup({ + name: 'Group B', + books: [createMockBook({ updatedAt: 3000 })], + }), + createMockGroup({ + name: 'Group C', + books: [createMockBook({ updatedAt: 2000 })], + }), + ]; + + const sorter = createGroupSorter(LibrarySortByType.Updated, 'en'); + const sorted = [...groups].sort(sorter); + + expect(sorted[0]!.name).toBe('Group A'); + expect(sorted[1]!.name).toBe('Group C'); + expect(sorted[2]!.name).toBe('Group B'); + }); + + it('should sort groups by most recent createdAt for date added sort', () => { + const groups = [ + createMockGroup({ + name: 'Group A', + books: [createMockBook({ createdAt: 3000 })], + }), + createMockGroup({ + name: 'Group B', + books: [createMockBook({ createdAt: 1000 })], + }), + ]; + + const sorter = createGroupSorter(LibrarySortByType.Created, 'en'); + const sorted = [...groups].sort(sorter); + + expect(sorted[0]!.name).toBe('Group B'); + expect(sorted[1]!.name).toBe('Group A'); + }); + + it('should handle groups with single book', () => { + const groups = [ + createMockGroup({ + name: 'Group A', + books: [createMockBook({ updatedAt: 1000 })], + }), + createMockGroup({ + name: 'Group B', + books: [createMockBook({ updatedAt: 2000 })], + }), + ]; + + const sorter = createGroupSorter(LibrarySortByType.Updated, 'en'); + const sorted = [...groups].sort(sorter); + + expect(sorted[0]!.name).toBe('Group A'); + expect(sorted[1]!.name).toBe('Group B'); + }); +}); + +describe('createBookSorter', () => { + it('should sort by title alphabetically', () => { + const books = [ + createMockBook({ title: 'Zebra' }), + createMockBook({ title: 'Apple' }), + createMockBook({ title: 'Mango' }), + ]; + + const sorter = createBookSorter(LibrarySortByType.Title, 'en'); + const sorted = [...books].sort(sorter); + + expect(sorted[0]!.title).toBe('Apple'); + expect(sorted[1]!.title).toBe('Mango'); + expect(sorted[2]!.title).toBe('Zebra'); + }); + + it('should sort by updatedAt for date read', () => { + const books = [ + createMockBook({ title: 'Book A', updatedAt: 2000 }), + createMockBook({ title: 'Book B', updatedAt: 1000 }), + createMockBook({ title: 'Book C', updatedAt: 3000 }), + ]; + + const sorter = createBookSorter(LibrarySortByType.Updated, 'en'); + const sorted = [...books].sort(sorter); + + expect(sorted[0]!.title).toBe('Book B'); + expect(sorted[1]!.title).toBe('Book A'); + expect(sorted[2]!.title).toBe('Book C'); + }); +}); + +describe('grouping and sorting integration', () => { + it('should correctly group by series and sort groups by date read', () => { + const books = [ + createMockBook({ + hash: '1', + title: 'Old Series Book', + metadata: { series: 'Old Series', seriesIndex: 1 }, + updatedAt: 1000, + }), + createMockBook({ + hash: '2', + title: 'New Series Book 1', + metadata: { series: 'New Series', seriesIndex: 1 }, + updatedAt: 3000, + }), + createMockBook({ + hash: '3', + title: 'New Series Book 2', + metadata: { series: 'New Series', seriesIndex: 2 }, + updatedAt: 2000, + }), + ]; + + // Create groups + const items = createBookGroups(books, LibraryGroupByType.Series); + const groups = items.filter((item): item is BooksGroup => 'books' in item); + + // Sort groups by updated (descending - most recent first) + const groupSorter = createGroupSorter(LibrarySortByType.Updated, 'en'); + groups.sort((a, b) => groupSorter(a, b) * -1); // Descending + + expect(groups[0]!.name).toBe('New Series'); // Most recent book at 3000 + expect(groups[1]!.name).toBe('Old Series'); // Most recent book at 1000 + + // Sort within groups by seriesIndex + const withinSorter = createWithinGroupSorter( + LibraryGroupByType.Series, + LibrarySortByType.Updated, + 'en', + ); + groups.forEach((group) => group.books.sort(withinSorter)); + + expect(groups[0]!.books[0]!.metadata?.seriesIndex).toBe(1); + expect(groups[0]!.books[1]!.metadata?.seriesIndex).toBe(2); + }); + + it('should correctly group by author and sort within groups by title', () => { + const books = [ + createMockBook({ hash: '1', title: 'Zebra', author: 'Author A' }), + createMockBook({ hash: '2', title: 'Apple', author: 'Author A' }), + createMockBook({ hash: '3', title: 'Mango', author: 'Author B' }), + ]; + + // Create groups + const items = createBookGroups(books, LibraryGroupByType.Author); + const groups = items.filter((item): item is BooksGroup => 'books' in item); + + // Sort groups alphabetically + const groupSorter = createGroupSorter(LibrarySortByType.Title, 'en'); + groups.sort(groupSorter); + + expect(groups[0]!.name).toBe('Author A'); + expect(groups[1]!.name).toBe('Author B'); + + // Sort within groups by title + const withinSorter = createWithinGroupSorter( + LibraryGroupByType.Author, + LibrarySortByType.Title, + 'en', + ); + groups.forEach((group) => group.books.sort(withinSorter)); + + expect(groups[0]!.books[0]!.title).toBe('Apple'); + expect(groups[0]!.books[1]!.title).toBe('Zebra'); + }); + + it('should handle ascending/descending sort order', () => { + const books = [ + createMockBook({ + hash: '1', + title: 'Series A Book', + metadata: { series: 'Series A' }, + updatedAt: 1000, + }), + createMockBook({ + hash: '2', + title: 'Series B Book', + metadata: { series: 'Series B' }, + updatedAt: 2000, + }), + ]; + + const items = createBookGroups(books, LibraryGroupByType.Series); + const groups = items.filter((item): item is BooksGroup => 'books' in item); + const groupSorter = createGroupSorter(LibrarySortByType.Updated, 'en'); + + // Ascending (oldest first) + const ascending = [...groups].sort((a, b) => groupSorter(a, b) * 1); + expect(ascending[0]!.name).toBe('Series A'); + + // Descending (newest first) + const descending = [...groups].sort((a, b) => groupSorter(a, b) * -1); + expect(descending[0]!.name).toBe('Series B'); + }); +}); + +describe('ensureLibrarySortByType', () => { + it('should return valid sort type when value is valid', () => { + expect(ensureLibrarySortByType('title', LibrarySortByType.Updated)).toBe( + LibrarySortByType.Title, + ); + expect(ensureLibrarySortByType('author', LibrarySortByType.Updated)).toBe( + LibrarySortByType.Author, + ); + expect(ensureLibrarySortByType('updated', LibrarySortByType.Title)).toBe( + LibrarySortByType.Updated, + ); + expect(ensureLibrarySortByType('created', LibrarySortByType.Updated)).toBe( + LibrarySortByType.Created, + ); + expect(ensureLibrarySortByType('format', LibrarySortByType.Updated)).toBe( + LibrarySortByType.Format, + ); + expect(ensureLibrarySortByType('published', LibrarySortByType.Updated)).toBe( + LibrarySortByType.Published, + ); + }); + + it('should return fallback when value is null', () => { + expect(ensureLibrarySortByType(null, LibrarySortByType.Updated)).toBe( + LibrarySortByType.Updated, + ); + }); + + it('should return fallback when value is undefined', () => { + expect(ensureLibrarySortByType(undefined, LibrarySortByType.Title)).toBe( + LibrarySortByType.Title, + ); + }); + + it('should return fallback when value is invalid', () => { + expect(ensureLibrarySortByType('invalid', LibrarySortByType.Updated)).toBe( + LibrarySortByType.Updated, + ); + expect(ensureLibrarySortByType('random', LibrarySortByType.Title)).toBe( + LibrarySortByType.Title, + ); + }); + + it('should return fallback when value is empty string', () => { + expect(ensureLibrarySortByType('', LibrarySortByType.Updated)).toBe(LibrarySortByType.Updated); + }); +}); + +describe('ensureLibraryGroupByType', () => { + it('should return valid group type when value is valid', () => { + expect(ensureLibraryGroupByType('none', LibraryGroupByType.Manual)).toBe( + LibraryGroupByType.None, + ); + expect(ensureLibraryGroupByType('manual', LibraryGroupByType.None)).toBe( + LibraryGroupByType.Manual, + ); + expect(ensureLibraryGroupByType('series', LibraryGroupByType.Manual)).toBe( + LibraryGroupByType.Series, + ); + expect(ensureLibraryGroupByType('author', LibraryGroupByType.Manual)).toBe( + LibraryGroupByType.Author, + ); + }); + + it('should return fallback when value is null', () => { + expect(ensureLibraryGroupByType(null, LibraryGroupByType.Manual)).toBe( + LibraryGroupByType.Manual, + ); + }); + + it('should return fallback when value is undefined', () => { + expect(ensureLibraryGroupByType(undefined, LibraryGroupByType.Series)).toBe( + LibraryGroupByType.Series, + ); + }); + + it('should return fallback when value is invalid', () => { + expect(ensureLibraryGroupByType('invalid', LibraryGroupByType.Manual)).toBe( + LibraryGroupByType.Manual, + ); + expect(ensureLibraryGroupByType('random', LibraryGroupByType.Author)).toBe( + LibraryGroupByType.Author, + ); + }); + + it('should return fallback when value is empty string', () => { + expect(ensureLibraryGroupByType('', LibraryGroupByType.Manual)).toBe(LibraryGroupByType.Manual); + }); +}); + +describe('findGroupById', () => { + const createMockGroup = (overrides: Partial = {}): BooksGroup => ({ + id: 'test-group', + name: 'Test Group', + displayName: 'Test Group', + books: [], + updatedAt: Date.now(), + ...overrides, + }); + + it('should find group by id', () => { + const items: (Book | BooksGroup)[] = [ + createMockBook({ hash: '1' }), + createMockGroup({ id: 'group-1', name: 'Group 1' }), + createMockGroup({ id: 'group-2', name: 'Group 2' }), + ]; + + const found = findGroupById(items, 'group-2'); + expect(found).toBeDefined(); + expect(found!.name).toBe('Group 2'); + }); + + it('should return undefined when group not found', () => { + const items: (Book | BooksGroup)[] = [ + createMockBook({ hash: '1' }), + createMockGroup({ id: 'group-1', name: 'Group 1' }), + ]; + + const found = findGroupById(items, 'non-existent'); + expect(found).toBeUndefined(); + }); + + it('should not match books', () => { + const items: (Book | BooksGroup)[] = [ + createMockBook({ hash: 'group-1' }), // Book with hash matching a group id + createMockGroup({ id: 'group-2', name: 'Group 2' }), + ]; + + const found = findGroupById(items, 'group-1'); + expect(found).toBeUndefined(); + }); +}); + +describe('getGroupDisplayName', () => { + const createMockGroup = (overrides: Partial = {}): BooksGroup => ({ + id: 'test-group', + name: 'Test Group', + displayName: 'Test Display Name', + books: [], + updatedAt: Date.now(), + ...overrides, + }); + + it('should return displayName when available', () => { + const items: (Book | BooksGroup)[] = [ + createMockGroup({ id: 'group-1', name: 'Name', displayName: 'Display Name' }), + ]; + + expect(getGroupDisplayName(items, 'group-1')).toBe('Display Name'); + }); + + it('should return name when displayName is empty', () => { + const items: (Book | BooksGroup)[] = [ + createMockGroup({ id: 'group-1', name: 'Name', displayName: '' }), + ]; + + expect(getGroupDisplayName(items, 'group-1')).toBe('Name'); + }); + + it('should return undefined when group not found', () => { + const items: (Book | BooksGroup)[] = [createMockGroup({ id: 'group-1', name: 'Name' })]; + + expect(getGroupDisplayName(items, 'non-existent')).toBeUndefined(); + }); +}); diff --git a/apps/readest-app/src/app/auth/page.tsx b/apps/readest-app/src/app/auth/page.tsx index befa5de3..1a6e473c 100644 --- a/apps/readest-app/src/app/auth/page.tsx +++ b/apps/readest-app/src/app/auth/page.tsx @@ -416,7 +416,7 @@ export default function AuthPage() { Icon={FaDiscord} label={_('Sign in with {{provider}}', { provider: 'Discord' })} /> -
+
= ({ const groupId = searchParams?.get('group') || ''; const queryTerm = searchParams?.get('q') || null; const viewMode = searchParams?.get('view') || settings.libraryViewMode; - const sortBy = searchParams?.get('sort') || settings.librarySortBy; + const sortBy = ensureLibrarySortByType(searchParams?.get('sort'), settings.librarySortBy); const sortOrder = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc'); + const groupBy = ensureLibraryGroupByType(searchParams?.get('groupBy'), settings.libraryGroupBy); const coverFit = searchParams?.get('cover') || settings.libraryCoverFit; const [loading, setLoading] = useState(false); @@ -102,8 +115,9 @@ const Bookshelf: React.FC = ({ } }); - if (params.get('sort') === 'updated') params.delete('sort'); + if (params.get('sort') === LibrarySortByType.Updated) params.delete('sort'); if (params.get('order') === 'desc') params.delete('order'); + if (params.get('groupBy') === LibraryGroupByType.Manual) params.delete('groupBy'); if (params.get('cover') === 'crop') params.delete('cover'); if (params.get('view') === 'grid') params.delete('view'); @@ -123,13 +137,33 @@ const Bookshelf: React.FC = ({ }, [libraryBooks, queryTerm]); const currentBookshelfItems = useMemo(() => { - const groupName = getGroupName(groupId) || ''; - if (groupId && !groupName) { - return []; + if (groupBy === LibraryGroupByType.Manual) { + // Use existing generateBookshelfItems for manual mode + const groupName = getGroupName(groupId) || ''; + if (groupId && !groupName) { + return []; + } + return generateBookshelfItems(filteredBooks, groupName); + } else { + // Use new createBookGroups for series/author/none modes + const allItems = createBookGroups(filteredBooks, groupBy); + + // If navigating into a specific group, show only that group's books + if (groupId) { + const targetGroup = allItems.find( + (item): item is BooksGroup => 'books' in item && item.id === groupId, + ); + if (targetGroup) { + // Return the books from the target group as individual items + return targetGroup.books; + } + // Group not found, return empty + return []; + } + + return allItems; } - const items = generateBookshelfItems(filteredBooks, groupName); - return items; - }, [filteredBooks, groupId, getGroupName]); + }, [filteredBooks, groupBy, groupId, getGroupName]); useEffect(() => { if (groupId && currentBookshelfItems.length === 0) { @@ -140,24 +174,34 @@ const Bookshelf: React.FC = ({ }, [searchParams, groupId, currentBookshelfItems.length, updateUrlParams]); const sortedBookshelfItems = useMemo(() => { - const bookSorter = createBookSorter(sortBy, uiLanguage); const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1; - return currentBookshelfItems.sort((a, b) => { - if (sortBy === 'updated') { - return ( - (new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) * sortOrderMultiplier - ); - } else if ('name' in a || 'name' in b) { - const aName = 'name' in a ? a.name : formatTitle(a.title); - const bName = 'name' in b ? b.name : formatTitle(b.title); - return aName.localeCompare(bName, uiLanguage || navigator.language) * sortOrderMultiplier; - } else if (!('name' in a || 'name' in b)) { - return bookSorter(a, b) * sortOrderMultiplier; - } else { - return 0; - } + + // Separate into ungrouped books and groups + const ungroupedBooks = currentBookshelfItems.filter((item): item is Book => 'format' in item); + const groups = currentBookshelfItems.filter((item): item is BooksGroup => 'books' in item); + + // Sort groups by aggregate value + const groupSorter = createGroupSorter(sortBy, uiLanguage); + groups.sort((a, b) => groupSorter(a, b) * sortOrderMultiplier); + + // Sort books within each group + const withinGroupSorter = createWithinGroupSorter(groupBy, sortBy, uiLanguage); + groups.forEach((group) => { + group.books.sort((a, b) => withinGroupSorter(a, b) * sortOrderMultiplier); }); - }, [sortOrder, sortBy, uiLanguage, currentBookshelfItems]); + + // Sort ungrouped books - use within-group sorter if we're inside a group + // (for series, this ensures books are sorted by series index) + if (groupId && groupBy !== LibraryGroupByType.Manual && groupBy !== LibraryGroupByType.None) { + ungroupedBooks.sort((a, b) => withinGroupSorter(a, b) * sortOrderMultiplier); + } else { + const bookSorter = createBookSorter(sortBy, uiLanguage); + ungroupedBooks.sort((a, b) => bookSorter(a, b) * sortOrderMultiplier); + } + + // Return groups first, then ungrouped books + return [...groups, ...ungroupedBooks]; + }, [sortOrder, sortBy, groupBy, groupId, uiLanguage, currentBookshelfItems]); useEffect(() => { if (isImportingBook.current) return; diff --git a/apps/readest-app/src/app/library/components/GroupHeader.tsx b/apps/readest-app/src/app/library/components/GroupHeader.tsx new file mode 100644 index 00000000..77930cfc --- /dev/null +++ b/apps/readest-app/src/app/library/components/GroupHeader.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { MdArrowBack } from 'react-icons/md'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useResponsiveSize } from '@/hooks/useResponsiveSize'; +import { navigateToLibrary } from '@/utils/nav'; +import { LibraryGroupByType } from '@/types/settings'; + +interface GroupHeaderProps { + groupBy: LibraryGroupByType; + groupName: string; +} + +/** + * Header component displayed when viewing books inside a series or author group. + * Shows the group type, group name, and a back button to return to the main bookshelf. + */ +const GroupHeader: React.FC = ({ groupBy, groupName }) => { + const _ = useTranslation(); + const router = useRouter(); + const searchParams = useSearchParams(); + const iconSize = useResponsiveSize(20); + + const handleBack = () => { + const params = new URLSearchParams(searchParams?.toString()); + params.delete('group'); + navigateToLibrary(router, params.toString()); + }; + + // Get localized label for the group type + const getGroupTypeLabel = (): string => { + switch (groupBy) { + case LibraryGroupByType.Series: + return _('Series'); + case LibraryGroupByType.Author: + return _('Author'); + default: + return _('Group'); + } + }; + + return ( +
+ +
+ {getGroupTypeLabel()}: + {groupName} +
+
+ ); +}; + +export default GroupHeader; diff --git a/apps/readest-app/src/app/library/components/SettingsMenu.tsx b/apps/readest-app/src/app/library/components/SettingsMenu.tsx index 4ad25299..e5cab2b7 100644 --- a/apps/readest-app/src/app/library/components/SettingsMenu.tsx +++ b/apps/readest-app/src/app/library/components/SettingsMenu.tsx @@ -254,7 +254,12 @@ const SettingsMenu: React.FC = ({ setIsDropdownOpen }) => { ) } > -
    +
      @@ -349,7 +354,12 @@ const SettingsMenu: React.FC = ({ setIsDropdownOpen }) => { <> -
        +
          = ({ setIsDropdownOpen }) => { const { settings } = useSettingsStore(); const viewMode = settings.libraryViewMode; - const sortBy = settings.librarySortBy; - const isAscending = settings.librarySortAscending; const coverFit = settings.libraryCoverFit; const autoColumns = settings.libraryAutoColumns; const columns = settings.libraryColumns; + const groupBy = settings.libraryGroupBy; + const sortBy = settings.librarySortBy; + const isAscending = settings.librarySortAscending; const viewOptions = [ { label: _('List'), value: 'list' }, @@ -38,13 +44,20 @@ const ViewMenu: React.FC = ({ setIsDropdownOpen }) => { { label: _('Fit'), value: 'fit' }, ]; + const groupByOptions = [ + { label: _('None'), value: LibraryGroupByType.None }, + { label: _('Manual'), value: LibraryGroupByType.Manual }, + { label: _('Series'), value: LibraryGroupByType.Series }, + { label: _('Author'), value: LibraryGroupByType.Author }, + ]; + const sortByOptions = [ - { label: _('Title'), value: 'title' }, - { label: _('Author'), value: 'author' }, - { label: _('Format'), value: 'format' }, - { label: _('Date Read'), value: 'updated' }, - { label: _('Date Added'), value: 'created' }, - { label: _('Date Published'), value: 'published' }, + { label: _('Title'), value: LibrarySortByType.Title }, + { label: _('Author'), value: LibrarySortByType.Author }, + { label: _('Format'), value: LibrarySortByType.Format }, + { label: _('Date Read'), value: LibrarySortByType.Updated }, + { label: _('Date Added'), value: LibrarySortByType.Created }, + { label: _('Date Published'), value: LibrarySortByType.Published }, ]; const sortingOptions = [ @@ -54,7 +67,6 @@ const ViewMenu: React.FC = ({ setIsDropdownOpen }) => { const handleSetViewMode = async (value: LibraryViewModeType) => { await saveSysSettings(envConfig, 'libraryViewMode', value); - setIsDropdownOpen?.(false); const params = new URLSearchParams(searchParams?.toString()); params.set('view', value); @@ -63,7 +75,6 @@ const ViewMenu: React.FC = ({ setIsDropdownOpen }) => { const handleToggleCropCovers = async (value: LibraryCoverFitType) => { await saveSysSettings(envConfig, 'libraryCoverFit', value); - setIsDropdownOpen?.(false); const params = new URLSearchParams(searchParams?.toString()); params.set('cover', value); @@ -80,9 +91,22 @@ const ViewMenu: React.FC = ({ setIsDropdownOpen }) => { await saveSysSettings(envConfig, 'libraryAutoColumns', false); }; + const handleSetGroupBy = async (value: LibraryGroupByType) => { + await saveSysSettings(envConfig, 'libraryGroupBy', value); + + const params = new URLSearchParams(searchParams?.toString()); + if (value === LibraryGroupByType.Manual) { + params.delete('groupBy'); + } else { + params.set('groupBy', value); + } + // Clear group navigation when changing groupBy mode + params.delete('group'); + navigateToLibrary(router, `${params.toString()}`); + }; + const handleSetSortBy = async (value: LibrarySortByType) => { await saveSysSettings(envConfig, 'librarySortBy', value); - setIsDropdownOpen?.(false); const params = new URLSearchParams(searchParams?.toString()); params.set('sort', value); @@ -91,7 +115,6 @@ const ViewMenu: React.FC = ({ setIsDropdownOpen }) => { const handleSetSortAscending = async (value: boolean) => { await saveSysSettings(envConfig, 'librarySortAscending', value); - setIsDropdownOpen?.(false); const params = new URLSearchParams(searchParams?.toString()); params.set('order', value ? 'asc' : 'desc'); @@ -103,6 +126,7 @@ const ViewMenu: React.FC = ({ setIsDropdownOpen }) => { className='view-menu dropdown-content no-triangle z-20 mt-2 shadow-2xl' onCancel={() => setIsDropdownOpen?.(false)} > + {/* View Mode */} {viewOptions.map((option) => ( = ({ setIsDropdownOpen }) => { buttonClass='h-8' toggled={viewMode === option.value} onClick={() => handleSetViewMode(option.value as LibraryViewModeType)} + transient /> ))} + + {/* Columns */} = ({ setIsDropdownOpen }) => { } onClick={() => handleToggleAutoColumns()} /> + + {/* Book Covers */} = ({ setIsDropdownOpen }) => { buttonClass='h-8' toggled={coverFit === option.value} onClick={() => handleToggleCropCovers(option.value as LibraryCoverFitType)} + transient /> ))} + + {/* Group By - Collapsible */} - - {sortByOptions.map((option) => ( - handleSetSortBy(option.value as LibrarySortByType)} - /> - ))} + +
            + {groupByOptions.map((option) => ( + handleSetGroupBy(option.value as LibraryGroupByType)} + transient + /> + ))} +
          +
          + + {/* Sort By - Collapsible */} - {sortingOptions.map((option) => ( - handleSetSortAscending(option.value)} - /> - ))} + +
            + {sortByOptions.map((option) => ( + handleSetSortBy(option.value as LibrarySortByType)} + transient + /> + ))} + + {sortingOptions.map((option) => ( + handleSetSortAscending(option.value)} + transient + /> + ))} +
          +
          ); }; diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 41365202..d50711b2 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -50,6 +50,7 @@ import { tauriQuitApp, } from '@/utils/window'; +import { LibraryGroupByType } from '@/types/settings'; import { BookMetadata } from '@/libs/document'; import { AboutWindow } from '@/components/AboutWindow'; import { BookDetailModal } from '@/components/metadata'; @@ -59,10 +60,16 @@ import { MigrateDataWindow } from './components/MigrateDataWindow'; import { useDragDropImport } from './hooks/useDragDropImport'; import { useTransferQueue } from '@/hooks/useTransferQueue'; import { Toast } from '@/components/Toast'; -import { getBreadcrumbs } from './utils/libraryUtils'; +import { + createBookGroups, + ensureLibraryGroupByType, + findGroupById, + getBreadcrumbs, +} from './utils/libraryUtils'; import Spinner from '@/components/Spinner'; import LibraryHeader from './components/LibraryHeader'; import Bookshelf from './components/Bookshelf'; +import GroupHeader from './components/GroupHeader'; import useShortcuts from '@/hooks/useShortcuts'; import DropIndicator from '@/components/DropIndicator'; import SettingsDialog from '@/components/settings/SettingsDialog'; @@ -110,6 +117,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP const [isSelectNone, setIsSelectNone] = useState(false); const [showDetailsBook, setShowDetailsBook] = useState(null); const [currentGroupPath, setCurrentGroupPath] = useState(undefined); + const [currentSeriesAuthorGroup, setCurrentSeriesAuthorGroup] = useState<{ + groupBy: typeof LibraryGroupByType.Series | typeof LibraryGroupByType.Author; + groupName: string; + } | null>(null); const [booksTransferProgress, setBooksTransferProgress] = useState<{ [key: string]: number | null; }>({}); @@ -379,6 +390,36 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP setCurrentGroupPath(groupName); }, [libraryBooks, searchParams, getGroupName]); + // Track current series/author group for navigation header + useEffect(() => { + const groupId = searchParams?.get('group') || ''; + const groupByParam = searchParams?.get('groupBy'); + const groupBy = ensureLibraryGroupByType(groupByParam, settings.libraryGroupBy); + + if ( + groupId && + (groupBy === LibraryGroupByType.Series || groupBy === LibraryGroupByType.Author) + ) { + // Find the group to get its name + const allGroups = createBookGroups( + libraryBooks.filter((b) => !b.deletedAt), + groupBy, + ); + const targetGroup = findGroupById(allGroups, groupId); + + if (targetGroup) { + setCurrentSeriesAuthorGroup({ + groupBy, + groupName: targetGroup.displayName || targetGroup.name, + }); + } else { + setCurrentSeriesAuthorGroup(null); + } + } else { + setCurrentSeriesAuthorGroup(null); + } + }, [libraryBooks, searchParams, settings.libraryGroupBy]); + useEffect(() => { if (demoBooks.length > 0 && libraryLoaded) { const newLibrary = [...libraryBooks]; @@ -804,6 +845,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
)} + {currentSeriesAuthorGroup && ( + + )} {showBookshelf && (libraryBooks.some((book) => !book.deletedAt) ? ( { + if (value && VALID_SORT_TYPES.includes(value as LibrarySortByType)) { + return value as LibrarySortByType; + } + return fallback; +}; + +/** + * Safely cast a query parameter to LibraryGroupByType with fallback. + * Returns the value if valid, otherwise returns the fallback. + */ +export const ensureLibraryGroupByType = ( + value: string | null | undefined, + fallback: LibraryGroupByType, +): LibraryGroupByType => { + if (value && VALID_GROUP_BY_TYPES.includes(value as LibraryGroupByType)) { + return value as LibraryGroupByType; + } + return fallback; +}; + +/** + * Find a group by ID from a list of bookshelf items. + * Works for both manual groups and series/author groups. + */ +export const findGroupById = ( + items: (Book | BooksGroup)[], + groupId: string, +): BooksGroup | undefined => { + return items.find((item): item is BooksGroup => 'books' in item && item.id === groupId); +}; + +/** + * Get the display name for a group, useful for breadcrumbs. + */ +export const getGroupDisplayName = ( + items: (Book | BooksGroup)[], + groupId: string, +): string | undefined => { + const group = findGroupById(items, groupId); + return group?.displayName || group?.name; +}; export const createBookFilter = (queryTerm: string | null) => (item: Book) => { if (!queryTerm) return true; @@ -33,21 +91,21 @@ export const createBookFilter = (queryTerm: string | null) => (item: Book) => { export const createBookSorter = (sortBy: string, uiLanguage: string) => (a: Book, b: Book) => { switch (sortBy) { - case 'title': + case LibrarySortByType.Title: const aTitle = formatTitle(a.title); const bTitle = formatTitle(b.title); return aTitle.localeCompare(bTitle, uiLanguage || navigator.language); - case '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 'updated': + case LibrarySortByType.Updated: return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); - case 'created': + case LibrarySortByType.Created: return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - case 'format': + case LibrarySortByType.Format: return a.format.localeCompare(b.format, uiLanguage || navigator.language); - case 'published': + case LibrarySortByType.Published: const aPublished = a.metadata?.published || '0001-01-01'; const bPublished = b.metadata?.published || '0001-01-01'; @@ -81,3 +139,229 @@ export const getBreadcrumbs = (currentPath: string) => { path: segments.slice(0, index + 1).join('/'), })); }; + +/** + * Parse a combined author string into individual author names. + * Handles common separators like ", ", " & ", " and ". + */ +export const parseAuthors = (authorString: string): string[] => { + if (!authorString || !authorString.trim()) { + return []; + } + + // Split by common separators: comma, ampersand, "and" + // Use regex to handle variations with different spacing + const authors = authorString + .split(/\s*(?:,|&|\band\b)\s*/i) + .map((author) => author.trim()) + .filter((author) => author.length > 0); + + return authors; +}; + +/** + * Create groups from books based on the groupBy setting. + * Returns a mix of BooksGroup and ungrouped Book items. + */ +export const createBookGroups = ( + books: Book[], + groupBy: LibraryGroupByType, +): (Book | BooksGroup)[] => { + // Filter out deleted books + const activeBooks = books.filter((book) => !book.deletedAt); + + if (groupBy === LibraryGroupByType.None) { + return activeBooks; + } + + if (groupBy === LibraryGroupByType.Series) { + return createSeriesGroups(activeBooks); + } + + if (groupBy === LibraryGroupByType.Author) { + return createAuthorGroups(activeBooks); + } + + // 'manual' mode is handled separately by generateBookshelfItems + return activeBooks; +}; + +/** + * Group books by series metadata. + * Books without series appear as individual items. + */ +const createSeriesGroups = (books: Book[]): (Book | BooksGroup)[] => { + const seriesMap = new Map(); + const ungroupedBooks: Book[] = []; + + for (const book of books) { + const seriesName = book.metadata?.series?.trim(); + + if (seriesName) { + const existing = seriesMap.get(seriesName); + if (existing) { + existing.push(book); + } else { + seriesMap.set(seriesName, [book]); + } + } else { + ungroupedBooks.push(book); + } + } + + const groups: BooksGroup[] = Array.from(seriesMap.entries()).map(([seriesName, seriesBooks]) => ({ + id: md5Fingerprint(`series:${seriesName}`), + name: seriesName, + displayName: seriesName, + books: seriesBooks, + updatedAt: Math.max(...seriesBooks.map((b) => b.updatedAt)), + })); + + return [...groups, ...ungroupedBooks]; +}; + +/** + * Group books by author. + * Books with multiple authors appear in ALL matching author groups. + * Books without author appear as individual items. + */ +const createAuthorGroups = (books: Book[]): (Book | BooksGroup)[] => { + const authorMap = new Map(); + const ungroupedBooks: Book[] = []; + + for (const book of books) { + const authorString = book.author?.trim(); + + if (!authorString) { + ungroupedBooks.push(book); + continue; + } + + const authors = parseAuthors(authorString); + + if (authors.length === 0) { + ungroupedBooks.push(book); + continue; + } + + // Add book to each author's group + for (const author of authors) { + const existing = authorMap.get(author); + if (existing) { + existing.push(book); + } else { + authorMap.set(author, [book]); + } + } + } + + const groups: BooksGroup[] = Array.from(authorMap.entries()).map(([authorName, authorBooks]) => ({ + id: md5Fingerprint(`author:${authorName}`), + name: authorName, + displayName: authorName, + books: authorBooks, + updatedAt: Math.max(...authorBooks.map((b) => b.updatedAt)), + })); + + return [...groups, ...ungroupedBooks]; +}; + +/** + * Create a sorter for books within a group. + * For series groups: sort by seriesIndex first, then by global sort for items without index. + * For author groups: follow global sort setting. + */ +export const createWithinGroupSorter = + (groupBy: LibraryGroupByType, sortBy: LibrarySortByType, uiLanguage: string) => + (a: Book, b: Book): number => { + if (groupBy === LibraryGroupByType.Series) { + const aIndex = a.metadata?.seriesIndex; + const bIndex = b.metadata?.seriesIndex; + + // Both have series index - sort by index + if (aIndex != null && bIndex != null) { + return aIndex - bIndex; + } + + // Only one has series index - the one with index comes first + if (aIndex != null) return -1; + if (bIndex != null) return 1; + + // Neither has series index - fall back to global sort + return createBookSorter(sortBy, uiLanguage)(a, b); + } + + // For author and other groupings, use global sort + return createBookSorter(sortBy, uiLanguage)(a, b); + }; + +/** + * Get the aggregate sort value from a group for sorting groups. + */ +export const getGroupSortValue = ( + group: BooksGroup, + sortBy: LibrarySortByType, +): number | string => { + const books = group.books; + + if (books.length === 0) { + return sortBy === LibrarySortByType.Title || + sortBy === LibrarySortByType.Author || + sortBy === LibrarySortByType.Format + ? group.name + : 0; + } + + switch (sortBy) { + case LibrarySortByType.Title: + case LibrarySortByType.Author: + case LibrarySortByType.Format: + // For text-based sorts, use the group name + // This isn't perfect, especially with the + return group.name; + + case LibrarySortByType.Updated: + // Return the most recent updatedAt + return Math.max(...books.map((b) => b.updatedAt)); + + case LibrarySortByType.Created: + // Return the most recent createdAt + return Math.max(...books.map((b) => b.createdAt)); + + case LibrarySortByType.Published: { + // Return the most recent published date + const publishedDates = books + .map((b) => b.metadata?.published) + .filter((d): d is string => !!d) + .map((d) => new Date(d).getTime()) + .filter((t) => !isNaN(t)); + + return publishedDates.length > 0 ? Math.max(...publishedDates) : 0; + } + + default: + return Math.max(...books.map((b) => b.updatedAt)); + } +}; + +/** + * Create a sorter for groups themselves based on sort criteria. + */ +export const createGroupSorter = + (sortBy: LibrarySortByType, uiLanguage: string) => + (a: BooksGroup, b: BooksGroup): number => { + const aValue = getGroupSortValue(a, sortBy); + const bValue = getGroupSortValue(b, sortBy); + + // String comparison for text-based sorts + if (typeof aValue === 'string' && typeof bValue === 'string') { + return aValue.localeCompare(bValue, uiLanguage || navigator.language); + } + + // Numeric comparison for date-based sorts + if (typeof aValue === 'number' && typeof bValue === 'number') { + return aValue - bValue; + } + + return 0; + }; diff --git a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx index 3ec54dff..f1ad2e04 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx @@ -159,7 +159,7 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen }) ) : ( ))} -
+ {settings.kosync.enabled && ( <> @@ -169,7 +169,7 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen }) )} {appService?.isDesktopApp && ( <> -
+ = ({ menuClassName, setIsDropdownOpen }) /> )} -
+ -
+ = ({ menuClassName, setIsDropdownOpen }) onClick={handleToggleSortTOC} /> -
+ {isWebAppPlatform() && } diff --git a/apps/readest-app/src/app/reader/components/sidebar/SearchOptions.tsx b/apps/readest-app/src/app/reader/components/sidebar/SearchOptions.tsx index 93ade09d..e36f4823 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/SearchOptions.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/SearchOptions.tsx @@ -64,7 +64,7 @@ const SearchOptions: React.FC = ({ isActive={searchConfig.scope === 'section'} onClick={() => updateConfig('scope', 'section')} /> -
+