diff --git a/.run/SeforimApp [run].run.xml b/.run/SeforimApp [run].run.xml
index 058c9f76..024a8205 100644
--- a/.run/SeforimApp [run].run.xml
+++ b/.run/SeforimApp [run].run.xml
@@ -4,6 +4,7 @@
diff --git a/SeforimApp/build.gradle.kts b/SeforimApp/build.gradle.kts
index 85d1930f..9e60d76a 100644
--- a/SeforimApp/build.gradle.kts
+++ b/SeforimApp/build.gradle.kts
@@ -1,7 +1,7 @@
+import dev.nucleusframework.desktop.application.dsl.ReleaseChannel
+import dev.nucleusframework.desktop.application.dsl.ReleaseType
+import dev.nucleusframework.desktop.application.dsl.TargetFormat
import io.github.kdroidfilter.buildsrc.Versioning
-import io.github.kdroidfilter.nucleus.desktop.application.dsl.ReleaseChannel
-import io.github.kdroidfilter.nucleus.desktop.application.dsl.ReleaseType
-import io.github.kdroidfilter.nucleus.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.reload.gradle.ComposeHotRun
plugins {
@@ -82,6 +82,7 @@ kotlin {
implementation(libs.multiplatformSettings)
implementation(libs.platformtools.core)
implementation(libs.nucleus.core.runtime)
+ implementation(libs.nucleus.application)
implementation(libs.nucleus.aot.runtime)
implementation(libs.nucleus.darkmode.detector)
implementation(libs.platformtools.appmanager)
@@ -138,7 +139,8 @@ kotlin {
api(project(":jewel"))
implementation(project(":earthwidget"))
implementation(libs.nucleus.system.color)
- implementation(libs.nucleus.decorated.window)
+ implementation(libs.nucleus.decorated.window.core)
+ implementation(libs.nucleus.decorated.window.tao)
implementation(libs.nucleus.decorated.window.jewel)
implementation(libs.nucleus.graalvm.runtime)
implementation(libs.nucleus.updater.runtime)
@@ -257,7 +259,6 @@ nucleus.application {
// Package-time resources root; include files under OS-specific subfolders (common, macos, windows, linux)
appResourcesRootDir.set(layout.projectDirectory.dir("src/jvmMain/assets"))
- splashImage = "splash.png"
enableAotCache = true
homepage = "https://zayitapp.com"
licenseFile.set(File(project.rootDir, "LICENSE"))
diff --git a/SeforimApp/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresets.kt b/SeforimApp/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresets.kt
new file mode 100644
index 00000000..2a56a141
--- /dev/null
+++ b/SeforimApp/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresets.kt
@@ -0,0 +1,245 @@
+// DO NOT EDIT.
+// This file is auto-generated by the catalog generator.
+// To regenerate: ./gradlew :cataloggen:generatePrecomputedCatalog
+// Manual changes will be lost.
+@file:Suppress("ktlint")
+
+package io.github.kdroidfilter.seforimapp.catalog
+
+import kotlin.Long
+import kotlin.String
+import kotlin.Suppress
+import kotlin.collections.List
+
+public data class BookRef(
+ public val id: Long,
+ public val title: String,
+)
+
+public data class TocQuickLink(
+ public val label: String,
+ public val tocEntryId: Long,
+ public val firstLineId: Long?,
+)
+
+public sealed interface DropdownSpec
+
+public data class CategoryDropdownSpec(
+ public val categoryId: Long,
+) : DropdownSpec
+
+public data class MultiCategoryDropdownSpec(
+ public val labelCategoryId: Long,
+ public val bookCategoryIds: List,
+) : DropdownSpec
+
+public data class TocQuickLinksSpec(
+ public val bookId: Long,
+ public val links: List,
+) : DropdownSpec
+
+public object CatalogPresets {
+ public object Ids {
+ public object Categories {
+ /**
+ * תנ״ך
+ */
+ public const val TANAKH: Long = 1L
+
+ /**
+ * תורה
+ */
+ public const val TORAH: Long = 2L
+
+ /**
+ * נביאים
+ */
+ public const val NEVIIM: Long = 3L
+
+ /**
+ * כתובים
+ */
+ public const val KETUVIM: Long = 4L
+
+ /**
+ * משנה
+ */
+ public const val MISHNA: Long = 5L
+
+ /**
+ * סדר זרעים
+ */
+ public const val MISHNA_ZERAIM: Long = 6L
+
+ /**
+ * סדר מועד
+ */
+ public const val MISHNA_MOED: Long = 7L
+
+ /**
+ * סדר נשים
+ */
+ public const val MISHNA_NASHIM: Long = 8L
+
+ /**
+ * סדר נזיקין
+ */
+ public const val MISHNA_NEZIKIN: Long = 9L
+
+ /**
+ * סדר קדשים
+ */
+ public const val MISHNA_KODASHIM: Long = 10L
+
+ /**
+ * סדר טהרות
+ */
+ public const val MISHNA_TAHAROT: Long = 11L
+
+ /**
+ * בבלי
+ */
+ public const val BAVLI: Long = 13L
+
+ /**
+ * סדר זרעים
+ */
+ public const val BAVLI_ZERAIM: Long = 14L
+
+ /**
+ * סדר מועד
+ */
+ public const val BAVLI_MOED: Long = 15L
+
+ /**
+ * סדר נשים
+ */
+ public const val BAVLI_NASHIM: Long = 16L
+
+ /**
+ * סדר נזיקין
+ */
+ public const val BAVLI_NEZIKIN: Long = 17L
+
+ /**
+ * סדר קדשים
+ */
+ public const val BAVLI_KODASHIM: Long = 18L
+
+ /**
+ * סדר טהרות
+ */
+ public const val BAVLI_TAHAROT: Long = 19L
+
+ /**
+ * ירושלמי
+ */
+ public const val YERUSHALMI: Long = 20L
+
+ /**
+ * סדר זרעים
+ */
+ public const val YERUSHALMI_ZERAIM: Long = 21L
+
+ /**
+ * סדר מועד
+ */
+ public const val YERUSHALMI_MOED: Long = 22L
+
+ /**
+ * סדר נשים
+ */
+ public const val YERUSHALMI_NASHIM: Long = 23L
+
+ /**
+ * סדר נזיקין
+ */
+ public const val YERUSHALMI_NEZIKIN: Long = 24L
+
+ /**
+ * סדר טהרות
+ */
+ public const val YERUSHALMI_TAHAROT: Long = 25L
+
+ /**
+ * משנה תורה
+ */
+ public const val MISHNE_TORAH: Long = 45L
+
+ /**
+ * טור
+ */
+ public const val TUR: Long = 61L
+
+ /**
+ * שולחן ערוך
+ */
+ public const val SHULCHAN_ARUCH: Long = 62L
+ }
+
+ public object Books {
+ /**
+ * טור
+ */
+ public const val TUR: Long = 380L
+ }
+
+ public object TocTexts {
+ /**
+ * אורח חיים
+ */
+ public const val ORACH_CHAIM: Long = 3_878L
+
+ /**
+ * יורה דעה
+ */
+ public const val YOREH_DEAH: Long = 4_521L
+
+ /**
+ * אבן העזר
+ */
+ public const val EVEN_HAEZER: Long = 4_522L
+
+ /**
+ * חושן משפט
+ */
+ public const val CHOSHEN_MISHPAT: Long = 4_523L
+ }
+ }
+
+ public object Dropdowns {
+ public val HOME: List = listOf(
+ MultiCategoryDropdownSpec(1L, listOf(2L, 3L, 4L)),
+ MultiCategoryDropdownSpec(5L, listOf(6L, 7L, 8L, 9L, 10L, 11L)),
+ MultiCategoryDropdownSpec(13L, listOf(14L, 15L, 16L, 17L, 18L, 19L)),
+ MultiCategoryDropdownSpec(20L, listOf(21L, 22L, 23L, 24L, 25L)),
+ CategoryDropdownSpec(62L),
+ TocQuickLinksSpec(380L, listOf(TocQuickLink("אורח חיים", 30_149L, 252_607), TocQuickLink("יורה דעה", 30_848L, 254_014), TocQuickLink("אבן העזר", 31_253L, 254_828), TocQuickLink("חושן משפט", 31_433L, 255_198))),
+ )
+
+ public val TANAKH: DropdownSpec = MultiCategoryDropdownSpec(1L, listOf(2L, 3L, 4L))
+
+ public val TORAH: DropdownSpec = CategoryDropdownSpec(2L)
+
+ public val NEVIIM: DropdownSpec = CategoryDropdownSpec(3L)
+
+ public val KETUVIM: DropdownSpec = CategoryDropdownSpec(4L)
+
+ public val MISHNA: DropdownSpec =
+ MultiCategoryDropdownSpec(5L, listOf(6L, 7L, 8L, 9L, 10L, 11L))
+
+ public val BAVLI: DropdownSpec =
+ MultiCategoryDropdownSpec(13L, listOf(14L, 15L, 16L, 17L, 18L, 19L))
+
+ public val YERUSHALMI: DropdownSpec =
+ MultiCategoryDropdownSpec(20L, listOf(21L, 22L, 23L, 24L, 25L))
+
+ public val SHULCHAN_ARUCH: DropdownSpec = CategoryDropdownSpec(62L)
+
+ public val MISHNE_TORAH: DropdownSpec =
+ MultiCategoryDropdownSpec(45L, listOf(46L, 47L, 48L, 49L, 50L, 51L, 52L, 53L, 54L, 55L, 59L, 56L, 57L, 58L, 60L))
+
+ public val TUR_QUICK_LINKS: DropdownSpec =
+ TocQuickLinksSpec(380L, listOf(TocQuickLink("אורח חיים", 30_149L, 252_607), TocQuickLink("יורה דעה", 30_848L, 254_014), TocQuickLink("אבן העזר", 31_253L, 254_828), TocQuickLink("חושן משפט", 31_433L, 255_198)))
+ }
+}
diff --git a/SeforimApp/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/catalog/PrecomputedCatalog.kt b/SeforimApp/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/catalog/PrecomputedCatalog.kt
deleted file mode 100644
index fc98976f..00000000
--- a/SeforimApp/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/catalog/PrecomputedCatalog.kt
+++ /dev/null
@@ -1,947 +0,0 @@
-// DO NOT EDIT.
-// This file is auto-generated by the catalog generator.
-// To regenerate: ./gradlew :cataloggen:generatePrecomputedCatalog
-// Manual changes will be lost.
-package io.github.kdroidfilter.seforimapp.catalog
-
-import kotlin.Long
-import kotlin.String
-import kotlin.collections.List
-import kotlin.collections.Map
-
-public data class BookRef(
- public val id: Long,
- public val title: String,
-)
-
-public data class TocQuickLink(
- public val label: String,
- public val tocEntryId: Long,
- public val firstLineId: Long?,
-)
-
-public sealed interface DropdownSpec
-
-public data class CategoryDropdownSpec(
- public val categoryId: Long,
-) : DropdownSpec
-
-public data class MultiCategoryDropdownSpec(
- public val labelCategoryId: Long,
- public val bookCategoryIds: List,
-) : DropdownSpec
-
-public data class TocQuickLinksSpec(
- public val bookId: Long,
- public val tocTextIds: List,
-) : DropdownSpec
-
-public object PrecomputedCatalog {
- public val BOOK_TITLES: Map =
- mapOf(
- 1L to "בראשית",
- 2L to "שמות",
- 3L to "ויקרא",
- 4L to "במדבר",
- 5L to "דברים",
- 6L to "יהושע",
- 7L to "שופטים",
- 8L to "שמואל א",
- 9L to "שמואל ב",
- 10L to "מלכים א",
- 11L to "מלכים ב",
- 12L to "ישעיהו",
- 13L to "ירמיהו",
- 14L to "יחזקאל",
- 15L to "הושע",
- 16L to "יואל",
- 17L to "עמוס",
- 18L to "עובדיה",
- 19L to "יונה",
- 20L to "מיכה",
- 21L to "נחום",
- 22L to "חבקוק",
- 23L to "צפניה",
- 24L to "חגי",
- 25L to "זכריה",
- 26L to "מלאכי",
- 27L to "רות",
- 28L to "תהילים",
- 29L to "איוב",
- 30L to "משלי",
- 31L to "שיר השירים",
- 32L to "קהלת",
- 33L to "איכה",
- 34L to "אסתר",
- 35L to "דניאל",
- 36L to "עזרא",
- 37L to "נחמיה",
- 38L to "דברי הימים א",
- 39L to "דברי הימים ב",
- 40L to "משנה ברכות",
- 41L to "משנה פאה",
- 42L to "משנה דמאי",
- 43L to "משנה כלאים",
- 44L to "משנה שביעית",
- 45L to "משנה תרומות",
- 46L to "משנה מעשרות",
- 47L to "משנה מעשר שני",
- 48L to "משנה חלה",
- 49L to "משנה ערלה",
- 50L to "משנה ביכורים",
- 51L to "משנה שבת",
- 52L to "משנה עירובין",
- 53L to "משנה פסחים",
- 54L to "משנה שקלים",
- 55L to "משנה יומא",
- 56L to "משנה סוכה",
- 57L to "משנה ביצה",
- 58L to "משנה ראש השנה",
- 59L to "משנה תענית",
- 60L to "משנה מגילה",
- 61L to "משנה מועד קטן",
- 62L to "משנה חגיגה",
- 63L to "משנה יבמות",
- 64L to "משנה כתובות",
- 65L to "משנה נדרים",
- 66L to "משנה נזיר",
- 67L to "משנה סוטה",
- 68L to "משנה גיטין",
- 69L to "משנה קידושין",
- 70L to "משנה בבא קמא",
- 71L to "משנה בבא מציעא",
- 72L to "משנה בבא בתרא",
- 73L to "משנה סנהדרין",
- 74L to "משנה מכות",
- 75L to "משנה שבועות",
- 76L to "משנה עדיות",
- 77L to "משנה עבודה זרה",
- 78L to "משנה אבות",
- 79L to "משנה הוריות",
- 80L to "משנה זבחים",
- 81L to "משנה מנחות",
- 82L to "משנה חולין",
- 83L to "משנה בכורות",
- 84L to "משנה ערכין",
- 85L to "משנה תמורה",
- 86L to "משנה כריתות",
- 87L to "משנה מעילה",
- 88L to "משנה תמיד",
- 89L to "משנה מדות",
- 90L to "משנה קינים",
- 91L to "משנה כלים",
- 92L to "משנה אהלות",
- 93L to "משנה נגעים",
- 94L to "משנה פרה",
- 95L to "משנה טהרות",
- 96L to "משנה מקואות",
- 97L to "משנה נדה",
- 98L to "משנה מכשירין",
- 99L to "משנה זבים",
- 100L to "משנה טבול יום",
- 101L to "משנה ידים",
- 102L to "משנה עוקצים",
- 103L to "ברכות",
- 104L to "שבת",
- 105L to "עירובין",
- 106L to "פסחים",
- 107L to "יומא",
- 108L to "סוכה",
- 109L to "ביצה",
- 110L to "ראש השנה",
- 111L to "תענית",
- 112L to "מגילה",
- 113L to "מועד קטן",
- 114L to "חגיגה",
- 115L to "יבמות",
- 116L to "כתובות",
- 117L to "נדרים",
- 118L to "נזיר",
- 119L to "סוטה",
- 120L to "גיטין",
- 121L to "קידושין",
- 122L to "בבא קמא",
- 123L to "בבא מציעא",
- 124L to "בבא בתרא",
- 125L to "סנהדרין",
- 126L to "מכות",
- 127L to "שבועות",
- 128L to "עבודה זרה",
- 129L to "הוריות",
- 130L to "זבחים",
- 131L to "מנחות",
- 132L to "חולין",
- 133L to "בכורות",
- 134L to "ערכין",
- 135L to "תמורה",
- 136L to "כריתות",
- 137L to "מעילה",
- 138L to "תמיד",
- 139L to "נדה",
- 140L to "תלמוד ירושלמי ברכות",
- 141L to "תלמוד ירושלמי פאה",
- 142L to "תלמוד ירושלמי דמאי",
- 143L to "תלמוד ירושלמי כלאים",
- 144L to "תלמוד ירושלמי שביעית",
- 145L to "תלמוד ירושלמי תרומות",
- 146L to "תלמוד ירושלמי מעשרות",
- 147L to "תלמוד ירושלמי מעשר שני",
- 148L to "תלמוד ירושלמי חלה",
- 149L to "תלמוד ירושלמי ערלה",
- 150L to "תלמוד ירושלמי בכורים",
- 151L to "תלמוד ירושלמי שבת",
- 152L to "תלמוד ירושלמי עירובין",
- 153L to "תלמוד ירושלמי פסחים",
- 154L to "תלמוד ירושלמי שקלים",
- 155L to "תלמוד ירושלמי יומא",
- 156L to "תלמוד ירושלמי סוכה",
- 157L to "תלמוד ירושלמי ביצה",
- 158L to "תלמוד ירושלמי ראש השנה",
- 159L to "תלמוד ירושלמי תענית",
- 160L to "תלמוד ירושלמי מגילה",
- 161L to "תלמוד ירושלמי מועד קטן",
- 162L to "תלמוד ירושלמי חגיגה",
- 163L to "תלמוד ירושלמי יבמות",
- 164L to "תלמוד ירושלמי כתובות",
- 165L to "תלמוד ירושלמי נדרים",
- 166L to "תלמוד ירושלמי נזיר",
- 167L to "תלמוד ירושלמי סוטה",
- 168L to "תלמוד ירושלמי גיטין",
- 169L to "תלמוד ירושלמי קידושין",
- 170L to "תלמוד ירושלמי בבא קמא",
- 171L to "תלמוד ירושלמי בבא מציעא",
- 172L to "תלמוד ירושלמי בבא בתרא",
- 173L to "תלמוד ירושלמי סנהדרין",
- 174L to "תלמוד ירושלמי מכות",
- 175L to "תלמוד ירושלמי שבועות",
- 176L to "תלמוד ירושלמי עבודה זרה",
- 177L to "תלמוד ירושלמי הוריות",
- 178L to "תלמוד ירושלמי נדה",
- 293L to "משנה תורה, מסירת תורה שבעל פה",
- 294L to "משנה תורה, מצוות לא תעשה",
- 295L to "משנה תורה, מצוות עשה",
- 296L to "משנה תורה, תוכן החיבור",
- 297L to "משנה תורה, הלכות יסודי התורה",
- 298L to "משנה תורה, הלכות דעות",
- 299L to "משנה תורה, הלכות תלמוד תורה",
- 300L to "משנה תורה, הלכות עבודה זרה וחוקות הגויים",
- 301L to "משנה תורה, הלכות תשובה",
- 302L to "משנה תורה, הלכות קריאת שמע",
- 303L to "משנה תורה, הלכות תפילה וברכת כהנים",
- 304L to "משנה תורה, הלכות תפילין ומזוזה וספר תורה",
- 305L to "משנה תורה, הלכות ציצית",
- 306L to "משנה תורה, הלכות ברכות",
- 307L to "משנה תורה, הלכות מילה",
- 308L to "משנה תורה, סדר התפילה",
- 309L to "משנה תורה, הלכות שבת",
- 310L to "משנה תורה, הלכות עירובין",
- 311L to "משנה תורה, הלכות שקלים",
- 312L to "משנה תורה, הלכות קידוש החודש",
- 313L to "משנה תורה, הלכות תעניות",
- 314L to "משנה תורה, הלכות מגילה וחנוכה",
- 315L to "משנה תורה, הלכות שופר וסוכה ולולב",
- 316L to "משנה תורה, הלכות שביתת יום טוב",
- 317L to "משנה תורה, הלכות שביתת עשור",
- 318L to "משנה תורה, הלכות חמץ ומצה",
- 319L to "משנה תורה, הלכות אישות",
- 320L to "משנה תורה, הלכות גירושין",
- 321L to "משנה תורה, הלכות יבום וחליצה",
- 322L to "משנה תורה, הלכות סוטה",
- 323L to "משנה תורה, הלכות נערה בתולה",
- 324L to "משנה תורה, הלכות מאכלות אסורות",
- 325L to "משנה תורה, הלכות שחיטה",
- 326L to "משנה תורה, הלכות איסורי ביאה",
- 327L to "משנה תורה, הלכות נדרים",
- 328L to "משנה תורה, הלכות נזירות",
- 329L to "משנה תורה, הלכות ערכים וחרמין",
- 330L to "משנה תורה, הלכות שבועות",
- 331L to "משנה תורה, הלכות תרומות",
- 332L to "משנה תורה, הלכות מעשרות",
- 333L to "משנה תורה, הלכות מעשר שני ונטע רבעי",
- 334L to "משנה תורה, הלכות שמיטה ויובל",
- 335L to "משנה תורה, הלכות מתנות עניים",
- 336L to "משנה תורה, הלכות כלאים",
- 337L to "משנה תורה, הלכות ביכורים ושאר מתנות כהונה שבגבולין",
- 338L to "משנה תורה, הלכות בית הבחירה",
- 339L to "משנה תורה, הלכות כלי המקדש והעובדין בו",
- 340L to "משנה תורה, הלכות איסורי המזבח",
- 341L to "משנה תורה, הלכות ביאת מקדש",
- 342L to "משנה תורה, הלכות מעשה הקרבנות",
- 343L to "משנה תורה, הלכות עבודת יום הכפורים",
- 344L to "משנה תורה, הלכות פסולי המוקדשין",
- 345L to "משנה תורה, הלכות תמידים ומוספין",
- 346L to "משנה תורה, הלכות מעילה",
- 347L to "משנה תורה, הלכות בכורות",
- 348L to "משנה תורה, הלכות שגגות",
- 349L to "משנה תורה, הלכות מחוסרי כפרה",
- 350L to "משנה תורה, הלכות תמורה",
- 351L to "משנה תורה, הלכות קרבן פסח",
- 352L to "משנה תורה, הלכות חגיגה",
- 353L to "משנה תורה, הלכות נזקי ממון",
- 354L to "משנה תורה, הלכות גזילה ואבידה",
- 355L to "משנה תורה, הלכות גניבה",
- 356L to "משנה תורה, הלכות חובל ומזיק",
- 357L to "משנה תורה, הלכות רוצח ושמירת נפש",
- 358L to "משנה תורה, הלכות מכירה",
- 359L to "משנה תורה, הלכות זכייה ומתנה",
- 360L to "משנה תורה, הלכות שלוחין ושותפין",
- 361L to "משנה תורה, הלכות עבדים",
- 362L to "משנה תורה, הלכות שכנים",
- 363L to "משנה תורה, הלכות מלווה ולווה",
- 364L to "משנה תורה, הלכות שכירות",
- 365L to "משנה תורה, הלכות נחלות",
- 366L to "משנה תורה, הלכות טוען ונטען",
- 367L to "משנה תורה, הלכות שאלה ופיקדון",
- 368L to "משנה תורה, הלכות שאר אבות הטומאות",
- 369L to "משנה תורה, הלכות טומאת מת",
- 370L to "משנה תורה, הלכות טומאת צרעת",
- 371L to "משנה תורה, הלכות מטמאי משכב ומושב",
- 372L to "משנה תורה, הלכות טומאת אוכלים",
- 373L to "משנה תורה, הלכות פרה אדומה",
- 374L to "משנה תורה, הלכות מקואות",
- 375L to "משנה תורה, הלכות כלים",
- 376L to "משנה תורה, הלכות סנהדרין והעונשין המסורין להם",
- 377L to "משנה תורה, הלכות עדות",
- 378L to "משנה תורה, הלכות ממרים",
- 379L to "משנה תורה, הלכות אבל",
- 380L to "משנה תורה, הלכות מלכים ומלחמות",
- 381L to "טור",
- 382L to "שולחן ערוך, הקדמה",
- 383L to "שולחן ערוך, אורח חיים",
- 384L to "שולחן ערוך, יורה דעה",
- 385L to "שולחן ערוך, אבן העזר",
- 386L to "שולחן ערוך, חושן משפט",
- 2_533L to "פרי מגדים על אורח חיים",
- )
-
- public val CATEGORY_TITLES: Map =
- mapOf(
- 1L to "תנ״ך",
- 2L to "תורה",
- 3L to "נביאים",
- 4L to "כתובים",
- 5L to "משנה",
- 6L to "סדר זרעים",
- 7L to "סדר מועד",
- 8L to "סדר נשים",
- 9L to "סדר נזיקין",
- 10L to "סדר קדשים",
- 11L to "סדר טהרות",
- 13L to "תלמוד בבלי",
- 14L to "סדר זרעים",
- 15L to "סדר מועד",
- 16L to "סדר נשים",
- 17L to "סדר נזיקין",
- 18L to "סדר קדשים",
- 19L to "סדר טהרות",
- 20L to "תלמוד ירושלמי",
- 21L to "סדר זרעים",
- 22L to "סדר מועד",
- 23L to "סדר נשים",
- 24L to "סדר נזיקין",
- 25L to "סדר טהרות",
- 45L to "משנה תורה",
- 46L to "הקדמה",
- 47L to "ספר מדע",
- 48L to "ספר אהבה",
- 49L to "ספר זמנים",
- 50L to "ספר נשים",
- 51L to "ספר קדושה",
- 52L to "ספר הפלאה",
- 53L to "ספר זרעים",
- 54L to "ספר עבודה",
- 55L to "ספר קורבנות",
- 56L to "ספר נזיקין",
- 57L to "ספר קניין",
- 58L to "ספר משפטים",
- 59L to "ספר טהרה",
- 60L to "ספר שופטים",
- 61L to "טור",
- 62L to "שולחן ערוך",
- )
-
- public val CATEGORY_BOOKS: Map> =
- mapOf(
- 1L to listOf(),
- 2L to listOf(BookRef(1L, "בראשית"), BookRef(2L, "שמות"), BookRef(3L, "ויקרא"), BookRef(4L, "במדבר"), BookRef(5L, "דברים")),
- 3L to
- listOf(
- BookRef(6L, "יהושע"),
- BookRef(7L, "שופטים"),
- BookRef(8L, "שמואל א"),
- BookRef(9L, "שמואל ב"),
- BookRef(10L, "מלכים א"),
- BookRef(11L, "מלכים ב"),
- BookRef(12L, "ישעיהו"),
- BookRef(13L, "ירמיהו"),
- BookRef(14L, "יחזקאל"),
- BookRef(15L, "הושע"),
- BookRef(16L, "יואל"),
- BookRef(17L, "עמוס"),
- BookRef(18L, "עובדיה"),
- BookRef(19L, "יונה"),
- BookRef(20L, "מיכה"),
- BookRef(21L, "נחום"),
- BookRef(22L, "חבקוק"),
- BookRef(23L, "צפניה"),
- BookRef(24L, "חגי"),
- BookRef(25L, "זכריה"),
- BookRef(26L, "מלאכי"),
- ),
- 4L to
- listOf(
- BookRef(28L, "תהילים"),
- BookRef(30L, "משלי"),
- BookRef(29L, "איוב"),
- BookRef(31L, "שיר השירים"),
- BookRef(27L, "רות"),
- BookRef(33L, "איכה"),
- BookRef(32L, "קהלת"),
- BookRef(34L, "אסתר"),
- BookRef(35L, "דניאל"),
- BookRef(36L, "עזרא"),
- BookRef(37L, "נחמיה"),
- BookRef(38L, "דברי הימים א"),
- BookRef(39L, "דברי הימים ב"),
- ),
- 5L to listOf(),
- 6L to
- listOf(
- BookRef(40L, "ברכות"),
- BookRef(41L, "פאה"),
- BookRef(42L, "דמאי"),
- BookRef(43L, "כלאים"),
- BookRef(44L, "שביעית"),
- BookRef(45L, "תרומות"),
- BookRef(46L, "מעשרות"),
- BookRef(47L, "מעשר שני"),
- BookRef(48L, "חלה"),
- BookRef(49L, "ערלה"),
- BookRef(50L, "ביכורים"),
- ),
- 7L to
- listOf(
- BookRef(51L, "שבת"),
- BookRef(52L, "עירובין"),
- BookRef(53L, "פסחים"),
- BookRef(54L, "שקלים"),
- BookRef(55L, "יומא"),
- BookRef(56L, "סוכה"),
- BookRef(57L, "ביצה"),
- BookRef(58L, "ראש השנה"),
- BookRef(59L, "תענית"),
- BookRef(60L, "מגילה"),
- BookRef(61L, "מועד קטן"),
- BookRef(62L, "חגיגה"),
- ),
- 8L to
- listOf(
- BookRef(63L, "יבמות"),
- BookRef(64L, "כתובות"),
- BookRef(65L, "נדרים"),
- BookRef(66L, "נזיר"),
- BookRef(67L, "סוטה"),
- BookRef(68L, "גיטין"),
- BookRef(69L, "קידושין"),
- ),
- 9L to
- listOf(
- BookRef(70L, "בבא קמא"),
- BookRef(71L, "בבא מציעא"),
- BookRef(72L, "בבא בתרא"),
- BookRef(73L, "סנהדרין"),
- BookRef(74L, "מכות"),
- BookRef(75L, "שבועות"),
- BookRef(76L, "עדיות"),
- BookRef(77L, "עבודה זרה"),
- BookRef(78L, "אבות"),
- BookRef(79L, "הוריות"),
- ),
- 10L to
- listOf(
- BookRef(80L, "זבחים"),
- BookRef(81L, "מנחות"),
- BookRef(82L, "חולין"),
- BookRef(83L, "בכורות"),
- BookRef(84L, "ערכין"),
- BookRef(85L, "תמורה"),
- BookRef(86L, "כריתות"),
- BookRef(87L, "מעילה"),
- BookRef(88L, "תמיד"),
- BookRef(89L, "מדות"),
- BookRef(90L, "קינים"),
- ),
- 11L to
- listOf(
- BookRef(91L, "כלים"),
- BookRef(92L, "אהלות"),
- BookRef(93L, "נגעים"),
- BookRef(94L, "פרה"),
- BookRef(95L, "טהרות"),
- BookRef(96L, "מקואות"),
- BookRef(97L, "נדה"),
- BookRef(98L, "מכשירין"),
- BookRef(99L, "זבים"),
- BookRef(100L, "טבול יום"),
- BookRef(101L, "ידים"),
- BookRef(102L, "עוקצים"),
- ),
- 13L to listOf(),
- 14L to listOf(BookRef(103L, "ברכות")),
- 15L to
- listOf(
- BookRef(104L, "שבת"),
- BookRef(105L, "עירובין"),
- BookRef(106L, "פסחים"),
- BookRef(110L, "ראש השנה"),
- BookRef(107L, "יומא"),
- BookRef(108L, "סוכה"),
- BookRef(109L, "ביצה"),
- BookRef(111L, "תענית"),
- BookRef(112L, "מגילה"),
- BookRef(113L, "מועד קטן"),
- BookRef(114L, "חגיגה"),
- ),
- 16L to
- listOf(
- BookRef(115L, "יבמות"),
- BookRef(116L, "כתובות"),
- BookRef(117L, "נדרים"),
- BookRef(118L, "נזיר"),
- BookRef(119L, "סוטה"),
- BookRef(120L, "גיטין"),
- BookRef(121L, "קידושין"),
- ),
- 17L to
- listOf(
- BookRef(122L, "בבא קמא"),
- BookRef(123L, "בבא מציעא"),
- BookRef(124L, "בבא בתרא"),
- BookRef(125L, "סנהדרין"),
- BookRef(126L, "מכות"),
- BookRef(127L, "שבועות"),
- BookRef(128L, "עבודה זרה"),
- BookRef(129L, "הוריות"),
- ),
- 18L to
- listOf(
- BookRef(130L, "זבחים"),
- BookRef(131L, "מנחות"),
- BookRef(132L, "חולין"),
- BookRef(133L, "בכורות"),
- BookRef(134L, "ערכין"),
- BookRef(135L, "תמורה"),
- BookRef(136L, "כריתות"),
- BookRef(137L, "מעילה"),
- BookRef(138L, "תמיד"),
- ),
- 19L to listOf(BookRef(139L, "נדה")),
- 20L to listOf(),
- 21L to
- listOf(
- BookRef(140L, "ירושלמי ברכות"),
- BookRef(141L, "ירושלמי פאה"),
- BookRef(142L, "ירושלמי דמאי"),
- BookRef(143L, "ירושלמי כלאים"),
- BookRef(144L, "ירושלמי שביעית"),
- BookRef(145L, "ירושלמי תרומות"),
- BookRef(146L, "ירושלמי מעשרות"),
- BookRef(147L, "ירושלמי מעשר שני"),
- BookRef(148L, "ירושלמי חלה"),
- BookRef(149L, "ירושלמי ערלה"),
- BookRef(150L, "ירושלמי בכורים"),
- ),
- 22L to
- listOf(
- BookRef(151L, "ירושלמי שבת"),
- BookRef(152L, "ירושלמי עירובין"),
- BookRef(153L, "ירושלמי פסחים"),
- BookRef(155L, "ירושלמי יומא"),
- BookRef(154L, "ירושלמי שקלים"),
- BookRef(156L, "ירושלמי סוכה"),
- BookRef(158L, "ירושלמי ראש השנה"),
- BookRef(157L, "ירושלמי ביצה"),
- BookRef(159L, "ירושלמי תענית"),
- BookRef(160L, "ירושלמי מגילה"),
- BookRef(162L, "ירושלמי חגיגה"),
- BookRef(161L, "ירושלמי מועד קטן"),
- ),
- 23L to
- listOf(
- BookRef(163L, "ירושלמי יבמות"),
- BookRef(167L, "ירושלמי סוטה"),
- BookRef(164L, "ירושלמי כתובות"),
- BookRef(165L, "ירושלמי נדרים"),
- BookRef(166L, "ירושלמי נזיר"),
- BookRef(168L, "ירושלמי גיטין"),
- BookRef(169L, "ירושלמי קידושין"),
- ),
- 24L to
- listOf(
- BookRef(170L, "ירושלמי בבא קמא"),
- BookRef(171L, "ירושלמי בבא מציעא"),
- BookRef(172L, "ירושלמי בבא בתרא"),
- BookRef(173L, "ירושלמי סנהדרין"),
- BookRef(175L, "ירושלמי שבועות"),
- BookRef(176L, "ירושלמי עבודה זרה"),
- BookRef(174L, "ירושלמי מכות"),
- BookRef(177L, "ירושלמי הוריות"),
- ),
- 25L to listOf(BookRef(178L, "ירושלמי נדה")),
- 45L to listOf(),
- 46L to
- listOf(
- BookRef(293L, "מסירת תורה שבעל פה"),
- BookRef(295L, "מצוות עשה"),
- BookRef(294L, "מצוות לא תעשה"),
- BookRef(296L, "תוכן החיבור"),
- ),
- 47L to
- listOf(
- BookRef(297L, "הלכות יסודי התורה"),
- BookRef(298L, "הלכות דעות"),
- BookRef(299L, "הלכות תלמוד תורה"),
- BookRef(300L, "הלכות עבודה זרה וחוקות הגויים"),
- BookRef(301L, "הלכות תשובה"),
- ),
- 48L to
- listOf(
- BookRef(302L, "הלכות קריאת שמע"),
- BookRef(303L, "הלכות תפילה וברכת כהנים"),
- BookRef(304L, "הלכות תפילין ומזוזה וספר תורה"),
- BookRef(305L, "הלכות ציצית"),
- BookRef(306L, "הלכות ברכות"),
- BookRef(307L, "הלכות מילה"),
- BookRef(308L, "סדר התפילה"),
- ),
- 49L to
- listOf(
- BookRef(309L, "הלכות שבת"),
- BookRef(310L, "הלכות עירובין"),
- BookRef(317L, "הלכות שביתת עשור"),
- BookRef(316L, "הלכות שביתת יום טוב"),
- BookRef(318L, "הלכות חמץ ומצה"),
- BookRef(315L, "הלכות שופר וסוכה ולולב"),
- BookRef(311L, "הלכות שקלים"),
- BookRef(312L, "הלכות קידוש החודש"),
- BookRef(313L, "הלכות תעניות"),
- BookRef(314L, "הלכות מגילה וחנוכה"),
- ),
- 50L to
- listOf(
- BookRef(319L, "הלכות אישות"),
- BookRef(320L, "הלכות גירושין"),
- BookRef(321L, "הלכות יבום וחליצה"),
- BookRef(323L, "הלכות נערה בתולה"),
- BookRef(322L, "הלכות סוטה"),
- ),
- 51L to listOf(BookRef(326L, "הלכות איסורי ביאה"), BookRef(324L, "הלכות מאכלות אסורות"), BookRef(325L, "הלכות שחיטה")),
- 52L to
- listOf(
- BookRef(330L, "הלכות שבועות"),
- BookRef(327L, "הלכות נדרים"),
- BookRef(328L, "הלכות נזירות"),
- BookRef(329L, "הלכות ערכים וחרמין"),
- ),
- 53L to
- listOf(
- BookRef(336L, "הלכות כלאים"),
- BookRef(335L, "הלכות מתנות עניים"),
- BookRef(331L, "הלכות תרומות"),
- BookRef(332L, "הלכות מעשרות"),
- BookRef(333L, "הלכות מעשר שני ונטע רבעי"),
- BookRef(337L, "הלכות ביכורים ושאר מתנות כהונה שבגבולין"),
- BookRef(334L, "הלכות שמיטה ויובל"),
- ),
- 54L to
- listOf(
- BookRef(338L, "הלכות בית הבחירה"),
- BookRef(339L, "הלכות כלי המקדש והעובדין בו"),
- BookRef(341L, "הלכות ביאת מקדש"),
- BookRef(340L, "הלכות איסורי המזבח"),
- BookRef(342L, "הלכות מעשה הקרבנות"),
- BookRef(345L, "הלכות תמידים ומוספין"),
- BookRef(344L, "הלכות פסולי המוקדשין"),
- BookRef(343L, "הלכות עבודת יום הכפורים"),
- BookRef(346L, "הלכות מעילה"),
- ),
- 55L to
- listOf(
- BookRef(351L, "הלכות קרבן פסח"),
- BookRef(352L, "הלכות חגיגה"),
- BookRef(347L, "הלכות בכורות"),
- BookRef(348L, "הלכות שגגות"),
- BookRef(349L, "הלכות מחוסרי כפרה"),
- BookRef(350L, "הלכות תמורה"),
- ),
- 56L to
- listOf(
- BookRef(353L, "הלכות נזקי ממון"),
- BookRef(355L, "הלכות גניבה"),
- BookRef(354L, "הלכות גזילה ואבידה"),
- BookRef(356L, "הלכות חובל ומזיק"),
- BookRef(357L, "הלכות רוצח ושמירת נפש"),
- ),
- 57L to
- listOf(
- BookRef(358L, "הלכות מכירה"),
- BookRef(359L, "הלכות זכייה ומתנה"),
- BookRef(362L, "הלכות שכנים"),
- BookRef(360L, "הלכות שלוחין ושותפין"),
- BookRef(361L, "הלכות עבדים"),
- ),
- 58L to
- listOf(
- BookRef(364L, "הלכות שכירות"),
- BookRef(367L, "הלכות שאלה ופיקדון"),
- BookRef(363L, "הלכות מלווה ולווה"),
- BookRef(366L, "הלכות טוען ונטען"),
- BookRef(365L, "הלכות נחלות"),
- ),
- 59L to
- listOf(
- BookRef(369L, "הלכות טומאת מת"),
- BookRef(373L, "הלכות פרה אדומה"),
- BookRef(370L, "הלכות טומאת צרעת"),
- BookRef(371L, "הלכות מטמאי משכב ומושב"),
- BookRef(368L, "הלכות שאר אבות הטומאות"),
- BookRef(372L, "הלכות טומאת אוכלים"),
- BookRef(375L, "הלכות כלים"),
- BookRef(374L, "הלכות מקואות"),
- ),
- 60L to
- listOf(
- BookRef(376L, "הלכות סנהדרין והעונשין המסורין להם"),
- BookRef(377L, "הלכות עדות"),
- BookRef(378L, "הלכות ממרים"),
- BookRef(379L, "הלכות אבל"),
- BookRef(380L, "הלכות מלכים ומלחמות"),
- ),
- 61L to listOf(BookRef(381L, "טור")),
- 62L to
- listOf(
- BookRef(382L, "הקדמה"),
- BookRef(383L, "אורח חיים"),
- BookRef(384L, "יורה דעה"),
- BookRef(385L, "אבן העזר"),
- BookRef(386L, "חושן משפט"),
- BookRef(2_533L, "פרי מגדים על אורח חיים"),
- ),
- )
-
- public val TOC_BY_TOC_TEXT_ID: Map> =
- mapOf(
- 381L to
- mapOf(
- 3_768L to TocQuickLink("אורח חיים", 30_015L, 252_674),
- 4_411L to TocQuickLink("יורה דעה", 30_714L, 254_081),
- 4_412L to TocQuickLink("אבן העזר", 31_119L, 254_895),
- 4_413L to TocQuickLink("חושן משפט", 31_299L, 255_265),
- ),
- )
-
- public object Ids {
- public object Categories {
- /**
- * תנ״ך
- */
- public const val TANAKH: Long = 1L
-
- /**
- * תורה
- */
- public const val TORAH: Long = 2L
-
- /**
- * נביאים
- */
- public const val NEVIIM: Long = 3L
-
- /**
- * כתובים
- */
- public const val KETUVIM: Long = 4L
-
- /**
- * משנה
- */
- public const val MISHNA: Long = 5L
-
- /**
- * סדר זרעים
- */
- public const val MISHNA_ZERAIM: Long = 6L
-
- /**
- * סדר מועד
- */
- public const val MISHNA_MOED: Long = 7L
-
- /**
- * סדר נשים
- */
- public const val MISHNA_NASHIM: Long = 8L
-
- /**
- * סדר נזיקין
- */
- public const val MISHNA_NEZIKIN: Long = 9L
-
- /**
- * סדר קדשים
- */
- public const val MISHNA_KODASHIM: Long = 10L
-
- /**
- * סדר טהרות
- */
- public const val MISHNA_TAHAROT: Long = 11L
-
- /**
- * תלמוד בבלי
- */
- public const val BAVLI: Long = 13L
-
- /**
- * סדר זרעים
- */
- public const val BAVLI_ZERAIM: Long = 14L
-
- /**
- * סדר מועד
- */
- public const val BAVLI_MOED: Long = 15L
-
- /**
- * סדר נשים
- */
- public const val BAVLI_NASHIM: Long = 16L
-
- /**
- * סדר נזיקין
- */
- public const val BAVLI_NEZIKIN: Long = 17L
-
- /**
- * סדר קדשים
- */
- public const val BAVLI_KODASHIM: Long = 18L
-
- /**
- * סדר טהרות
- */
- public const val BAVLI_TAHAROT: Long = 19L
-
- /**
- * תלמוד ירושלמי
- */
- public const val YERUSHALMI: Long = 20L
-
- /**
- * סדר זרעים
- */
- public const val YERUSHALMI_ZERAIM: Long = 21L
-
- /**
- * סדר מועד
- */
- public const val YERUSHALMI_MOED: Long = 22L
-
- /**
- * סדר נשים
- */
- public const val YERUSHALMI_NASHIM: Long = 23L
-
- /**
- * סדר נזיקין
- */
- public const val YERUSHALMI_NEZIKIN: Long = 24L
-
- /**
- * סדר טהרות
- */
- public const val YERUSHALMI_TAHAROT: Long = 25L
-
- /**
- * משנה תורה
- */
- public const val MISHNE_TORAH: Long = 45L
-
- /**
- * טור
- */
- public const val TUR: Long = 61L
-
- /**
- * שולחן ערוך
- */
- public const val SHULCHAN_ARUCH: Long = 62L
- }
-
- public object Books {
- /**
- * טור
- */
- public const val TUR: Long = 381L
- }
-
- public object TocTexts {
- /**
- * אורח חיים
- */
- public const val ORACH_CHAIM: Long = 3_768L
-
- /**
- * יורה דעה
- */
- public const val YOREH_DEAH: Long = 4_411L
-
- /**
- * אבן העזר
- */
- public const val EVEN_HAEZER: Long = 4_412L
-
- /**
- * חושן משפט
- */
- public const val CHOSHEN_MISHPAT: Long = 4_413L
- }
- }
-
- public object Dropdowns {
- public val HOME: List =
- listOf(
- MultiCategoryDropdownSpec(1L, listOf(2L, 3L, 4L)),
- MultiCategoryDropdownSpec(5L, listOf(6L, 7L, 8L, 9L, 10L, 11L)),
- MultiCategoryDropdownSpec(13L, listOf(14L, 15L, 16L, 17L, 18L, 19L)),
- MultiCategoryDropdownSpec(20L, listOf(21L, 22L, 23L, 24L, 25L)),
- CategoryDropdownSpec(62L),
- TocQuickLinksSpec(381L, listOf(3_768L, 4_411L, 4_412L, 4_413L)),
- )
-
- public val TANAKH: DropdownSpec = MultiCategoryDropdownSpec(1L, listOf(2L, 3L, 4L))
-
- public val TORAH: DropdownSpec = CategoryDropdownSpec(2L)
-
- public val NEVIIM: DropdownSpec = CategoryDropdownSpec(3L)
-
- public val KETUVIM: DropdownSpec = CategoryDropdownSpec(4L)
-
- public val MISHNA: DropdownSpec =
- MultiCategoryDropdownSpec(5L, listOf(6L, 7L, 8L, 9L, 10L, 11L))
-
- public val BAVLI: DropdownSpec =
- MultiCategoryDropdownSpec(13L, listOf(14L, 15L, 16L, 17L, 18L, 19L))
-
- public val YERUSHALMI: DropdownSpec =
- MultiCategoryDropdownSpec(20L, listOf(21L, 22L, 23L, 24L, 25L))
-
- public val SHULCHAN_ARUCH: DropdownSpec = CategoryDropdownSpec(62L)
-
- public val MISHNE_TORAH: DropdownSpec =
- MultiCategoryDropdownSpec(45L, listOf(46L, 47L, 48L, 49L, 50L, 51L, 52L, 53L, 54L, 55L, 59L, 56L, 57L, 58L, 60L))
-
- public val TUR_QUICK_LINKS: DropdownSpec =
- TocQuickLinksSpec(381L, listOf(3_768L, 4_411L, 4_412L, 4_413L))
- }
-}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccess.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccess.kt
new file mode 100644
index 00000000..85fd0a3f
--- /dev/null
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccess.kt
@@ -0,0 +1,175 @@
+package io.github.kdroidfilter.seforimapp.core.catalog
+
+import io.github.kdroidfilter.seforimapp.catalog.BookRef
+import io.github.kdroidfilter.seforimapp.catalog.CatalogPresets
+import io.github.kdroidfilter.seforimlibrary.core.models.CatalogCategory
+import io.github.kdroidfilter.seforimlibrary.core.models.PrecomputedCatalog
+
+/**
+ * Runtime access to per-category titles and book lists, sourced from the lib's
+ * [PrecomputedCatalog] (catalog.pb). Applies the display transformations that
+ * `cataloggen` used to bake into PrecomputedCatalog.kt:
+ * - "תלמוד" prefix on Bavli/Yerushalmi category titles
+ * - "מפרשים" filter inside Mishneh Torah and its direct children
+ * - "הקדמה" / "פרי מגדים" filter inside Shulchan Aruch and its direct children
+ * - ancestor-label prefix stripping on book display titles
+ */
+class CatalogAccess(
+ private val catalogProvider: () -> PrecomputedCatalog?,
+) {
+ private data class Indices(
+ val source: PrecomputedCatalog,
+ val categoryTitles: Map,
+ val bookTitles: Map,
+ val booksByCategory: Map>,
+ )
+
+ @Volatile
+ private var indices: Indices? = null
+
+ private fun resolve(): Indices? {
+ val catalog = catalogProvider() ?: return null
+ val current = indices
+ if (current != null && current.source === catalog) return current
+ val built = build(catalog)
+ indices = built
+ return built
+ }
+
+ fun categoryTitle(id: Long): String? = resolve()?.categoryTitles?.get(id)
+
+ fun bookTitle(id: Long): String? = resolve()?.bookTitles?.get(id)
+
+ fun booksFor(categoryId: Long): List = resolve()?.booksByCategory?.get(categoryId).orEmpty()
+
+ private fun build(catalog: PrecomputedCatalog): Indices {
+ val rawCategoryTitles = LinkedHashMap()
+ val parentIdByCategory = HashMap()
+ val bookTitles = HashMap()
+
+ fun walk(cat: CatalogCategory) {
+ rawCategoryTitles[cat.id] = cat.title
+ parentIdByCategory[cat.id] = cat.parentId
+ cat.books.forEach { bookTitles[it.id] = it.title }
+ cat.subcategories.forEach { walk(it) }
+ }
+ catalog.rootCategories.forEach { walk(it) }
+
+ val categoryTitles = LinkedHashMap(rawCategoryTitles.size)
+ rawCategoryTitles.forEach { (id, title) ->
+ categoryTitles[id] = displayCategoryTitle(id, title, rawCategoryTitles, parentIdByCategory)
+ }
+
+ val booksByCategory = HashMap>()
+ val ancestorTitlesCache = HashMap>()
+
+ fun walkBooks(cat: CatalogCategory) {
+ val excludedPrefixes = excludedBookPrefixesFor(cat.id, parentIdByCategory)
+ val ancestorLabels =
+ ancestorTitlesCache.getOrPut(cat.id) {
+ collectAncestorTitles(cat.id, rawCategoryTitles, parentIdByCategory)
+ }
+ // Strip ancestor labels first, then exclude books whose display title starts with
+ // a forbidden prefix (e.g. שולחן ערוך, הקדמה → הקדמה → filtered).
+ val refs =
+ cat.books.mapNotNull { book ->
+ val display = stripAnyLabelPrefix(ancestorLabels, book.title)
+ val trimmed = display.trimStart()
+ if (excludedPrefixes.any { trimmed.startsWith(it) }) {
+ null
+ } else {
+ BookRef(book.id, display)
+ }
+ }
+ booksByCategory[cat.id] = refs
+ cat.subcategories.forEach { walkBooks(it) }
+ }
+ catalog.rootCategories.forEach { walkBooks(it) }
+
+ return Indices(catalog, categoryTitles, bookTitles, booksByCategory)
+ }
+
+ /**
+ * Returns the book-title prefixes to exclude in the given category context, or empty if none.
+ * Mirrors the legacy display rules previously baked into the codegen:
+ * - Mishneh Torah (root or direct child): drop "מפרשים".
+ * - Shulchan Aruch (root or direct child): drop "הקדמה" and "פרי מגדים".
+ */
+ private fun excludedBookPrefixesFor(
+ categoryId: Long,
+ parents: Map,
+ ): List {
+ val parentId = parents[categoryId]
+ val mishnehTorahId = CatalogPresets.Ids.Categories.MISHNE_TORAH
+ val shulchanAruchId = CatalogPresets.Ids.Categories.SHULCHAN_ARUCH
+ return when {
+ categoryId == mishnehTorahId || parentId == mishnehTorahId -> listOf(MEFARSHIM_PREFIX)
+ categoryId == shulchanAruchId || parentId == shulchanAruchId -> SHULCHAN_ARUCH_EXCLUDED_PREFIXES
+ else -> emptyList()
+ }
+ }
+
+ private fun displayCategoryTitle(
+ id: Long,
+ rawTitle: String,
+ rawTitles: Map,
+ parents: Map,
+ ): String {
+ val needsTalmudPrefix = id == CatalogPresets.Ids.Categories.BAVLI || id == CatalogPresets.Ids.Categories.YERUSHALMI
+ if (!needsTalmudPrefix) return rawTitle
+ val parentTitle = parents[id]?.let { rawTitles[it] }?.takeIf { it.isNotBlank() } ?: TALMUD_FALLBACK
+ return "$parentTitle $rawTitle"
+ }
+
+ private fun collectAncestorTitles(
+ categoryId: Long,
+ titles: Map,
+ parents: Map,
+ ): List {
+ val labels = mutableListOf()
+ var current: Long? = categoryId
+ var guard = 0
+ while (current != null && guard++ < MAX_ANCESTOR_WALK) {
+ titles[current]?.takeIf { it.isNotBlank() }?.let { labels += it }
+ current = parents[current]
+ }
+ return labels.distinct()
+ }
+
+ private fun stripAnyLabelPrefix(
+ labels: List,
+ title: String,
+ ): String {
+ var result = title
+ for (label in labels) result = stripLabelPrefix(label, result)
+ return result
+ }
+
+ private fun stripLabelPrefix(
+ label: String,
+ title: String,
+ ): String {
+ if (label.isBlank()) return title
+ val prefix = Regex.escape(label)
+ val patterns =
+ listOf(
+ Regex("^$prefix\\s*,\\s*"),
+ Regex("^$prefix,\\s*"),
+ Regex("^$prefix\\s*[:–—-]\\s*"),
+ Regex("^$prefix\\s*\\+\\s*"),
+ Regex("^$prefix\\s+"),
+ )
+ for (p in patterns) {
+ val replaced = title.replaceFirst(p, "")
+ if (replaced !== title) return replaced.trimStart()
+ }
+ return title
+ }
+
+ private companion object {
+ const val MEFARSHIM_PREFIX = "מפרשים"
+ const val TALMUD_FALLBACK = "תלמוד"
+ const val MAX_ANCESTOR_WALK = 50
+ val SHULCHAN_ARUCH_EXCLUDED_PREFIXES = listOf("הקדמה", "פרי מגדים")
+ }
+}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/coroutines/EfficiencyCoreDispatcher.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/coroutines/EfficiencyCoreDispatcher.kt
index ec6ab7b1..1f493516 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/coroutines/EfficiencyCoreDispatcher.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/coroutines/EfficiencyCoreDispatcher.kt
@@ -1,6 +1,6 @@
package io.github.kdroidfilter.seforimapp.core.coroutines
-import io.github.kdroidfilter.nucleus.energymanager.EnergyManager
+import dev.nucleusframework.energymanager.EnergyManager
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.asCoroutineDispatcher
import java.util.concurrent.Executors
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/AppDockMenu.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/AppDockMenu.kt
index 4fa78f46..6fd2d97d 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/AppDockMenu.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/AppDockMenu.kt
@@ -6,9 +6,9 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.kdroid.gematria.converter.toHebrewNumeral
-import io.github.kdroidfilter.nucleus.launcher.macos.DockMenuItem
-import io.github.kdroidfilter.nucleus.launcher.macos.DockMenuListener
-import io.github.kdroidfilter.nucleus.launcher.macos.MacOsDockMenu
+import dev.nucleusframework.launcher.macos.DockMenuItem
+import dev.nucleusframework.launcher.macos.DockMenuListener
+import dev.nucleusframework.launcher.macos.MacOsDockMenu
import io.github.kdroidfilter.seforim.tabs.TabType
import io.github.kdroidfilter.seforim.tabs.TabsEvents
import io.github.kdroidfilter.seforim.tabs.TabsViewModel
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/AppJumpList.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/AppJumpList.kt
index e9a361bc..d3717122 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/AppJumpList.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/AppJumpList.kt
@@ -7,9 +7,9 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import com.kdroid.gematria.converter.toHebrewNumeral
-import io.github.kdroidfilter.nucleus.launcher.windows.JumpListCategory
-import io.github.kdroidfilter.nucleus.launcher.windows.JumpListItem
-import io.github.kdroidfilter.nucleus.launcher.windows.WindowsJumpListManager
+import dev.nucleusframework.launcher.windows.JumpListCategory
+import dev.nucleusframework.launcher.windows.JumpListItem
+import dev.nucleusframework.launcher.windows.WindowsJumpListManager
import io.github.kdroidfilter.seforim.tabs.TabType
import io.github.kdroidfilter.seforim.tabs.TabsEvents
import io.github.kdroidfilter.seforim.tabs.TabsViewModel
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/AppLinuxQuicklist.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/AppLinuxQuicklist.kt
index a6e67e08..51dd610c 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/AppLinuxQuicklist.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/AppLinuxQuicklist.kt
@@ -7,10 +7,10 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import com.kdroid.gematria.converter.toHebrewNumeral
-import io.github.kdroidfilter.nucleus.launcher.linux.DbusmenuItem
-import io.github.kdroidfilter.nucleus.launcher.linux.LauncherProperties
-import io.github.kdroidfilter.nucleus.launcher.linux.LinuxLauncherEntry
-import io.github.kdroidfilter.nucleus.launcher.linux.LinuxQuicklist
+import dev.nucleusframework.launcher.linux.DbusmenuItem
+import dev.nucleusframework.launcher.linux.LauncherProperties
+import dev.nucleusframework.launcher.linux.LinuxLauncherEntry
+import dev.nucleusframework.launcher.linux.LinuxQuicklist
import io.github.kdroidfilter.seforim.tabs.TabType
import io.github.kdroidfilter.seforim.tabs.TabsEvents
import io.github.kdroidfilter.seforim.tabs.TabsViewModel
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/AppNativeMenuBar.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/AppNativeMenuBar.kt
index 98990a51..6c593a29 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/AppNativeMenuBar.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/AppNativeMenuBar.kt
@@ -3,12 +3,12 @@ package io.github.kdroidfilter.seforimapp.core.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import io.github.kdroidfilter.nucleus.menu.macos.NativeKeyShortcut
-import io.github.kdroidfilter.nucleus.menu.macos.NativeMenuBar
-import io.github.kdroidfilter.nucleus.menu.macos.NsMenuItemImage
-import io.github.kdroidfilter.nucleus.sfsymbols.SFSymbolGeneral
-import io.github.kdroidfilter.nucleus.sfsymbols.SFSymbolStatus
-import io.github.kdroidfilter.nucleus.sfsymbols.SFSymbolTextFormatting
+import dev.nucleusframework.menu.macos.NativeKeyShortcut
+import dev.nucleusframework.menu.macos.NativeMenuBar
+import dev.nucleusframework.menu.macos.NsMenuItemImage
+import dev.nucleusframework.sfsymbols.SFSymbolGeneral
+import dev.nucleusframework.sfsymbols.SFSymbolStatus
+import dev.nucleusframework.sfsymbols.SFSymbolTextFormatting
import io.github.kdroidfilter.seforim.tabs.TabsDestination
import io.github.kdroidfilter.seforim.tabs.TabsEvents
import io.github.kdroidfilter.seforim.tabs.TabsViewModel
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/CatalogDropdown.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/CatalogDropdown.kt
index f85466a1..93a8c88e 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/CatalogDropdown.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/CatalogDropdown.kt
@@ -20,10 +20,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import io.github.kdroidfilter.seforimapp.catalog.CatalogPresets
import io.github.kdroidfilter.seforimapp.catalog.CategoryDropdownSpec
import io.github.kdroidfilter.seforimapp.catalog.DropdownSpec
import io.github.kdroidfilter.seforimapp.catalog.MultiCategoryDropdownSpec
-import io.github.kdroidfilter.seforimapp.catalog.PrecomputedCatalog
import io.github.kdroidfilter.seforimapp.catalog.TocQuickLinksSpec
import io.github.kdroidfilter.seforimapp.core.coroutines.runSuspendCatching
import io.github.kdroidfilter.seforimapp.features.bookcontent.BookContentEvent
@@ -48,6 +48,7 @@ fun CatalogDropdown(
maxPopupHeight: Dp? = null,
) {
val repo = LocalAppGraph.current.repository
+ val catalogAccess = LocalAppGraph.current.catalogAccess
val scope = rememberCoroutineScope()
fun selectBook(
@@ -63,22 +64,26 @@ fun CatalogDropdown(
when (spec) {
is CategoryDropdownSpec -> {
val categoryId = spec.categoryId
- val categoryTitle = remember(categoryId) { PrecomputedCatalog.CATEGORY_TITLES[categoryId] }
- val precomputedBooks = remember(categoryId) { PrecomputedCatalog.CATEGORY_BOOKS[categoryId] }
+ val categoryTitle = remember(categoryId, catalogAccess) { catalogAccess.categoryTitle(categoryId) }
+ val precomputedBooks = remember(categoryId, catalogAccess) { catalogAccess.booksFor(categoryId) }
- if (categoryTitle != null && !precomputedBooks.isNullOrEmpty()) {
+ if (categoryTitle != null && precomputedBooks.isNotEmpty()) {
val baseMax: Dp = 360.dp
val minHeight: Dp =
minPopupHeight ?: when (categoryId) {
- PrecomputedCatalog.Ids.Categories.TORAH -> 160.dp
- PrecomputedCatalog.Ids.Categories.SHULCHAN_ARUCH -> 120.dp
+ CatalogPresets.Ids.Categories.TORAH -> 160.dp
else -> Dp.Unspecified
}
val desiredMax: Dp = maxPopupHeight ?: baseMax
val effectiveMax: Dp = if (minHeight != Dp.Unspecified && minHeight > desiredMax) minHeight else desiredMax
+ val popupWidth =
+ popupWidthMultiplier ?: when (categoryId) {
+ CatalogPresets.Ids.Categories.SHULCHAN_ARUCH -> 1.1f
+ else -> 1.5f
+ }
DropdownButton(
- modifier = modifier.widthIn(max = 280.dp),
- popupWidthMultiplier = popupWidthMultiplier ?: 1.5f,
+ modifier = modifier,
+ popupWidthMultiplier = popupWidth,
maxPopupHeight = effectiveMax,
minPopupHeight = minHeight,
content = { Text(text = categoryTitle) },
@@ -124,20 +129,23 @@ fun CatalogDropdown(
}
}
is MultiCategoryDropdownSpec -> {
- val labelTitle = remember(spec.labelCategoryId) { PrecomputedCatalog.CATEGORY_TITLES[spec.labelCategoryId] }
+ val labelTitle =
+ remember(spec.labelCategoryId, catalogAccess) {
+ catalogAccess.categoryTitle(spec.labelCategoryId)
+ }
val sections =
- remember(spec.bookCategoryIds) {
+ remember(spec.bookCategoryIds, catalogAccess) {
spec.bookCategoryIds.mapNotNull { cid ->
- val t = PrecomputedCatalog.CATEGORY_TITLES[cid]
- val list = PrecomputedCatalog.CATEGORY_BOOKS[cid]
- if (t != null && !list.isNullOrEmpty()) t to list else null
+ val t = catalogAccess.categoryTitle(cid) ?: return@mapNotNull null
+ val list = catalogAccess.booksFor(cid)
+ if (list.isNotEmpty()) t to list else null
}
}
if (labelTitle != null && sections.any { it.second.isNotEmpty() }) {
val popupWidth =
popupWidthMultiplier ?: when (spec.labelCategoryId) {
- PrecomputedCatalog.Ids.Categories.BAVLI,
- PrecomputedCatalog.Ids.Categories.YERUSHALMI,
+ CatalogPresets.Ids.Categories.BAVLI,
+ CatalogPresets.Ids.Categories.YERUSHALMI,
-> 1.1f
else -> 1.5f
}
@@ -146,7 +154,7 @@ fun CatalogDropdown(
val desiredMax: Dp = maxPopupHeight ?: baseMax
val effectiveMax: Dp = if (minHeight != Dp.Unspecified && minHeight > desiredMax) minHeight else desiredMax
DropdownButton(
- modifier = modifier.widthIn(max = 280.dp),
+ modifier = modifier,
popupWidthMultiplier = popupWidth,
maxPopupHeight = effectiveMax,
minPopupHeight = minHeight,
@@ -209,9 +217,9 @@ fun CatalogDropdown(
}
}
is TocQuickLinksSpec -> {
- TocJumpDropdownByIds(
+ TocJumpDropdownForBook(
bookId = spec.bookId,
- tocTextIds = spec.tocTextIds.toImmutableList(),
+ links = spec.links.toImmutableList(),
onEvent = onEvent,
modifier = modifier,
popupWidthMultiplier = popupWidthMultiplier ?: 1.5f,
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/MainTitleBar.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/MainTitleBar.kt
index db77e6d7..ad5fd72b 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/MainTitleBar.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/MainTitleBar.kt
@@ -10,12 +10,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-import io.github.kdroidfilter.nucleus.window.ControlButtonsDirection
-import io.github.kdroidfilter.nucleus.window.DecoratedWindowScope
-import io.github.kdroidfilter.nucleus.window.jewel.JewelTitleBar
-import io.github.kdroidfilter.nucleus.window.macOSLargeCornerRadius
-import io.github.kdroidfilter.nucleus.window.newFullscreenControls
-import io.github.kdroidfilter.nucleus.window.styling.LocalTitleBarStyle
+import dev.nucleusframework.window.ControlButtonsDirection
+import dev.nucleusframework.window.DecoratedWindowScope
+import dev.nucleusframework.window.jewel.JewelTitleBar
+import dev.nucleusframework.window.macOSLargeCornerRadius
+import dev.nucleusframework.window.newFullscreenControls
+import dev.nucleusframework.window.styling.LocalTitleBarStyle
import io.github.kdroidfilter.platformtools.OperatingSystem
import io.github.kdroidfilter.seforimapp.core.presentation.tabs.TabsView
import io.github.kdroidfilter.seforimapp.core.presentation.theme.ThemeUtils
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/TitleBarActionsButtonsView.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/TitleBarActionsButtonsView.kt
index 0fad2c96..7d902de4 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/TitleBarActionsButtonsView.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/TitleBarActionsButtonsView.kt
@@ -8,15 +8,21 @@ import io.github.kdroidfilter.seforim.tabs.TabsViewModel
import io.github.kdroidfilter.seforimapp.core.presentation.theme.IntUiThemes
import io.github.kdroidfilter.seforimapp.core.presentation.utils.LocalWindowViewModelStoreOwner
import io.github.kdroidfilter.seforimapp.core.settings.AppSettings
-import io.github.kdroidfilter.seforimapp.features.settings.SettingsWindow
import io.github.kdroidfilter.seforimapp.features.settings.SettingsWindowEvents
import io.github.kdroidfilter.seforimapp.features.settings.SettingsWindowViewModel
import io.github.kdroidfilter.seforimapp.framework.di.LocalAppGraph
import io.github.kdroidfilter.seforimapp.framework.platform.PlatformInfo
import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.jewel.ui.icon.PathIconKey
import org.jetbrains.jewel.ui.icons.AllIconsKeys
import seforimapp.seforimapp.generated.resources.*
+// AllIconsKeys.MeetNewUi.SystemTheme was removed in Jewel 0.37 and the SVG
+// dropped from IntelliJ Platform icons 262, so the asset is shipped locally.
+private object SystemThemeIconAnchor
+
+private val SystemTheme = PathIconKey("icons/system_theme.svg", SystemThemeIconAnchor::class.java)
+
@Composable
fun TitleBarActionsButtonsView() {
val appGraph = LocalAppGraph.current
@@ -129,7 +135,7 @@ fun TitleBarActionsButtonsView() {
when (theme) {
IntUiThemes.Light -> AllIconsKeys.MeetNewUi.LightTheme
IntUiThemes.Dark -> AllIconsKeys.MeetNewUi.DarkTheme
- IntUiThemes.System -> AllIconsKeys.MeetNewUi.SystemTheme
+ IntUiThemes.System -> SystemTheme
},
contentDescription = iconDescription,
onClick = {
@@ -154,10 +160,6 @@ fun TitleBarActionsButtonsView() {
)
}
- if (settingsState.isVisible) {
- SettingsWindow(
- onClose = { settingsViewModel.onEvent(SettingsWindowEvents.OnClose) },
- initialDestination = settingsState.initialDestination,
- )
- }
+ // SettingsWindow is hoisted to main.kt where NucleusApplicationScope is available
+ // (JewelDecoratedDialog is an extension on NucleusApplicationScope in Nucleus 2.0).
}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/TocJumpDropdown.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/TocJumpDropdown.kt
index 633c94af..8fc0ff6c 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/TocJumpDropdown.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/components/TocJumpDropdown.kt
@@ -21,14 +21,15 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import io.github.kdroidfilter.seforimapp.catalog.PrecomputedCatalog
import io.github.kdroidfilter.seforimapp.core.coroutines.runSuspendCatching
import io.github.kdroidfilter.seforimapp.features.bookcontent.BookContentEvent
import io.github.kdroidfilter.seforimapp.framework.di.LocalAppGraph
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.component.Text
+import io.github.kdroidfilter.seforimapp.catalog.TocQuickLink as PresetTocQuickLink
/** Quick TOC jump menu for a specific book. */
data class TocQuickLink(
@@ -114,58 +115,27 @@ fun TocJumpDropdown(
}
@Composable
-fun TocJumpDropdownByIds(
- title: String,
+fun TocJumpDropdownForBook(
bookId: Long,
- tocTextIds: ImmutableList,
+ links: ImmutableList,
onEvent: (BookContentEvent) -> Unit,
modifier: Modifier = Modifier,
popupWidthMultiplier: Float = 1.5f,
minPopupHeight: Dp = Dp.Unspecified,
maxPopupHeight: Dp = 360.dp,
) {
- val loader: suspend () -> List = {
- val bookMap = PrecomputedCatalog.TOC_BY_TOC_TEXT_ID[bookId].orEmpty()
- tocTextIds.mapNotNull { tx ->
- val ql = bookMap[tx] ?: return@mapNotNull null
- TocQuickLink(ql.label, ql.tocEntryId, ql.firstLineId)
- }
- }
+ val catalogAccess = LocalAppGraph.current.catalogAccess
+ val title = catalogAccess.bookTitle(bookId) ?: return
+ val items = links.map { TocQuickLink(it.label, it.tocEntryId, it.firstLineId) }.toImmutableList()
TocJumpDropdown(
title = title,
bookId = bookId,
onEvent = onEvent,
modifier = modifier,
- items = persistentListOf(),
+ items = items,
popupWidthMultiplier = popupWidthMultiplier,
minPopupHeight = minPopupHeight,
maxPopupHeight = maxPopupHeight,
- prepareItems = loader,
)
}
-
-@Composable
-fun TocJumpDropdownByIds(
- bookId: Long,
- tocTextIds: ImmutableList,
- onEvent: (BookContentEvent) -> Unit,
- modifier: Modifier = Modifier,
- popupWidthMultiplier: Float = 1.5f,
- minPopupHeight: Dp = Dp.Unspecified,
- maxPopupHeight: Dp = 360.dp,
-) {
- val t = PrecomputedCatalog.BOOK_TITLES[bookId]
- if (t != null) {
- TocJumpDropdownByIds(
- title = t,
- bookId = bookId,
- tocTextIds = tocTextIds,
- onEvent = onEvent,
- modifier = modifier,
- popupWidthMultiplier = popupWidthMultiplier,
- minPopupHeight = minPopupHeight,
- maxPopupHeight = maxPopupHeight,
- )
- }
-}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/tabs/TabsContent.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/tabs/TabsContent.kt
index 479791c7..2806f2d2 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/tabs/TabsContent.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/tabs/TabsContent.kt
@@ -9,11 +9,12 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.compositionLocalOf
-import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.zIndex
@@ -60,9 +61,12 @@ import org.jetbrains.jewel.foundation.theme.JewelTheme
*/
val LocalTabSelected = compositionLocalOf { true }
+private const val MAX_RETAINED_TAB_COMPOSITIONS = 3
+
/**
* Simplified tab content renderer without Compose Navigation.
- * Each tab renders its content directly based on destination type.
+ * Keeps ViewModel owners per tab and retains a small LRU of tab compositions to
+ * reduce switch latency without keeping every open tab actively composed.
*/
@Composable
fun TabsContent() {
@@ -81,15 +85,8 @@ fun TabsContent() {
val searchUi by remember(searchHomeViewModel) { searchHomeViewModel.uiState }.collectAsState()
val scope = rememberCoroutineScope()
- // Helper to get current tab ID
- val currentTabId by remember {
- derivedStateOf {
- tabsState.tabs
- .getOrNull(tabsState.selectedTabIndex)
- ?.destination
- ?.tabId
- }
- }
+ val currentTabId = tabs.getOrNull(selectedTabIndex)?.destination?.tabId
+ val latestCurrentTabId by rememberUpdatedState(currentTabId)
fun launchSubmitSearch(
@StructuredScope scope: CoroutineScope,
@@ -114,11 +111,11 @@ fun TabsContent() {
onFilterChange = searchHomeViewModel::onFilterChange,
onGlobalExtendedChange = searchHomeViewModel::onGlobalExtendedChange,
onSubmitTextSearch = { query ->
- val tabId = currentTabId ?: return@HomeSearchCallbacks
+ val tabId = latestCurrentTabId ?: return@HomeSearchCallbacks
launchSubmitSearch(scope, query, tabId)
},
onOpenReference = {
- val tabId = currentTabId ?: return@HomeSearchCallbacks
+ val tabId = latestCurrentTabId ?: return@HomeSearchCallbacks
launchOpenReference(scope, tabId)
},
onPickCategory = searchHomeViewModel::onPickCategory,
@@ -163,15 +160,33 @@ fun TabsContent() {
// ViewModel owners per tab - manages lifecycle and state
val tabOwners = remember { mutableMapOf() }
+ val knownTabIds = remember { mutableSetOf() }
+ val retainedTabIds = remember { mutableStateListOf() }
// Cleanup removed tabs
LaunchedEffect(tabs) {
val activeTabIds = tabs.map { it.destination.tabId }.toSet()
- val removed = tabOwners.keys.toSet() - activeTabIds
+ val removed = (knownTabIds + tabOwners.keys) - activeTabIds
removed.forEach { tabId ->
tabOwners.remove(tabId)?.clear()
persistedStore.remove(tabId)
}
+ retainedTabIds.removeAll(removed)
+ knownTabIds.clear()
+ knownTabIds.addAll(activeTabIds)
+ }
+
+ LaunchedEffect(tabs, selectedTabIndex) {
+ val selectedTabId = tabs.getOrNull(selectedTabIndex)?.destination?.tabId ?: return@LaunchedEffect
+ val activeTabIds = tabs.map { it.destination.tabId }.toSet()
+
+ retainedTabIds.removeAll { it !in activeTabIds }
+ retainedTabIds.remove(selectedTabId)
+ retainedTabIds.add(0, selectedTabId)
+
+ while (retainedTabIds.size > MAX_RETAINED_TAB_COMPOSITIONS) {
+ retainedTabIds.removeAt(retainedTabIds.lastIndex)
+ }
}
DisposableEffect(Unit) {
@@ -190,7 +205,6 @@ fun TabsContent() {
}
}
- // Render all tabs with visibility control
val isIslands = ThemeUtils.isIslandsStyle()
val canvasBg =
if (isIslands) {
@@ -206,21 +220,38 @@ fun TabsContent() {
.fillMaxSize()
.background(canvasBg),
) {
- tabs.forEachIndexed { index, tabItem ->
+ val selectedTabId = currentTabId
+ val retainedTabIdsForComposition =
+ buildList {
+ if (selectedTabId != null) {
+ add(selectedTabId)
+ }
+ retainedTabIds.forEach { retainedTabId ->
+ if (retainedTabId != selectedTabId) {
+ add(retainedTabId)
+ }
+ }
+ }.take(MAX_RETAINED_TAB_COMPOSITIONS)
+
+ val retainedTabs =
+ retainedTabIdsForComposition
+ .mapNotNull { retainedTabId -> tabs.firstOrNull { it.destination.tabId == retainedTabId } }
+ .asReversed()
+
+ retainedTabs.forEach { tabItem ->
+ val tabId = tabItem.destination.tabId
+ val isSelected = tabId == currentTabId
key(tabItem.id) {
- val isSelected = index == selectedTabIndex
- val tabId = tabItem.destination.tabId
+ val tabOwner = tabOwners.getOrPut(tabId) { SimpleTabViewModelOwner(tabId) }
Box(
modifier =
Modifier
.fillMaxSize()
- .graphicsLayer { alpha = if (isSelected) 1f else 0f }
- .zIndex(if (isSelected) 1f else 0f),
+ .zIndex(if (isSelected) 1f else -1f)
+ .graphicsLayer { alpha = if (isSelected) 1f else 0f },
) {
CompositionLocalProvider(LocalTabSelected provides isSelected) {
- val tabOwner = tabOwners.getOrPut(tabId) { SimpleTabViewModelOwner(tabId) }
-
when (val destination = tabItem.destination) {
is TabsDestination.Home -> {
HomeTabContent(
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/AccentColor.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/AccentColor.kt
index 49a9c894..7bdfb777 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/AccentColor.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/AccentColor.kt
@@ -2,7 +2,7 @@ package io.github.kdroidfilter.seforimapp.core.presentation.theme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
-import io.github.kdroidfilter.nucleus.systemcolor.systemAccentColor
+import dev.nucleusframework.systemcolor.systemAccentColor
import org.jetbrains.jewel.intui.core.theme.IntUiDarkTheme
import org.jetbrains.jewel.intui.core.theme.IntUiLightTheme
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/ThemeUtils.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/ThemeUtils.kt
index 09b1165d..9d8cb132 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/ThemeUtils.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/presentation/theme/ThemeUtils.kt
@@ -5,7 +5,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
-import io.github.kdroidfilter.nucleus.darkmodedetector.isSystemInDarkMode
+import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
import io.github.kdroidfilter.seforimapp.framework.di.LocalAppGraph
import org.jetbrains.compose.resources.Font
import org.jetbrains.jewel.foundation.BorderColors
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEvent.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEvent.kt
index f61041db..0038ebdc 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEvent.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentEvent.kt
@@ -139,6 +139,7 @@ sealed interface BookContentEvent {
val commentatorId: Long,
val index: Int,
val offset: Int,
+ val persist: Boolean = false,
) : BookContentEvent
data class CommentariesPageChanged(
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModel.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModel.kt
index c7029c6c..8fcf2ed2 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModel.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentViewModel.kt
@@ -508,11 +508,16 @@ class BookContentViewModel(
commentariesUseCase.updateCommentatorsListScrollPosition(event.index, event.offset)
is BookContentEvent.CommentaryColumnScrolled ->
- commentariesUseCase.updateCommentaryColumnScrollPosition(
- event.commentatorId,
- event.index,
- event.offset,
- )
+ run {
+ commentariesUseCase.updateCommentaryColumnScrollPosition(
+ event.commentatorId,
+ event.index,
+ event.offset,
+ )
+ if (event.persist) {
+ stateManager.saveAllStates()
+ }
+ }
is BookContentEvent.CommentariesPageChanged ->
stateManager.updateContent(save = false) {
@@ -739,6 +744,13 @@ class BookContentViewModel(
val state = stateManager.state.value
// Always prefer an explicit anchor when present (e.g., opening from a commentary link)
val shouldUseAnchor = state.content.anchorId != -1L
+ val savedScrollAnchorId =
+ if (!shouldUseAnchor && state.content.scrollIndex > 0) {
+ runSuspendCatching { repository.getLineByIndex(book.id, state.content.scrollIndex)?.id }
+ .getOrNull()
+ } else {
+ null
+ }
// Resolve initial line anchor if any, otherwise fall back to the first TOC's first line
// so that opening a book from the category tree selects the first meaningful section.
@@ -750,6 +762,7 @@ class BookContentViewModel(
when {
forceAnchorId != null -> forceAnchorId
shouldUseAnchor -> state.content.anchorId
+ savedScrollAnchorId != null -> savedScrollAnchorId
currentPrimaryLine != null -> currentPrimaryLine.id
else -> {
// Compute from TOC: take the first root TOC entry (or its first leaf) and
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/BookContentPanel.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/BookContentPanel.kt
index e54be6e1..608f8c32 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/BookContentPanel.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/BookContentPanel.kt
@@ -288,10 +288,10 @@ private fun LoaderPanel(modifier: Modifier = Modifier) {
Box(
modifier =
modifier
- .fillMaxSize()
- .wrapContentSize(Alignment.Center),
+ .fillMaxSize(),
+ contentAlignment = Alignment.Center,
) {
- CircularProgressIndicator()
+ CircularProgressIndicator(modifier = Modifier.size(72.dp))
}
}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/components/CatalogRow.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/components/CatalogRow.kt
index 5fab1ba4..7a371db7 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/components/CatalogRow.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/components/CatalogRow.kt
@@ -14,7 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
-import io.github.kdroidfilter.seforimapp.catalog.PrecomputedCatalog
+import io.github.kdroidfilter.seforimapp.catalog.CatalogPresets
import io.github.kdroidfilter.seforimapp.core.presentation.components.CatalogDropdown
import io.github.kdroidfilter.seforimapp.core.presentation.theme.ThemeUtils
import io.github.kdroidfilter.seforimapp.features.bookcontent.BookContentEvent
@@ -43,46 +43,45 @@ fun CatalogRow(
val buttonModifier = Modifier.widthIn(max = 130.dp)
CatalogDropdown(
- spec = PrecomputedCatalog.Dropdowns.TANAKH,
+ spec = CatalogPresets.Dropdowns.TANAKH,
onEvent = onEvent,
modifier = buttonModifier,
popupWidthMultiplier = 1.50f,
)
CatalogDropdown(
- spec = PrecomputedCatalog.Dropdowns.MISHNA,
+ spec = CatalogPresets.Dropdowns.MISHNA,
onEvent = onEvent,
modifier = buttonModifier,
)
CatalogDropdown(
- spec = PrecomputedCatalog.Dropdowns.BAVLI,
+ spec = CatalogPresets.Dropdowns.BAVLI,
onEvent = onEvent,
modifier = buttonModifier,
popupWidthMultiplier = 1.1f,
)
CatalogDropdown(
- spec = PrecomputedCatalog.Dropdowns.YERUSHALMI,
+ spec = CatalogPresets.Dropdowns.YERUSHALMI,
onEvent = onEvent,
modifier = buttonModifier,
popupWidthMultiplier = 1.1f,
)
CatalogDropdown(
- spec = PrecomputedCatalog.Dropdowns.MISHNE_TORAH,
+ spec = CatalogPresets.Dropdowns.MISHNE_TORAH,
onEvent = onEvent,
modifier = buttonModifier,
popupWidthMultiplier = 1.5f,
)
CatalogDropdown(
- spec = PrecomputedCatalog.Dropdowns.TUR_QUICK_LINKS,
+ spec = CatalogPresets.Dropdowns.TUR_QUICK_LINKS,
onEvent = onEvent,
modifier = buttonModifier,
maxPopupHeight = 130.dp,
)
CatalogDropdown(
- spec = PrecomputedCatalog.Dropdowns.SHULCHAN_ARUCH,
+ spec = CatalogPresets.Dropdowns.SHULCHAN_ARUCH,
onEvent = onEvent,
modifier = buttonModifier,
- maxPopupHeight = 160.dp,
- popupWidthMultiplier = 1.1f,
+ maxPopupHeight = 130.dp,
)
}
}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BookContentView.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BookContentView.kt
index eb34ef6a..16c341dc 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BookContentView.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/BookContentView.kt
@@ -19,6 +19,7 @@ import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.*
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
@@ -31,9 +32,12 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.*
import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.PointerEventType
+import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.isCtrlPressed
import androidx.compose.ui.input.pointer.isMetaPressed
import androidx.compose.ui.input.pointer.isPrimaryPressed
+import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
@@ -67,7 +71,9 @@ import io.github.kdroidfilter.seforimlibrary.core.models.Line
import io.github.kdroidfilter.seforimlibrary.core.text.HebrewTextUtils
import io.github.santimattius.structured.annotations.StructuredScope
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@@ -75,8 +81,11 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.component.CircularProgressIndicator
import org.jetbrains.jewel.ui.component.Text
+import kotlin.math.abs
+import kotlin.math.exp
+import kotlin.time.Duration.Companion.milliseconds
-@OptIn(FlowPreview::class)
+@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class, ExperimentalComposeUiApi::class)
@Suppress(
"ComposeUnstableCollections",
"ParamsComparedByRef",
@@ -126,11 +135,44 @@ fun BookContentView(
// Collect text size from settings
val rawTextSize by AppSettings.textSizeFlow.collectAsState()
val isTabSelected = LocalTabSelected.current
+ val pointerZoomScope = rememberCoroutineScope()
+ var pointerZoomResetJob by remember { mutableStateOf(null) }
+ var isPointerZooming by remember { mutableStateOf(false) }
+
+ fun markPointerZooming() {
+ isPointerZooming = true
+ pointerZoomResetJob?.cancel()
+ pointerZoomResetJob =
+ pointerZoomScope.launch {
+ delay(120.milliseconds)
+ isPointerZooming = false
+ }
+ }
+
+ fun applyZoomFactor(factor: Float) {
+ if (!factor.isFinite() || factor <= 0f) return
+
+ val currentTextSize = AppSettings.textSizeFlow.value
+ val newTextSize =
+ (currentTextSize * factor)
+ .coerceIn(AppSettings.MIN_TEXT_SIZE, AppSettings.MAX_TEXT_SIZE)
+
+ if (abs(newTextSize - currentTextSize) >= 0.01f) {
+ markPointerZooming()
+ AppSettings.setTextSize(newTextSize)
+ }
+ }
+
+ val applyZoomFactorState = rememberUpdatedState<(Float) -> Unit> { factor -> applyZoomFactor(factor) }
+
+ DisposableEffect(Unit) {
+ onDispose { pointerZoomResetJob?.cancel() }
+ }
// Animate text size changes only for the active tab; background tabs snap instantly
val textSize by animateFloatAsState(
targetValue = rawTextSize,
- animationSpec = if (isTabSelected) tween(durationMillis = 300) else snap(),
+ animationSpec = if (isTabSelected && !isPointerZooming) tween(durationMillis = 300) else snap(),
label = "textSizeAnimation",
)
@@ -164,8 +206,13 @@ fun BookContentView(
var isInitialBookOpen by remember(bookId) { mutableStateOf(true) }
// Hide content until initial scroll is complete to prevent visual glitch
- // Only apply on initial book open, not when changing TOC entries
- val needsInitialPositioning = isInitialBookOpen && topAnchorLineId != -1L && !hasRestored
+ // Only apply on initial book open, not when changing TOC entries after content is already visible.
+ val hasSavedInitialPosition = anchorId != -1L || scrollIndex > 0 || scrollOffset > 0
+ val hasTopAnchorRequest = topAnchorTimestamp != 0L && topAnchorLineId != -1L
+ val needsInitialPositioning =
+ isInitialBookOpen &&
+ !hasRestored &&
+ (hasTopAnchorRequest || (topAnchorTimestamp == 0L && hasSavedInitialPosition))
val contentAlpha by animateFloatAsState(
targetValue = if (needsInitialPositioning) 0f else 1f,
animationSpec = tween(durationMillis = if (needsInitialPositioning) 0 else 50),
@@ -187,6 +234,7 @@ fun BookContentView(
}
snapshotFlow { lazyPagingItems.itemSnapshotList.items }
.distinctUntilChanged()
+ .catch { e -> debugln { "visible-lines flow failed: $e" } }
.collect { items -> selectionContext.setVisibleLines(tabId, items) }
}
DisposableEffect(tabId, selectionContext) {
@@ -211,7 +259,8 @@ fun BookContentView(
}.map { ids -> ids.distinct() }
.filter { it.isNotEmpty() }
.distinctUntilChanged()
- .debounce(150)
+ .debounce(150.milliseconds)
+ .catch { e -> debugln { "prefetch-connections flow failed: $e" } }
.collect { ids -> onPrefetchLineConnections(ids) }
}
@@ -234,7 +283,7 @@ fun BookContentView(
if (isTopAnchorRequest) return@LaunchedEffect
while (lazyPagingItems.loadState.refresh is LoadState.Loading) {
- delay(16)
+ delay(16.milliseconds)
}
val snapshot = lazyPagingItems.itemSnapshotList
@@ -268,7 +317,7 @@ fun BookContentView(
// Wait for any ongoing refresh to complete
while (lazyPagingItems.loadState.refresh is LoadState.Loading) {
- delay(16)
+ delay(16.milliseconds)
}
// Helper to locate the target index in the current snapshot
@@ -280,11 +329,14 @@ fun BookContentView(
var targetIndex = currentTargetIndex()
if (targetIndex == null) {
debugln { "Top-anchor target $topAnchorLineId not yet in snapshot; waiting" }
- withTimeoutOrNull(1500L) {
+ withTimeoutOrNull(1500L.milliseconds) {
snapshotFlow { lazyPagingItems.itemSnapshotList.items }
- .map { items -> items.indices.firstOrNull { items[it].id == topAnchorLineId } }
- .filterNotNull()
- .first()
+ .mapNotNull { items ->
+ items.indices.firstOrNull {
+ items[it].id ==
+ topAnchorLineId
+ }
+ }.first()
.also { idx -> targetIndex = idx }
}
}
@@ -307,7 +359,7 @@ fun BookContentView(
// Wait for initial page load to complete
while (lazyPagingItems.loadState.refresh is LoadState.Loading) {
- delay(16)
+ delay(16.milliseconds)
}
if (lazyPagingItems.itemCount <= 0) return@LaunchedEffect
@@ -322,11 +374,14 @@ fun BookContentView(
var idx = currentAnchorIndex()
if (idx == null) {
debugln { "Saved anchor $anchorId not yet in snapshot; waiting" }
- withTimeoutOrNull(1500L) {
+ withTimeoutOrNull(1500L.milliseconds) {
snapshotFlow { lazyPagingItems.itemSnapshotList }
- .map { snapshot -> snapshot.indices.firstOrNull { snapshot[it]?.id == anchorId } }
- .filterNotNull()
- .first()
+ .mapNotNull { snapshot ->
+ snapshot.indices.firstOrNull {
+ snapshot[it]?.id ==
+ anchorId
+ }
+ }.first()
.also { resolved -> idx = resolved }
}
}
@@ -335,6 +390,7 @@ fun BookContentView(
debugln { "Restoring by saved anchor: idx=$resolved, offset=$scrollOffset" }
listState.scrollToItem(resolved, scrollOffset.coerceAtLeast(0))
hasRestored = true
+ isInitialBookOpen = false
restoredAnchorId = anchorId
return@LaunchedEffect
}
@@ -348,7 +404,12 @@ fun BookContentView(
debugln { "Restoring by index/offset: index=$targetIndex, offset=$targetOffset" }
listState.scrollToItem(targetIndex, targetOffset)
hasRestored = true
+ isInitialBookOpen = false
+ return@LaunchedEffect
}
+
+ hasRestored = true
+ isInitialBookOpen = false
}
// Save scroll position with anchor information - optimized with derivedStateOf
@@ -386,31 +447,46 @@ fun BookContentView(
val savedAnchorIdUpdated by rememberUpdatedState(anchorId)
val savedAnchorIndexUpdated by rememberUpdatedState(anchorIndex)
- LaunchedEffect(listState, lazyPagingItems) {
- fun maybeSave(data: AnchorData) {
- // Guard: on cold-boot restore, don't overwrite the persisted position with an initial transient emission
- // before the restoration effect has applied the saved anchor/offset.
- val hasPersistedPosition =
- savedAnchorIdUpdated > 0 || savedScrollIndexUpdated > 0 || savedScrollOffsetUpdated > 0
- if (!hasRestoredUpdated && hasPersistedPosition) {
- return
- }
+ fun maybeSave(data: AnchorData) {
+ // Guard: on cold-boot restore, don't overwrite the persisted position with an initial transient emission
+ // before the restoration effect has applied the saved anchor/offset.
+ val hasPersistedPosition =
+ savedAnchorIdUpdated > 0 || savedScrollIndexUpdated > 0 || savedScrollOffsetUpdated > 0
+ if (!hasRestoredUpdated && hasPersistedPosition) {
+ return
+ }
- // Avoid wiping a previously known anchor when the list hasn't resolved item keys yet (e.g., while loading).
- val stableAnchorId = data.anchorId.takeIf { it > 0 } ?: savedAnchorIdUpdated
- val stableAnchorIndex = if (data.anchorId > 0) data.anchorIndex else savedAnchorIndexUpdated
+ // Avoid wiping a previously known anchor when the list hasn't resolved item keys yet (e.g., while loading).
+ val stableAnchorId = data.anchorId.takeIf { it > 0 } ?: savedAnchorIdUpdated
+ val stableAnchorIndex = if (data.anchorId > 0) data.anchorIndex else savedAnchorIndexUpdated
- debugln {
- "Saving scroll: anchor=$stableAnchorId, index=${data.scrollIndex}, offset=${data.scrollOffset}"
- }
- onScrollUpdated(stableAnchorId, stableAnchorIndex, data.scrollIndex, data.scrollOffset)
+ debugln {
+ "Saving scroll: anchor=$stableAnchorId, index=${data.scrollIndex}, offset=${data.scrollOffset}"
}
+ onScrollUpdated(stableAnchorId, stableAnchorIndex, data.scrollIndex, data.scrollOffset)
+ }
+ DisposableEffect(listState, lazyPagingItems) {
+ onDispose { maybeSave(scrollData.value) }
+ }
+
+ LaunchedEffect(listState, lazyPagingItems) {
// While scrolling, sample periodically so a close during an active scroll still restores closely.
+ // Gated by isScrollInProgress so the `sample` ticker only runs during an active scroll —
+ // otherwise its fixedPeriodTicker keeps the FlushCoroutineDispatcher waking up at 5 Hz forever
+ // and the Compose scene re-renders every tick even at idle.
launch {
- snapshotFlow { scrollData.value }
+ snapshotFlow { listState.isScrollInProgress }
.distinctUntilChanged()
- .sample(200)
+ .flatMapLatest { inProgress ->
+ if (inProgress) {
+ snapshotFlow { scrollData.value }
+ .distinctUntilChanged()
+ .sample(200.milliseconds)
+ } else {
+ emptyFlow()
+ }
+ }.catch { e -> debugln { "scroll-save flow failed: $e" } }
.collect { data -> maybeSave(data) }
}
@@ -419,6 +495,7 @@ fun BookContentView(
snapshotFlow { listState.isScrollInProgress }
.distinctUntilChanged()
.filter { inProgress -> !inProgress }
+ .catch { e -> debugln { "scroll-stop flush flow failed: $e" } }
.collect {
// Wait one frame so layoutInfo/visibleItemsInfo reflect the final settled position.
withFrameNanos { }
@@ -440,7 +517,7 @@ fun BookContentView(
}
// Smart mode: get highlight terms from search engine with dictionary expansion
- val appGraph = io.github.kdroidfilter.seforimapp.framework.di.LocalAppGraph.current
+ val appGraph = LocalAppGraph.current
val smartHighlightTerms by remember(smartModeEnabled, findState) {
derivedStateOf {
if (smartModeEnabled) {
@@ -588,6 +665,51 @@ fun BookContentView(
modifier
.fillMaxSize()
.graphicsLayer { alpha = contentAlpha } // Hide until positioned to prevent glitch
+ .onPointerEvent(PointerEventType.Scroll) { event ->
+ val isZoomScroll = event.keyboardModifiers.isCtrlPressed || event.keyboardModifiers.isMetaPressed
+ if (!isZoomScroll) return@onPointerEvent
+
+ val scrollDelta = event.changes.firstOrNull()?.scrollDelta ?: Offset.Zero
+ val zoomDelta =
+ if (abs(scrollDelta.y) >= abs(scrollDelta.x)) {
+ scrollDelta.y
+ } else {
+ scrollDelta.x
+ }
+ if (zoomDelta == 0f) return@onPointerEvent
+
+ val exponent = (-zoomDelta * 0.08f).coerceIn(-0.25f, 0.25f)
+ applyZoomFactorState.value(exp(exponent.toDouble()).toFloat())
+ event.changes.forEach { it.consume() }
+ }.pointerInput(Unit) {
+ awaitEachGesture {
+ var previousDistance = 0f
+ var hasZoomed = false
+
+ do {
+ val event = awaitPointerEvent(PointerEventPass.Main)
+ val pressedChanges = event.changes.filter { it.pressed }
+
+ if (pressedChanges.size >= 2) {
+ val distance = averageDistanceToCentroid(pressedChanges)
+ if (previousDistance > 0f && distance > 0f) {
+ val zoomFactor = (distance / previousDistance).coerceIn(0.85f, 1.18f)
+ if (abs(zoomFactor - 1f) > 0.002f) {
+ applyZoomFactorState.value(zoomFactor)
+ hasZoomed = true
+ }
+ }
+ previousDistance = distance
+
+ if (hasZoomed) {
+ event.changes.forEach { it.consume() }
+ }
+ } else {
+ previousDistance = 0f
+ }
+ } while (event.changes.any { it.pressed })
+ }
+ }
.focusRequester(focusRequester)
.onPreviewKeyEvent(previewKeyHandler)
.focusable(),
@@ -875,6 +997,22 @@ private data class AnchorData(
val scrollOffset: Int,
)
+private fun averageDistanceToCentroid(changes: List): Float {
+ if (changes.isEmpty()) return 0f
+
+ var centroid = Offset.Zero
+ changes.forEach { change ->
+ centroid += change.position
+ }
+ centroid /= changes.size.toFloat()
+
+ var totalDistance = 0f
+ changes.forEach { change ->
+ totalDistance += (change.position - centroid).getDistance()
+ }
+ return totalDistance / changes.size.toFloat()
+}
+
/**
* Stable wrapper for alt headings map to avoid unnecessary recompositions.
* The map content is considered stable once created.
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/CommentatorsGrid.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/CommentatorsGrid.kt
index 83212565..6b25cdf4 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/CommentatorsGrid.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/CommentatorsGrid.kt
@@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -242,7 +243,7 @@ internal fun CommentatorsGridScaffold(
VerticalPager(
state = pagerState,
modifier = Modifier.weight(1f).fillMaxHeight(),
- beyondViewportPageCount = 0,
+ beyondViewportPageCount = 1,
// Navigation is driven solely by indicator clicks to avoid swallowing the
// LazyColumn scrolls rendered inside each commentator cell.
userScrollEnabled = false,
@@ -287,14 +288,16 @@ private fun CommentatorPageGrid(
) {
rowNames.forEach { name ->
val id = config.titleToIdMap[name] ?: return@forEach
- CommentatorCell(
- name = name,
- commentTextSize = config.textSizes.commentTextSize,
- isRecentlyAdded = name == recentlyAdded,
- onTitleClick = { config.onOpenCommentatorBook(id) },
- modifier = Modifier.weight(1f).fillMaxHeight().padding(horizontal = 4.dp),
- ) {
- column(id)
+ key(id) {
+ CommentatorCell(
+ name = name,
+ commentTextSize = config.textSizes.commentTextSize,
+ isRecentlyAdded = name == recentlyAdded,
+ onTitleClick = { config.onOpenCommentatorBook(id) },
+ modifier = Modifier.weight(1f).fillMaxHeight().padding(horizontal = 4.dp),
+ ) {
+ column(id)
+ }
}
}
}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/LineCommentsView.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/LineCommentsView.kt
index 10318f28..26ba8e52 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/LineCommentsView.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/LineCommentsView.kt
@@ -12,6 +12,7 @@ import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.runtime.*
@@ -67,6 +68,7 @@ import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.drop
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi
import org.jetbrains.compose.splitpane.rememberSplitPaneState
@@ -488,6 +490,10 @@ private fun CommentatorsGrid(
onEvent: (BookContentEvent) -> Unit,
) {
val providers = uiState.providers ?: return
+ val pagerFlowCache = remember(selection) { mutableMapOf>>() }
+ val listStateCache = remember(selection) { mutableMapOf() }
+ val restoredCommentatorIds = remember(selection) { mutableStateMapOf() }
+
CommentatorsGridScaffold(
config = config,
initialPage = uiState.content.commentariesPageIndex,
@@ -495,8 +501,10 @@ private fun CommentatorsGrid(
onFlushPersist = { onEvent(BookContentEvent.FlushCommentariesState) },
) { commentatorId ->
val pagerFlow =
- remember(selection, commentatorId) {
- providers.buildCommentariesPagerFor(selection, commentatorId)
+ remember(commentatorId, pagerFlowCache) {
+ pagerFlowCache.getOrPut(commentatorId) {
+ providers.buildCommentariesPagerFor(selection, commentatorId)
+ }
}
val initialIndex =
uiState.content.commentariesColumnScrollIndexByCommentator[commentatorId]
@@ -504,12 +512,25 @@ private fun CommentatorsGrid(
val initialOffset =
uiState.content.commentariesColumnScrollOffsetByCommentator[commentatorId]
?: uiState.content.commentariesScrollOffset
+ val listState =
+ remember(commentatorId, listStateCache) {
+ listStateCache.getOrPut(commentatorId) {
+ LazyListState(
+ firstVisibleItemIndex = initialIndex.coerceAtLeast(0),
+ firstVisibleItemScrollOffset = initialOffset.coerceAtLeast(0),
+ )
+ }
+ }
+
CommentariesPagedList(
pagerFlow = pagerFlow,
+ listState = listState,
initialIndex = initialIndex,
initialOffset = initialOffset,
- onScroll = { i, o ->
- onEvent(BookContentEvent.CommentaryColumnScrolled(commentatorId, i, o))
+ shouldRestore = restoredCommentatorIds[commentatorId] != true,
+ onRestored = { restoredCommentatorIds[commentatorId] = true },
+ onScrollSettled = { i, o ->
+ onEvent(BookContentEvent.CommentaryColumnScrolled(commentatorId, i, o, persist = true))
},
config = config,
selection = selection,
@@ -534,16 +555,19 @@ private fun CommentatorsGrid(
@Composable
private fun CommentariesPagedList(
pagerFlow: Flow>,
+ listState: LazyListState,
initialIndex: Int,
initialOffset: Int,
- onScroll: (Int, Int) -> Unit,
+ shouldRestore: Boolean,
+ onRestored: () -> Unit,
+ onScrollSettled: (Int, Int) -> Unit,
config: CommentariesLayoutConfig,
selection: LineSelection,
commentatorId: Long,
getCharCountsForLine: suspend (Long, Long) -> List,
getCharCountsForLines: suspend (List, Long) -> List,
) {
- val currentOnScroll by rememberUpdatedState(onScroll)
+ val currentOnScrollSettled by rememberUpdatedState(onScrollSettled)
val lazyPagingItems = pagerFlow.collectAsLazyPagingItems()
// Ordered char-count vector for every commentary matching this selection ×
@@ -602,29 +626,35 @@ private fun CommentariesPagedList(
}
}
- val listState =
- rememberLazyListState(
- initialFirstVisibleItemIndex = initialIndex,
- initialFirstVisibleItemScrollOffset = initialOffset,
- )
-
- var hasRestored by remember(pagerFlow) { mutableStateOf(false) }
- LaunchedEffect(pagerFlow, lazyPagingItems.loadState.refresh, initialIndex, initialOffset) {
- if (!hasRestored && lazyPagingItems.loadState.refresh !is LoadState.Loading) {
+ val restoreIndex = remember(pagerFlow) { initialIndex }
+ val restoreOffset = remember(pagerFlow) { initialOffset }
+ LaunchedEffect(pagerFlow, lazyPagingItems.loadState.refresh, shouldRestore) {
+ if (shouldRestore && lazyPagingItems.loadState.refresh !is LoadState.Loading) {
if (lazyPagingItems.itemCount > 0) {
- val safeIndex = initialIndex.coerceIn(0, lazyPagingItems.itemCount - 1)
- val safeOffset = initialOffset.coerceAtLeast(0)
+ val safeIndex = restoreIndex.coerceIn(0, lazyPagingItems.itemCount - 1)
+ val safeOffset = restoreOffset.coerceAtLeast(0)
listState.scrollToItem(safeIndex, safeOffset)
- hasRestored = true
+ onRestored()
}
}
}
LaunchedEffect(listState) {
- snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
+ snapshotFlow {
+ Triple(
+ listState.firstVisibleItemIndex,
+ listState.firstVisibleItemScrollOffset,
+ listState.isScrollInProgress,
+ )
+ }
.distinctUntilChanged()
+ .drop(1)
.debounce(SCROLL_DEBOUNCE)
- .collect { (i, o) -> currentOnScroll(i, o) }
+ .collect { (i, o, isScrollInProgress) ->
+ if (!isScrollInProgress) {
+ currentOnScrollSettled(i, o)
+ }
+ }
}
SafeSelectionContainer(modifier = Modifier.fillMaxSize()) {
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/LineTargumView.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/LineTargumView.kt
index 2eb16846..0da48867 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/LineTargumView.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/LineTargumView.kt
@@ -187,11 +187,16 @@ private fun SingleLineTargumView(
Text(text = stringResource(emptyRes))
}
} else {
+ // Preserve insertion order from the provider — the SQL
+ // ORDER BY (l.isDeclaredBase DESC, b.isBaseBook DESC,
+ // b.id, sl.lineIndex) ranks Sefaria-declared bases
+ // first and then by canonical catalog position
+ // (Tanakh → Mishnah → Bavli → Yerushalmi → Tosefta →
+ // Halakhah → commentators). Re-sorting by title
+ // alphabet would override that semantic ordering.
val availableSources =
remember(titleToIdMap) {
- titleToIdMap.entries
- .sortedBy { it.key }
- .map { SourceMeta(it.key, it.value) }
+ titleToIdMap.entries.map { SourceMeta(it.key, it.value) }
}
val selectedSources =
@@ -584,11 +589,14 @@ private fun MultiLineTargumView(
Text(text = stringResource(emptyRes))
}
} else {
+ // Preserve insertion order from the provider — the underlying
+ // SQL ORDER BY (l.isDeclaredBase DESC, b.orderIndex, sl.lineIndex)
+ // already ranks declared bases first and otherwise sorts by the
+ // source book's catalog position. Re-sorting by title alphabet
+ // here would override that semantic ordering.
val availableSources =
remember(titleToIdMap) {
- titleToIdMap.entries
- .sortedBy { it.key }
- .map { SourceMeta(it.key, it.value) }
+ titleToIdMap.entries.map { SourceMeta(it.key, it.value) }
}
// Build pagers for each source using multi-line provider
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCase.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCase.kt
index 062c354a..f4c77b45 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCase.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentariesUseCase.kt
@@ -61,7 +61,9 @@ class CommentariesUseCase(
localCache[bookId] = cached
return cached
}
- val loaded = runSuspendCatching { repository.getBookWithPubDates(bookId) }.getOrNull() ?: return null
+ // Load authors + pubDates so commentator ordering can use canonical author ranks
+ // before falling back to publication-date heuristics.
+ val loaded = runSuspendCatching { repository.getBook(bookId) }.getOrNull() ?: return null
commentatorBookCache[bookId] = loaded
localCache[bookId] = loaded
return loaded
@@ -306,21 +308,35 @@ class CommentariesUseCase(
}
/**
- * Récupère les sources disponibles pour plusieurs lignes (union)
+ * Récupère les sources disponibles pour plusieurs lignes (union).
+ *
+ * Issues a single inverse-direction repository query against the union of
+ * all base lines so the order returned by the underlying SQL
+ * (`l.isDeclaredBase DESC, b.orderIndex, sl.lineIndex`) is respected
+ * globally. Per-lineId iteration would interleave per-source-book entries
+ * by lineId rather than by catalog position.
*/
suspend fun getAvailableSourcesForLines(lineIds: List): Map {
if (lineIds.isEmpty()) return emptyMap()
return runSuspendCatching {
- val map = LinkedHashMap()
- for (lineId in lineIds) {
- val sources = getAvailableSources(lineId)
- sources.forEach { (name, id) ->
- if (!map.containsKey(name)) {
- map[name] = id
- }
- }
- }
- map
+ val selectedBook =
+ stateManager.state
+ .first()
+ .navigation.selectedBook
+ if (selectedBook?.hasSourceConnection != true) return@runSuspendCatching emptyMap()
+
+ val allBaseIds =
+ lineIds
+ .flatMap { resolveBaseLineIds(it) }
+ .distinct()
+ if (allBaseIds.isEmpty()) return@runSuspendCatching emptyMap()
+
+ val links =
+ repository
+ .getCommentarySummariesForLines(allBaseIds, includeSources = true)
+ .filter { it.link.connectionType == ConnectionType.SOURCE }
+
+ buildSourceMap(links, selectedBook.title.trim())
}.getOrElse { emptyMap() }
}
@@ -370,13 +386,29 @@ class CommentariesUseCase(
return loaded
}
+ // Collect the full ancestor chain (book.categoryId → root). Cap depth as
+ // a safety net against accidental cycles in the closure table.
+ val chain = mutableListOf()
var currentId: Long? = book.categoryId
- while (currentId != null) {
+ var depth = 0
+ while (currentId != null && depth < 32) {
+ currentCoroutineContext().ensureActive()
+ val cat = loadCategory(currentId) ?: break
+ chain.add(cat)
+ currentId = cat.parentId
+ depth++
+ }
+ if (chain.isEmpty()) return ""
+
+ // Pass 1: the strongest, most-informative bucket — "X על Y" labels
+ // anchored on a primary corpus (Tanakh / Talmud / Mishna / Shas) win
+ // over everything else, scanned across the whole ancestor chain. This
+ // is important because a sub-commentary on Rif (chain:
+ // `מפרשים < רי״ף < ראשונים על התלמוד`) must surface as
+ // `ראשונים על התלמוד`, NOT as `מפרשים על רי״ף`.
+ for (category in chain) {
currentCoroutineContext().ensureActive()
- val category = loadCategory(currentId) ?: break
val title = category.title
-
- // Prefer high-level "commentaries on ..." buckets
if (
title.contains("על התנ״ך") ||
title.contains("על התלמוד") ||
@@ -387,41 +419,291 @@ class CommentariesUseCase(
) {
return title
}
+ }
- // Broad families (e.g., חסידות, מילונים, מחברי זמננו)
- if (title == "חסידות" || title.contains("חסידות")) {
- return title
- }
- if (title.contains("מילונים")) {
- return title
- }
- if (title == "ראשונים") {
- return title
- }
- if (title == "מחברי זמננו") {
- return title
- }
- if (title == "ביאור חברותא" || title == "הערות על ביאור חברותא") {
- return "חברותא"
- }
+ // Pass 2: hard-coded multi-book families (chevruta, dictionaries,
+ // contemporary authors).
+ for (category in chain) {
+ currentCoroutineContext().ensureActive()
+ val title = category.title
+ if (title == "ביאור חברותא" || title == "הערות על ביאור חברותא") return "חברותא"
+ if (title.contains("מילונים")) return title
+ if (title == "מחברי זמננו") return title
+ }
- // Generic "מפרשים" bucket (e.g., for משנה תורה)
- if (title == "מפרשים") {
- val parent =
- category.parentId?.let { parentId ->
- loadCategory(parentId)
+ // Pass 3: "מפרשים" — only when no higher-level "X על Y" anchor exists
+ // (e.g. Mishneh Torah's super-commentaries). Check if the parent's
+ // ancestors include a corpus reference. If so, use that. Otherwise use
+ // immediate parent.
+ for ((idx, category) in chain.withIndex()) {
+ currentCoroutineContext().ensureActive()
+ if (category.title == "מפרשים") {
+ // Check if "ראשונים" appears further up the chain; if so, use its corpus parent.
+ for (ancestorIdx in (idx + 1) until chain.size) {
+ val ancestor = chain[ancestorIdx]
+ if (ancestor.title == "ראשונים") {
+ // Found "ראשונים" — use its parent corpus
+ val corpus = chain.getOrNull(ancestorIdx + 1)
+ if (corpus != null) {
+ return "ראשונים על ${when (corpus.title) {
+ "בבלי", "בבל" -> "התלמוד"
+ "תנ״ך", "תנך" -> "התנ״ך"
+ "משנה" -> "המשנה"
+ "ש\"ס", "ש״ס", "שס" -> "הש\"ס"
+ else -> corpus.title
+ }}"
+ }
+ break
}
+ }
+ // Fallback: use immediate parent
+ val parent = chain.getOrNull(idx + 1)
if (parent != null && parent.title.isNotBlank()) {
return "מפרשים על ${parent.title}"
}
+ return "מפרשים"
+ }
+ }
+
+ // Pass 4: bare "ראשונים" / "אחרונים" anywhere in the chain. Look for corpus
+ // references further up; if found, use those. Otherwise use immediate parent.
+ for ((idx, category) in chain.withIndex()) {
+ currentCoroutineContext().ensureActive()
+ val title = category.title
+ if (title == "ראשונים" || title == "אחרונים") {
+ // Check ancestors for corpus references (בבלי, תנ״ך, משנה, etc.)
+ for (ancestorIdx in (idx + 1) until chain.size) {
+ val ancestor = chain[ancestorIdx]
+ val corpusLabel = when (ancestor.title) {
+ "בבלי", "בבל" -> "התלמוד"
+ "תנ״ך", "תנך" -> "התנ״ך"
+ "משנה" -> "המשנה"
+ "ש\"ס", "ש״ס", "שס" -> "הש\"ס"
+ else -> null
+ }
+ if (corpusLabel != null) {
+ return "$title על $corpusLabel"
+ }
+ }
+ // Fallback: use immediate parent
+ val parent = chain.getOrNull(idx + 1)
+ if (parent != null && parent.title.isNotBlank()) {
+ val parentLabel = when (parent.title) {
+ "תנ״ך", "תנך" -> "התנ״ך"
+ "תלמוד" -> "התלמוד"
+ "משנה" -> "המשנה"
+ "ש\"ס", "ש״ס", "שס" -> "הש\"ס"
+ else -> parent.title
+ }
+ return "$title על $parentLabel"
+ }
return title
}
+ }
- currentId = category.parentId
+ // Second pass: collapse fragmented sub-trees (Targumim, Midrash, Kabbalah, Chasidut)
+ // into a single top-level bucket so multi-volume editorial families don't
+ // create one group per book.
+ for (category in chain) {
+ currentCoroutineContext().ensureActive()
+ val title = category.title
+ // Targums: "תורה < תרגום אונקלוס < תרגומים < תנ״ך"; also bare "תרגום ירושלמי" etc.
+ if (title == "תרגומים" || title.startsWith("תרגום ") || title.startsWith("תפסיר ")) {
+ return "תרגומים"
+ }
+ // Midrash: "מדרש לקח טוב < אגדה < מדרש", "מדרש רבה < אגדה < מדרש".
+ if (title == "מדרש") return "מדרש"
+ // Kabbalah: "ספרי קבלה נוספים < קבלה", "זהר < קבלה".
+ if (title == "קבלה") return "קבלה"
+ // Chasidut sub-tree.
+ if (title == "חסידות" || title.contains("חסידות")) return "חסידות"
}
- val baseCategory = loadCategory(book.categoryId)
- return baseCategory?.title ?: ""
+ // Fallback: use the deepest reasonable bucket from the root side
+ // (avoid single-author categories like חזקוני/מהר״ל that produce singleton groups).
+ val rootLevel = chain.lastOrNull()?.title
+ return rootLevel ?: chain.first().title
+ }
+
+ /**
+ * Canonical editorial rank for top-level commentator groups. Lower wins.
+ * Anything not listed falls into [GROUP_RANK_DEFAULT] and is sorted by
+ * pub-date / alphabet within that bucket.
+ */
+ private fun groupRank(label: String): Int {
+ // Tanach buckets
+ if (label == "תרגומים") return 10
+ if (label.startsWith("ראשונים על המשנה")) return 20
+ if (label.startsWith("ראשונים על התלמוד") || label.startsWith("ראשונים על הש")) return 25
+ if (label.startsWith("ראשונים על התנ״ך")) return 30
+ if (label.startsWith("אחרונים על המשנה")) return 40
+ if (label.startsWith("אחרונים על התלמוד") || label.startsWith("אחרונים על הש")) return 45
+ if (label.startsWith("אחרונים על התנ״ך")) return 50
+ if (label.startsWith("מפרשים על")) return 55
+ if (label == "ראשונים") return 60
+ if (label == "אחרונים") return 65
+ if (label.startsWith("ראשונים על ")) return 67
+ if (label.startsWith("אחרונים על ")) return 68
+ if (label == "מדרש") return 70
+ if (label == "חסידות") return 80
+ if (label == "קבלה") return 90
+ if (label == "חברותא") return 100
+ if (label.contains("מילונים")) return 110
+ if (label == "מחברי זמננו") return 120
+ return GROUP_RANK_DEFAULT
+ }
+
+ /**
+ * Approximate canonical year (birth) for major Rishonim/Acharonim. Used as the
+ * primary intra-group sort key — the printed first-edition dates stored in
+ * `pub_date` are too noisy (Rashi at 1476, Hadar Zekenim at 1840) to give a
+ * stable chronological order on their own.
+ *
+ * Match against [Book.authors] first, then against [Book.title] / displayName.
+ */
+ private fun canonicalRank(
+ book: Book?,
+ displayName: String,
+ ): Int? {
+ if (book == null) return null
+
+ fun normalize(s: String): String = s.replace('"', '״').replace('\'', '׳').trim()
+
+ val authorNames = book.authors.map { normalize(it.name) }
+ for (author in authorNames) {
+ CANONICAL_AUTHOR_YEAR[author]?.let { return it }
+ }
+ val title = normalize(book.title)
+ for ((pattern, year) in CANONICAL_TITLE_YEAR) {
+ if (title.contains(pattern)) return year
+ }
+ val display = normalize(displayName)
+ for ((pattern, year) in CANONICAL_TITLE_YEAR) {
+ if (display.contains(pattern)) return year
+ }
+ return null
+ }
+
+ private companion object {
+ const val GROUP_RANK_DEFAULT = 1_000
+
+ // Canonical (approximate) author birth years for the dominant Rishonim
+ // and Acharonim that ship with the corpus. Keys are normalized to gershayim
+ // form (״). Add to this list when new "VIP" commentators surface.
+ val CANONICAL_AUTHOR_YEAR: Map =
+ mapOf(
+ // Geonim
+ "ר' סעדיה גאון" to 882,
+ "סעדיה גאון" to 882,
+ "רב סעדיה גאון" to 882,
+ // Rishonim – Ashkenaz / Tsarfat
+ "רש״י" to 1040,
+ "רשב״ם" to 1085,
+ "ר' יוסף קרא" to 1065,
+ "יוסף בכור שור" to 1140,
+ "אבן עזרא" to 1089,
+ "ר' אברהם אבן עזרא" to 1089,
+ "רד״ק" to 1160,
+ "רמב״ן" to 1194,
+ "חזקוני" to 1240,
+ "רא״ש" to 1250,
+ "רבנו בחיי" to 1255,
+ "בחיי בן אשר" to 1255,
+ "יעקב בן אשר" to 1269,
+ "רלב״ג" to 1288,
+ "מנחם ריקנטי" to 1290,
+ "אברבנאל" to 1437,
+ "עובדיה מברטנורא" to 1445,
+ "אליהו בן אברהם מזרחי" to 1455,
+ "ספורנו" to 1475,
+ // Acharonim
+ "אלשיך" to 1508,
+ "מהר״ל" to 1520,
+ "שלמה אפרים מלונטשיץ" to 1550,
+ "ש״ך" to 1622,
+ "חיים בן עטר" to 1696,
+ "אליהו בן שלמה זלמן מווילנה" to 1720,
+ "משה סופר" to 1762,
+ "נתן נטע שפירא" to 1585,
+ "יעקב צבי מקלנבורג" to 1785,
+ "מלבי״ם" to 1809,
+ "נפתלי צבי יהודה ברלין" to 1816,
+ "יוסף חיים" to 1834,
+ "מאיר שמחה הכהן" to 1843,
+ "רב יוסף דוב הלוי סולובייצ'ק" to 1903,
+ )
+
+ // Title fragments (normalized to gershayim) → canonical year. Acts as a
+ // fallback when the author table is sparse for older works.
+ val CANONICAL_TITLE_YEAR: List> =
+ listOf(
+ "רס״ג" to 882,
+ "ר' סעדיה גאון" to 882,
+ "רש״י" to 1040,
+ "רשב״ם" to 1085,
+ "אבן עזרא" to 1089,
+ "אב״ע" to 1089,
+ "ראב״ע" to 1089,
+ "בכור שור" to 1140,
+ "רד״ק" to 1160,
+ "רמב״ן" to 1194,
+ "חזקוני" to 1240,
+ "רא״ש" to 1250,
+ "רבנו בחיי" to 1255,
+ "רבינו בחיי" to 1255,
+ "בעל הטורים" to 1269,
+ "הטור הארוך" to 1269,
+ "רלב״ג" to 1288,
+ "פענח רזא" to 1295,
+ "ריקנטי" to 1290,
+ "רקנאטי" to 1290,
+ "דעת זקנים" to 1290,
+ "הדר זקנים" to 1290,
+ "אברבנאל" to 1437,
+ "ברטנורא" to 1445,
+ "מזרחי" to 1455,
+ "ספורנו" to 1475,
+ "צרור המור" to 1440,
+ "תולדות יצחק" to 1458,
+ "אלשיך" to 1508,
+ "מהר״ל" to 1520,
+ "גור אריה" to 1520,
+ "כלי יקר" to 1550,
+ "שפתי כהן" to 1622,
+ "אור החיים" to 1696,
+ "מנחת שי" to 1565,
+ "שפתי חכמים" to 1641,
+ "אבי עזר" to 1750,
+ "אדרת אליהו" to 1720,
+ "הגר״א" to 1720,
+ "חתם סופר" to 1762,
+ "הכתב והקבלה" to 1785,
+ "תפארת יהונתן" to 1690,
+ "אהבת יהונתן" to 1690,
+ "מלבי״ם" to 1809,
+ "העמק דבר" to 1816,
+ "נצי״ב" to 1816,
+ "רש״ר הירש" to 1808,
+ "בן איש חי" to 1834,
+ "תורה תמימה" to 1860,
+ "פרדס יוסף" to 1880,
+ "משך חכמה" to 1843,
+ "בית הלוי" to 1820,
+ "נתינה לגר" to 1875,
+ "תרגום אונקלוס" to -100,
+ "תרגום יונתן" to 100,
+ "תרגום ירושלמי" to 200,
+ "תפסיר רס״ג" to 882,
+ "מדרש" to 500,
+ "בראשית רבה" to 400,
+ "שמות רבה" to 400,
+ "ויקרא רבה" to 400,
+ "מדרש תנחומא" to 500,
+ "תנחומא בובר" to 500,
+ "פסיקתא" to 600,
+ "מכילתא" to 200,
+ "ספרי" to 200,
+ )
}
private fun sanitizeCommentatorName(
@@ -502,30 +784,40 @@ class CommentariesUseCase(
data class TempGroup(
val label: String,
+ val rank: Int,
val entries: List,
val earliestYear: Int,
)
+ // Resolve a chronological year per entry, falling back through:
+ // 1. canonicalRank() — hand-curated author/title → birth year table.
+ // 2. earliest pub_date year — first known printing.
+ // 3. Int.MAX_VALUE — pushes undated entries to the tail.
+ fun entryYear(entry: CommentatorEntry): Int {
+ canonicalRank(entry.book, entry.displayName)?.let { return it }
+ entry.book
+ ?.pubDates
+ ?.let { extractEarliestYear(it) }
+ ?.let { return it }
+ return Int.MAX_VALUE
+ }
+
val tempGroups =
groupsByLabel.map { (label, groupEntries) ->
val sortedEntries =
groupEntries.sortedWith(
compareBy(
+ // "הערות על X" companion notes always trail their parent book.
{ if (categoryCache[it.book?.categoryId]?.title?.startsWith("הערות על") == true) 1 else 0 },
- { it.book?.pubDates?.let { d -> extractEarliestYear(d) } ?: Int.MAX_VALUE },
+ { entryYear(it) },
{ it.displayName },
),
)
- val groupEarliestYear =
- sortedEntries
- .firstOrNull()
- ?.book
- ?.pubDates
- ?.let { extractEarliestYear(it) }
- ?: Int.MAX_VALUE
+ val groupEarliestYear = sortedEntries.minOfOrNull { entryYear(it) } ?: Int.MAX_VALUE
TempGroup(
label = label,
+ rank = groupRank(label),
entries = sortedEntries,
earliestYear = groupEarliestYear,
)
@@ -533,7 +825,10 @@ class CommentariesUseCase(
return tempGroups
.sortedWith(
- compareBy { it.earliestYear }
+ // Editorial rank dominates (Targums → Rishonim → Acharonim → Midrash → …);
+ // within the same rank, earliest year then label keep results deterministic.
+ compareBy { it.rank }
+ .thenBy { it.earliestYear }
.thenBy { it.label },
).map { group ->
CommentatorGroup(
@@ -662,20 +957,20 @@ class CommentariesUseCase(
suspend fun getAvailableSources(lineId: Long): Map =
runSuspendCatching {
+ val selectedBook =
+ stateManager.state
+ .first()
+ .navigation.selectedBook
+ // Fast path: book has no inbound oriented links — no need to hit DB.
+ if (selectedBook?.hasSourceConnection != true) return@runSuspendCatching emptyMap()
+
val baseIds = resolveBaseLineIds(lineId)
val links =
repository
.getCommentarySummariesForLines(baseIds)
.filter { it.link.connectionType == ConnectionType.SOURCE }
- val currentBookTitle =
- stateManager.state
- .first()
- .navigation.selectedBook
- ?.title
- ?.trim()
- .orEmpty()
-
+ val currentBookTitle = selectedBook.title.trim()
buildSourceMap(links, currentBookTitle)
}.getOrElse { emptyMap() }
@@ -694,13 +989,19 @@ class CommentariesUseCase(
val allBaseIds = resolutionCache.values.flatMap { it.baseLineIds }.distinct()
if (allBaseIds.isEmpty()) return distinctIds.associateWith { LineConnectionsSnapshot() }
- val allConnections = repository.getCommentarySummariesForLines(allBaseIds)
+ val currentState = stateManager.state.first()
+ val selectedBook = currentState.navigation.selectedBook
+ // Skip the SOURCE-side inverse query when the current book has no
+ // dependant-side links at all (e.g. Tanakh, Mishna). Saves a costly
+ // mirror query on hot navigation paths.
+ val includeSources = selectedBook?.hasSourceConnection == true
+
+ val allConnections = repository.getCommentarySummariesForLines(allBaseIds,)
if (allConnections.isEmpty()) return distinctIds.associateWith { LineConnectionsSnapshot() }
val connectionsBySource = allConnections.groupBy { it.link.sourceLineId }
- val currentState = stateManager.state.first()
val currentBookTitle =
- currentState.navigation.selectedBook
+ selectedBook
?.title
?.trim()
.orEmpty()
@@ -1139,7 +1440,14 @@ class CommentariesUseCase(
index: Int,
offset: Int,
) {
- stateManager.updateContent {
+ stateManager.updateContent(save = false) {
+ if (
+ commentariesColumnScrollIndexByCommentator[commentatorId] == index &&
+ commentariesColumnScrollOffsetByCommentator[commentatorId] == offset
+ ) {
+ return@updateContent this
+ }
+
val idxMap = commentariesColumnScrollIndexByCommentator.toMutableMap()
val offMap = commentariesColumnScrollOffsetByCommentator.toMutableMap()
idxMap[commentatorId] = index
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/DatabaseUpdateWindow.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/DatabaseUpdateWindow.kt
index 11145a52..f39377e0 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/DatabaseUpdateWindow.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/database/update/DatabaseUpdateWindow.kt
@@ -7,13 +7,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.ApplicationScope
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.navigation.compose.rememberNavController
-import io.github.kdroidfilter.nucleus.window.ControlButtonsDirection
-import io.github.kdroidfilter.nucleus.window.jewel.JewelDecoratedWindow
-import io.github.kdroidfilter.nucleus.window.jewel.JewelTitleBar
-import io.github.kdroidfilter.nucleus.window.newFullscreenControls
+import dev.nucleusframework.application.NucleusApplicationScope
+import dev.nucleusframework.window.BasicTitleBar
+import dev.nucleusframework.window.ControlButtonsDirection
+import dev.nucleusframework.window.TitleBarLayoutPolicy
+import dev.nucleusframework.window.jewel.JewelDecoratedWindow
+import dev.nucleusframework.window.newFullscreenControls
+import dev.nucleusframework.window.styling.LocalTitleBarStyle
+import org.jetbrains.jewel.foundation.theme.LocalContentColor
import io.github.kdroidfilter.seforimapp.core.presentation.theme.ThemeUtils
import io.github.kdroidfilter.seforimapp.core.presentation.utils.LocalWindowViewModelStoreOwner
import io.github.kdroidfilter.seforimapp.core.presentation.utils.getCenteredWindowState
@@ -35,7 +38,7 @@ import seforimapp.seforimapp.generated.resources.app_name
import seforimapp.seforimapp.generated.resources.db_update_title_bar
@Composable
-fun ApplicationScope.DatabaseUpdateWindow(
+fun NucleusApplicationScope.DatabaseUpdateWindow(
onUpdateComplete: () -> Unit = {},
isDatabaseMissing: Boolean = false,
) {
@@ -53,8 +56,6 @@ fun ApplicationScope.DatabaseUpdateWindow(
LocalWindowViewModelStoreOwner provides windowViewModelOwner,
LocalViewModelStoreOwner provides windowViewModelOwner,
) {
- val isMac = PlatformInfo.isMacOS
- val isWindows = PlatformInfo.isWindows
val navController = rememberNavController()
var canNavigateBack by remember { mutableStateOf(false) }
@@ -64,24 +65,20 @@ fun ApplicationScope.DatabaseUpdateWindow(
}
}
- JewelTitleBar(
+ val titleBarStyle = LocalTitleBarStyle.current
+ BasicTitleBar(
modifier = Modifier.newFullscreenControls(),
gradientStartColor = ThemeUtils.titleBarGradientColor(),
+ style = titleBarStyle,
controlButtonsDirection = ControlButtonsDirection.SystemNative,
+ layoutPolicy = TitleBarLayoutPolicy.FillCenter,
) {
- // Keep the back button pinned to the start and
- // center the title (icon + text) regardless of OS/window controls.
- Box(
- modifier =
- Modifier
- .fillMaxWidth(if (isMac) 0.9f else 1f)
- .padding(start = if (isWindows) 70.dp else 0.dp),
- ) {
+ CompositionLocalProvider(LocalContentColor provides titleBarStyle.colors.content) {
if (canNavigateBack) {
IconButton(
modifier =
Modifier
- .align(Alignment.CenterStart)
+ .align(Alignment.Start)
.padding(start = 8.dp)
.size(24.dp),
onClick = { navController.navigateUp() },
@@ -90,15 +87,13 @@ fun ApplicationScope.DatabaseUpdateWindow(
}
}
- val centerOffset = 40.dp
-
Row(
modifier =
Modifier
- .align(Alignment.Center)
- .offset(x = centerOffset),
+ .align(Alignment.CenterHorizontally)
+ .fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.Center,
) {
Icon(
Deployed_code_update,
@@ -106,6 +101,7 @@ fun ApplicationScope.DatabaseUpdateWindow(
tint = JewelTheme.globalColors.text.normal,
modifier = Modifier.size(16.dp),
)
+ Spacer(Modifier.size(8.dp))
Text(stringResource(Res.string.db_update_title_bar))
}
}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/OnBoardingWindow.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/OnBoardingWindow.kt
index f80208c3..34334e01 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/OnBoardingWindow.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/OnBoardingWindow.kt
@@ -7,13 +7,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.ApplicationScope
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.navigation.compose.rememberNavController
-import io.github.kdroidfilter.nucleus.window.ControlButtonsDirection
-import io.github.kdroidfilter.nucleus.window.jewel.JewelDecoratedWindow
-import io.github.kdroidfilter.nucleus.window.jewel.JewelTitleBar
-import io.github.kdroidfilter.nucleus.window.newFullscreenControls
+import dev.nucleusframework.application.NucleusApplicationScope
+import dev.nucleusframework.window.BasicTitleBar
+import dev.nucleusframework.window.ControlButtonsDirection
+import dev.nucleusframework.window.TitleBarLayoutPolicy
+import dev.nucleusframework.window.jewel.JewelDecoratedWindow
+import dev.nucleusframework.window.newFullscreenControls
+import dev.nucleusframework.window.styling.LocalTitleBarStyle
+import org.jetbrains.jewel.foundation.theme.LocalContentColor
import io.github.kdroidfilter.seforimapp.core.presentation.theme.ThemeUtils
import io.github.kdroidfilter.seforimapp.core.presentation.utils.LocalWindowViewModelStoreOwner
import io.github.kdroidfilter.seforimapp.core.presentation.utils.getCenteredWindowState
@@ -35,7 +38,7 @@ import seforimapp.seforimapp.generated.resources.app_name
import seforimapp.seforimapp.generated.resources.onboarding_title_bar
@Composable
-fun ApplicationScope.OnBoardingWindow() {
+fun NucleusApplicationScope.OnBoardingWindow() {
val onboardingWindowState = remember { getCenteredWindowState(720, 420) }
JewelDecoratedWindow(
onCloseRequest = { exitApplication() },
@@ -50,8 +53,6 @@ fun ApplicationScope.OnBoardingWindow() {
LocalWindowViewModelStoreOwner provides windowViewModelOwner,
LocalViewModelStoreOwner provides windowViewModelOwner,
) {
- val isMac = PlatformInfo.isMacOS
- val isWindows = PlatformInfo.isWindows
val navController = rememberNavController()
var canNavigateBack by remember { mutableStateOf(false) }
LaunchedEffect(navController) {
@@ -59,24 +60,20 @@ fun ApplicationScope.OnBoardingWindow() {
canNavigateBack = navController.previousBackStackEntry != null
}
}
- JewelTitleBar(
+ val titleBarStyle = LocalTitleBarStyle.current
+ BasicTitleBar(
modifier = Modifier.newFullscreenControls(),
gradientStartColor = ThemeUtils.titleBarGradientColor(),
+ style = titleBarStyle,
controlButtonsDirection = ControlButtonsDirection.SystemNative,
+ layoutPolicy = TitleBarLayoutPolicy.FillCenter,
) {
- // Keep the back button pinned to the start and
- // center the title (icon + text) regardless of OS/window controls.
- Box(
- modifier =
- Modifier
- .fillMaxWidth(if (isMac) 0.9f else 1f)
- .padding(start = if (isWindows) 70.dp else 0.dp),
- ) {
+ CompositionLocalProvider(LocalContentColor provides titleBarStyle.colors.content) {
if (canNavigateBack) {
IconButton(
modifier =
Modifier
- .align(Alignment.CenterStart)
+ .align(Alignment.Start)
.padding(start = 8.dp)
.size(24.dp),
onClick = { navController.navigateUp() },
@@ -84,16 +81,13 @@ fun ApplicationScope.OnBoardingWindow() {
Icon(AllIconsKeys.Actions.Back, null, modifier = Modifier.rotate(180f))
}
}
-
- val centerOffset = 40.dp
-
Row(
modifier =
Modifier
- .align(Alignment.Center)
- .offset(x = centerOffset),
+ .align(Alignment.CenterHorizontally)
+ .fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.Center,
) {
Icon(
Install_desktop,
@@ -101,6 +95,7 @@ fun ApplicationScope.OnBoardingWindow() {
tint = JewelTheme.globalColors.text.normal,
modifier = Modifier.size(16.dp),
)
+ Spacer(Modifier.size(8.dp))
Text(stringResource(Res.string.onboarding_title_bar))
}
}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceUseCase.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceUseCase.kt
index dbd9e3f6..96f4599c 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceUseCase.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/onboarding/diskspace/AvailableDiskSpaceUseCase.kt
@@ -1,6 +1,6 @@
package io.github.kdroidfilter.seforimapp.features.onboarding.diskspace
-import io.github.kdroidfilter.nucleus.systeminfo.SystemInfo
+import dev.nucleusframework.systeminfo.SystemInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindow.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindow.kt
index 04d1cfd1..1a210348 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindow.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/SettingsWindow.kt
@@ -14,10 +14,11 @@ import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.rememberDialogState
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.navigation.compose.rememberNavController
-import io.github.kdroidfilter.nucleus.window.ControlButtonsDirection
-import io.github.kdroidfilter.nucleus.window.jewel.JewelDecoratedDialog
-import io.github.kdroidfilter.nucleus.window.jewel.JewelDialogTitleBar
-import io.github.kdroidfilter.nucleus.window.newFullscreenControls
+import dev.nucleusframework.application.NucleusApplicationScope
+import dev.nucleusframework.window.ControlButtonsDirection
+import dev.nucleusframework.window.jewel.JewelDecoratedDialog
+import dev.nucleusframework.window.jewel.JewelDialogTitleBar
+import dev.nucleusframework.window.newFullscreenControls
import io.github.kdroidfilter.seforimapp.core.presentation.theme.ThemeUtils
import io.github.kdroidfilter.seforimapp.core.presentation.theme.ThemeUtils.buildThemeDefinition
import io.github.kdroidfilter.seforimapp.core.presentation.utils.LocalWindowViewModelStoreOwner
@@ -42,7 +43,7 @@ import seforimapp.seforimapp.generated.resources.settings
import seforimapp.seforimapp.generated.resources.settings_close
@Composable
-fun SettingsWindow(
+fun NucleusApplicationScope.SettingsWindow(
onClose: () -> Unit,
initialDestination: SettingsDestination? = null,
) {
@@ -53,7 +54,7 @@ fun SettingsWindow(
}
@Composable
-private fun SettingsWindowView(
+private fun NucleusApplicationScope.SettingsWindowView(
onClose: () -> Unit,
initialDestination: SettingsDestination? = null,
) {
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateSection.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateSection.kt
index 3b31871f..cd2c57a0 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateSection.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateSection.kt
@@ -34,12 +34,11 @@ import org.jetbrains.jewel.ui.component.Text
* Jewel + Material3 LinearProgressIndicator.
*/
@Composable
-fun DbDeltaUpdateSection(
- modifier: Modifier = Modifier,
-) {
- val viewModel = metroViewModel(
- viewModelStoreOwner = LocalWindowViewModelStoreOwner.current,
- )
+fun DbDeltaUpdateSection(modifier: Modifier = Modifier) {
+ val viewModel =
+ metroViewModel(
+ viewModelStoreOwner = LocalWindowViewModelStoreOwner.current,
+ )
val state by viewModel.state.collectAsState()
val isBusy = state.phase != null
val hasMessage = state.message.isNotEmpty() || state.errorMessage != null
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateViewModel.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateViewModel.kt
index 5c700480..f10eb137 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateViewModel.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateViewModel.kt
@@ -33,7 +33,6 @@ class DbDeltaUpdateViewModel(
private val deltaService: DbDeltaUpdateService,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : ViewModel() {
-
private val mutableState = MutableStateFlow(DbDeltaUpdateState())
val state = mutableState.asStateFlow()
@@ -50,46 +49,55 @@ class DbDeltaUpdateViewModel(
if (current.phase != null) return // already running
viewModelScope.launch {
- mutableState.value = DbDeltaUpdateState(
- phase = DbDeltaUpdateState.Phase.CheckingForUpdates,
- message = "Checking server for new database delta…",
- )
+ mutableState.value =
+ DbDeltaUpdateState(
+ phase = DbDeltaUpdateState.Phase.CheckingForUpdates,
+ message = "Checking server for new database delta…",
+ )
try {
- val outcome = withContext(ioDispatcher) {
- deltaService.checkAndApply { _, _, status ->
- // The orchestrator pumps statuses like
- // "downloading patch files", "applying sqlite delta",
- // "updating lucene", "updating catalog", "done".
- val phase = when {
- "download" in status -> DbDeltaUpdateState.Phase.Downloading
- "sqlite" in status -> DbDeltaUpdateState.Phase.Applying
- "lucene" in status || "catalog" in status ->
- DbDeltaUpdateState.Phase.UpdatingIndex
- else -> mutableState.value.phase
+ val outcome =
+ withContext(ioDispatcher) {
+ deltaService.checkAndApply { _, _, status ->
+ // The orchestrator pumps statuses like
+ // "downloading patch files", "applying sqlite delta",
+ // "updating lucene", "updating catalog", "done".
+ val phase =
+ when {
+ "download" in status -> DbDeltaUpdateState.Phase.Downloading
+ "sqlite" in status -> DbDeltaUpdateState.Phase.Applying
+ "lucene" in status || "catalog" in status ->
+ DbDeltaUpdateState.Phase.UpdatingIndex
+ else -> mutableState.value.phase
+ }
+ mutableState.value =
+ mutableState.value.copy(
+ phase = phase,
+ message = status,
+ )
}
- mutableState.value = mutableState.value.copy(
- phase = phase,
- message = status,
- )
}
- }
- mutableState.value = when (outcome) {
- DbDeltaUpdateService.Outcome.UpToDate -> DbDeltaUpdateState(
- message = "Database is up to date.",
- )
- is DbDeltaUpdateService.Outcome.Applied -> DbDeltaUpdateState(
- message = "Applied ${outcome.deltaCount} delta(s).",
- lastAppliedCount = outcome.deltaCount,
- )
- DbDeltaUpdateService.Outcome.NeedsFullBundle -> DbDeltaUpdateState(
- message = "Your local database is too old for an incremental update — please download the full bundle.",
- needsFullBundle = true,
- )
- }
+ mutableState.value =
+ when (outcome) {
+ DbDeltaUpdateService.Outcome.UpToDate ->
+ DbDeltaUpdateState(
+ message = "Database is up to date.",
+ )
+ is DbDeltaUpdateService.Outcome.Applied ->
+ DbDeltaUpdateState(
+ message = "Applied ${outcome.deltaCount} delta(s).",
+ lastAppliedCount = outcome.deltaCount,
+ )
+ DbDeltaUpdateService.Outcome.NeedsFullBundle ->
+ DbDeltaUpdateState(
+ message = "Your local database is too old for an incremental update — please download the full bundle.",
+ needsFullBundle = true,
+ )
+ }
} catch (t: Throwable) {
- mutableState.value = DbDeltaUpdateState(
- errorMessage = "Update failed: ${t.message ?: t.javaClass.simpleName}",
- )
+ mutableState.value =
+ DbDeltaUpdateState(
+ errorMessage = "Update failed: ${t.message ?: t.javaClass.simpleName}",
+ )
}
}
}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/ui/GeneralSettingsScreen.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/ui/GeneralSettingsScreen.kt
index c16f3be5..0e0aa959 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/ui/GeneralSettingsScreen.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/ui/GeneralSettingsScreen.kt
@@ -25,8 +25,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import dev.nucleusframework.updater.UpdaterConfig
import dev.zacsweers.metrox.viewmodel.metroViewModel
-import io.github.kdroidfilter.nucleus.updater.UpdaterConfig
import io.github.kdroidfilter.seforimapp.core.presentation.utils.LocalWindowViewModelStoreOwner
import io.github.kdroidfilter.seforimapp.features.settings.general.GeneralSettingsEvents
import io.github.kdroidfilter.seforimapp.features.settings.general.GeneralSettingsState
@@ -117,7 +117,8 @@ private fun GeneralSettingsView(
// Database delta-update panel: checks the release server for
// a new seforim.db delta and applies it incrementally.
- io.github.kdroidfilter.seforimapp.features.settings.dbupdate.DbDeltaUpdateSection()
+ io.github.kdroidfilter.seforimapp.features.settings.dbupdate
+ .DbDeltaUpdateSection()
ResetSection(
resetDone = state.resetDone,
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppGraph.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppGraph.kt
index 96f913cb..5282e499 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppGraph.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppGraph.kt
@@ -6,6 +6,7 @@ import dev.zacsweers.metrox.viewmodel.ViewModelGraph
import io.github.kdroidfilter.seforim.tabs.TabTitleUpdateManager
import io.github.kdroidfilter.seforim.tabs.TabsViewModel
import io.github.kdroidfilter.seforimapp.core.MainAppState
+import io.github.kdroidfilter.seforimapp.core.catalog.CatalogAccess
import io.github.kdroidfilter.seforimapp.core.selection.SelectionContext
import io.github.kdroidfilter.seforimapp.core.settings.CategoryDisplaySettingsStore
import io.github.kdroidfilter.seforimapp.features.database.update.DatabaseCleanupUseCase
@@ -24,6 +25,7 @@ import io.github.kdroidfilter.seforimlibrary.search.SearchEngine
abstract class AppGraph : ViewModelGraph {
// Expose strongly-typed graph entries as abstract vals for generated implementation
abstract val mainAppState: MainAppState
+ abstract val catalogAccess: CatalogAccess
abstract val selectionContext: SelectionContext
abstract val tabPersistedStateStore: TabPersistedStateStore
abstract val tabTitleUpdateManager: TabTitleUpdateManager
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppMetroViewModelFactory.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppMetroViewModelFactory.kt
index d429692c..762ceb33 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppMetroViewModelFactory.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/AppMetroViewModelFactory.kt
@@ -3,7 +3,6 @@ package io.github.kdroidfilter.seforimapp.framework.di
import androidx.lifecycle.ViewModel
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
-import dev.zacsweers.metro.Provider
import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory
import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory
@@ -14,7 +13,7 @@ import kotlin.reflect.KClass
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class AppMetroViewModelFactory(
- override val viewModelProviders: Map, Provider>,
- override val assistedFactoryProviders: Map, Provider>,
- override val manualAssistedFactoryProviders: Map, Provider>,
+ override val viewModelProviders: Map, () -> ViewModel>,
+ override val assistedFactoryProviders: Map, () -> ViewModelAssistedFactory>,
+ override val manualAssistedFactoryProviders: Map, () -> ManualViewModelAssistedFactory>,
) : MetroViewModelFactory()
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/modules/AppCoreBindings.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/modules/AppCoreBindings.kt
index e8569c81..a61741ea 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/modules/AppCoreBindings.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/modules/AppCoreBindings.kt
@@ -10,11 +10,13 @@ import io.github.kdroidfilter.seforim.tabs.TabTitleUpdateManager
import io.github.kdroidfilter.seforim.tabs.TabsDestination
import io.github.kdroidfilter.seforim.tabs.TabsViewModel
import io.github.kdroidfilter.seforimapp.core.MainAppState
+import io.github.kdroidfilter.seforimapp.core.catalog.CatalogAccess
import io.github.kdroidfilter.seforimapp.core.selection.DefaultSelectionContext
import io.github.kdroidfilter.seforimapp.core.selection.SelectionContext
import io.github.kdroidfilter.seforimapp.core.settings.CategoryDisplaySettingsStore
import io.github.kdroidfilter.seforimapp.db.UserSettingsDb
import io.github.kdroidfilter.seforimapp.features.search.SearchHomeViewModel
+import io.github.kdroidfilter.seforimapp.framework.database.CatalogCache
import io.github.kdroidfilter.seforimapp.framework.database.PersistentSqliteDriver
import io.github.kdroidfilter.seforimapp.framework.database.getDatabasePath
import io.github.kdroidfilter.seforimapp.framework.database.getUserSettingsDatabasePath
@@ -37,6 +39,10 @@ object AppCoreBindings {
@SingleIn(AppScope::class)
fun provideMainAppState(): MainAppState = MainAppState()
+ @Provides
+ @SingleIn(AppScope::class)
+ fun provideCatalogAccess(): CatalogAccess = CatalogAccess { CatalogCache.getCatalog() }
+
@Provides
@SingleIn(AppScope::class)
fun provideSelectionContext(): SelectionContext = DefaultSelectionContext()
@@ -104,8 +110,9 @@ object AppCoreBindings {
val seforimDb = Paths.get(dbPath)
val catalogPb = Paths.get(seforimDb.parent.toString(), "catalog.pb")
val workDir = Paths.get(seforimDb.parent.toString(), "delta-cache")
- val releaseMetaUrl = System.getenv("SEFORIMAPP_RELEASE_META_URL")
- ?: "https://kdroidfilter.github.io/SefariaExport/release_meta.json"
+ val releaseMetaUrl =
+ System.getenv("SEFORIMAPP_RELEASE_META_URL")
+ ?: "https://kdroidfilter.github.io/SefariaExport/release_meta.json"
return io.github.kdroidfilter.seforimapp.framework.update.DbDeltaUpdateService(
seforimDb = seforimDb,
catalogPb = catalogPb,
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionManager.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionManager.kt
index 1bc58621..c7bf48ea 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionManager.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/session/SessionManager.kt
@@ -82,20 +82,7 @@ object SessionManager {
val desktopsState = loadDesktopsState() ?: return
if (desktopsState.desktops.isEmpty()) return
- // Compute titles for the active desktop snapshot
- val activeSnapshot = desktopsState.snapshots[desktopsState.activeDesktopId]
- val enrichedSnapshots = desktopsState.snapshots.toMutableMap()
-
- if (activeSnapshot != null) {
- val computedTitles = computeTabTitles(activeSnapshot.destinations, activeSnapshot.tabStates, appGraph)
- val mergedTitles = activeSnapshot.titles.toMutableMap()
- computedTitles.forEach { (tabId, pair) ->
- mergedTitles[tabId] = SerializableTabTitle(title = pair.first, tabType = pair.second)
- }
- enrichedSnapshots[desktopsState.activeDesktopId] = activeSnapshot.copy(titles = mergedTitles)
- }
-
- val enrichedState = desktopsState.copy(snapshots = enrichedSnapshots)
+ val enrichedState = enrichMissingTabTitles(desktopsState, appGraph)
debugln {
buildString {
@@ -225,4 +212,34 @@ object SessionManager {
}
return titles
}
+
+ private suspend fun enrichMissingTabTitles(
+ state: DesktopsState,
+ appGraph: AppGraph,
+ ): DesktopsState {
+ val enrichedSnapshots =
+ state.snapshots.mapValues { (_, snapshot) ->
+ val destinationsMissingTitles =
+ snapshot.destinations.filter { destination ->
+ destination !is TabsDestination.Home &&
+ snapshot.titles[destination.tabId]?.title.isNullOrBlank()
+ }
+ if (destinationsMissingTitles.isEmpty()) {
+ snapshot
+ } else {
+ val computedTitles = computeTabTitles(destinationsMissingTitles, snapshot.tabStates, appGraph)
+ if (computedTitles.isEmpty()) {
+ snapshot
+ } else {
+ val mergedTitles = snapshot.titles.toMutableMap()
+ computedTitles.forEach { (tabId, pair) ->
+ mergedTitles[tabId] = SerializableTabTitle(title = pair.first, tabType = pair.second)
+ }
+ snapshot.copy(titles = mergedTitles)
+ }
+ }
+ }
+
+ return state.copy(snapshots = enrichedSnapshots)
+ }
}
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/AppUpdateChecker.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/AppUpdateChecker.kt
index 628590b1..0cc38afe 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/AppUpdateChecker.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/AppUpdateChecker.kt
@@ -1,6 +1,6 @@
package io.github.kdroidfilter.seforimapp.framework.update
-import io.github.kdroidfilter.nucleus.updater.UpdaterConfig
+import dev.nucleusframework.updater.UpdaterConfig
import io.github.kdroidfilter.platformtools.releasefetcher.github.GitHubReleaseFetcher
import io.github.kdroidfilter.seforimapp.network.KtorConfig
import kotlinx.coroutines.Dispatchers
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/DbDeltaRecoveryBootstrap.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/DbDeltaRecoveryBootstrap.kt
index e7b4bd2f..cc5cc0d1 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/DbDeltaRecoveryBootstrap.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/DbDeltaRecoveryBootstrap.kt
@@ -3,10 +3,10 @@ package io.github.kdroidfilter.seforimapp.framework.update
import io.github.kdroidfilter.seforimapp.core.settings.AppSettings
import io.github.kdroidfilter.seforimapp.logger.infoln
import io.github.kdroidfilter.seforimapp.logger.warnln
+import io.github.kdroidfilter.seforimlibrary.deltaupdater.DeltaApplierClient
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.databasesDir
import io.github.vinceglb.filekit.path
-import io.github.kdroidfilter.seforimlibrary.deltaupdater.DeltaApplierClient
import java.io.File
import java.nio.file.Path
@@ -25,7 +25,6 @@ import java.nio.file.Path
* Call this exactly once, from `main()`, before any DB consumer runs.
*/
object DbDeltaRecoveryBootstrap {
-
/** Returns `true` if a half-applied delta was rolled back. */
fun runOnce(): Boolean {
val dbPath = resolveDbPathOrNull() ?: return false
@@ -52,8 +51,10 @@ object DbDeltaRecoveryBootstrap {
private fun resolveDbPathOrNull(): String? {
val env = System.getenv("SEFORIMAPP_DATABASE_PATH")?.takeIf { it.isNotBlank() }
if (env != null) return env
- val settings = runCatching { AppSettings.getDatabasePath() }.getOrNull()
- ?.takeIf { it.isNotBlank() && !it.endsWith("lexical.db", ignoreCase = true) }
+ val settings =
+ runCatching { AppSettings.getDatabasePath() }
+ .getOrNull()
+ ?.takeIf { it.isNotBlank() && !it.endsWith("lexical.db", ignoreCase = true) }
if (settings != null) return settings
return runCatching { File(FileKit.databasesDir.path, "seforim.db").absolutePath }
.getOrNull()
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/DbDeltaUpdateService.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/DbDeltaUpdateService.kt
index 0655e124..7d345d57 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/DbDeltaUpdateService.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/DbDeltaUpdateService.kt
@@ -39,7 +39,6 @@ open class DbDeltaUpdateService(
private val luceneSinksProvider: () -> LuceneUpdater.SinkSession =
defaultLuceneSinksProvider(luceneIndexDir, seforimDb),
) {
-
private val log = LoggerFactory.getLogger(DbDeltaUpdateService::class.java)
private val client by lazy {
@@ -70,10 +69,8 @@ open class DbDeltaUpdateService(
* Polls the release server and applies any available chain. Reports
* progress via [onProgress] as `current/total: status`.
*/
- open suspend fun checkAndApply(
- onProgress: (current: Int, total: Int, status: String) -> Unit = { _, _, _ -> },
- ): Outcome {
- return when (val path = client.checkForUpdate()) {
+ open suspend fun checkAndApply(onProgress: (current: Int, total: Int, status: String) -> Unit = { _, _, _ -> }): Outcome =
+ when (val path = client.checkForUpdate()) {
UpdatePath.UpToDate -> Outcome.UpToDate
is UpdatePath.FullBundle -> Outcome.NeedsFullBundle
is UpdatePath.Chain -> {
@@ -81,11 +78,14 @@ open class DbDeltaUpdateService(
Outcome.Applied(path.deltas.size)
}
}
- }
sealed interface Outcome {
data object UpToDate : Outcome
- data class Applied(val deltaCount: Int) : Outcome
+
+ data class Applied(
+ val deltaCount: Int,
+ ) : Outcome
+
data object NeedsFullBundle : Outcome
}
@@ -118,80 +118,88 @@ open class DbDeltaUpdateService(
fun defaultLuceneSinksProvider(
luceneIndexDir: Path?,
seforimDb: Path?,
- ): () -> LuceneUpdater.SinkSession = {
- if (luceneIndexDir == null) {
- LuceneUpdater.SinkSession(
- delete = LuceneUpdater.DeleteSink { },
- upsert = LuceneUpdater.UpsertSink { },
- )
- } else {
- val dir = FSDirectory.open(luceneIndexDir)
- val writer = IndexWriter(dir, IndexWriterConfig(StandardAnalyzer()))
- val bookMetaCache = HashMap()
- val dbConn = seforimDb?.let {
- java.sql.DriverManager.getConnection("jdbc:sqlite:${it.toAbsolutePath()}")
- }
+ ): () -> LuceneUpdater.SinkSession =
+ {
+ if (luceneIndexDir == null) {
+ LuceneUpdater.SinkSession(
+ delete = LuceneUpdater.DeleteSink { },
+ upsert = LuceneUpdater.UpsertSink { },
+ )
+ } else {
+ val dir = FSDirectory.open(luceneIndexDir)
+ val writer = IndexWriter(dir, IndexWriterConfig(StandardAnalyzer()))
+ val bookMetaCache = HashMap()
+ val dbConn =
+ seforimDb?.let {
+ java.sql.DriverManager.getConnection("jdbc:sqlite:${it.toAbsolutePath()}")
+ }
- fun lookupBookMeta(bookId: Long): BookMeta {
- bookMetaCache[bookId]?.let { return it }
- if (dbConn == null) return BookMeta.EMPTY.also { bookMetaCache[bookId] = it }
- return runCatching {
- dbConn.prepareStatement(
- "SELECT title, categoryId, orderIndex, isBaseBook FROM book WHERE id = ?"
- ).use { ps ->
- ps.setLong(1, bookId)
- ps.executeQuery().use { rs ->
- if (rs.next()) {
- BookMeta(
- title = rs.getString(1) ?: "",
- categoryId = rs.getLong(2),
- orderIndex = rs.getLong(3),
- isBaseBook = rs.getInt(4),
- )
- } else BookMeta.EMPTY
+ fun lookupBookMeta(bookId: Long): BookMeta {
+ bookMetaCache[bookId]?.let { return it }
+ if (dbConn == null) return BookMeta.EMPTY.also { bookMetaCache[bookId] = it }
+ return runCatching {
+ dbConn
+ .prepareStatement(
+ "SELECT title, categoryId, orderIndex, isBaseBook FROM book WHERE id = ?",
+ ).use { ps ->
+ ps.setLong(1, bookId)
+ ps.executeQuery().use { rs ->
+ if (rs.next()) {
+ BookMeta(
+ title = rs.getString(1) ?: "",
+ categoryId = rs.getLong(2),
+ orderIndex = rs.getLong(3),
+ isBaseBook = rs.getInt(4),
+ )
+ } else {
+ BookMeta.EMPTY
+ }
+ }
+ }
+ }.getOrDefault(BookMeta.EMPTY).also { bookMetaCache[bookId] = it }
+ }
+
+ LuceneUpdater.SinkSession(
+ delete =
+ LuceneUpdater.DeleteSink { id ->
+ writer.deleteDocuments(IntPoint.newExactQuery(FIELD_LINE_ID, id.toInt()))
+ },
+ upsert =
+ LuceneUpdater.UpsertSink { line ->
+ val meta = lookupBookMeta(line.bookId)
+ writer.deleteDocuments(IntPoint.newExactQuery(FIELD_LINE_ID, line.id.toInt()))
+ val doc =
+ Document().apply {
+ add(Field(FIELD_TYPE, TYPE_LINE, org.apache.lucene.document.StringField.TYPE_STORED))
+ add(IntPoint(FIELD_BOOK_ID, line.bookId.toInt()))
+ add(StoredField(FIELD_BOOK_ID, line.bookId))
+ add(IntPoint(FIELD_CATEGORY_ID, meta.categoryId.toInt()))
+ add(StoredField(FIELD_CATEGORY_ID, meta.categoryId))
+ add(IntPoint(FIELD_LINE_ID, line.id.toInt()))
+ add(StoredField(FIELD_LINE_ID, line.id))
+ add(StoredField(FIELD_LINE_INDEX, line.lineIndex.toLong()))
+ add(TextField(FIELD_TEXT, line.content, Field.Store.NO))
+ add(StoredField(FIELD_BOOK_TITLE, meta.title))
+ add(StoredField(FIELD_ORDER_INDEX, meta.orderIndex))
+ add(StoredField(FIELD_IS_BASE_BOOK, meta.isBaseBook.toLong()))
+ }
+ writer.addDocument(doc)
+ },
+ // Commit + close in lock-step: this is what guarantees
+ // the delta is actually persisted to the index. Skipping
+ // either of these silently drops every upsert.
+ onClose = {
+ try {
+ writer.commit()
+ } finally {
+ runCatching { writer.close() }
+ runCatching { dir.close() }
+ runCatching { dbConn?.close() }
}
- }
- }.getOrDefault(BookMeta.EMPTY).also { bookMetaCache[bookId] = it }
+ },
+ )
}
-
- LuceneUpdater.SinkSession(
- delete = LuceneUpdater.DeleteSink { id ->
- writer.deleteDocuments(IntPoint.newExactQuery(FIELD_LINE_ID, id.toInt()))
- },
- upsert = LuceneUpdater.UpsertSink { line ->
- val meta = lookupBookMeta(line.bookId)
- writer.deleteDocuments(IntPoint.newExactQuery(FIELD_LINE_ID, line.id.toInt()))
- val doc = Document().apply {
- add(Field(FIELD_TYPE, TYPE_LINE, org.apache.lucene.document.StringField.TYPE_STORED))
- add(IntPoint(FIELD_BOOK_ID, line.bookId.toInt()))
- add(StoredField(FIELD_BOOK_ID, line.bookId))
- add(IntPoint(FIELD_CATEGORY_ID, meta.categoryId.toInt()))
- add(StoredField(FIELD_CATEGORY_ID, meta.categoryId))
- add(IntPoint(FIELD_LINE_ID, line.id.toInt()))
- add(StoredField(FIELD_LINE_ID, line.id))
- add(StoredField(FIELD_LINE_INDEX, line.lineIndex.toLong()))
- add(TextField(FIELD_TEXT, line.content, Field.Store.NO))
- add(StoredField(FIELD_BOOK_TITLE, meta.title))
- add(StoredField(FIELD_ORDER_INDEX, meta.orderIndex))
- add(StoredField(FIELD_IS_BASE_BOOK, meta.isBaseBook.toLong()))
- }
- writer.addDocument(doc)
- },
- // Commit + close in lock-step: this is what guarantees
- // the delta is actually persisted to the index. Skipping
- // either of these silently drops every upsert.
- onClose = {
- try {
- writer.commit()
- } finally {
- runCatching { writer.close() }
- runCatching { dir.close() }
- runCatching { dbConn?.close() }
- }
- },
- )
}
- }
private data class BookMeta(
val title: String,
diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt
index 42dd1b11..bdf91beb 100644
--- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt
+++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt
@@ -10,24 +10,23 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowPosition
-import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import com.kdroid.gematria.converter.toHebrewNumeral
+import dev.nucleusframework.application.aotTraining
+import dev.nucleusframework.application.nucleusApplication
+import dev.nucleusframework.core.runtime.ExecutableRuntime
+import dev.nucleusframework.energymanager.EnergyManager
+import dev.nucleusframework.notification.common.notification
+import dev.nucleusframework.window.jewel.JewelDecoratedWindow
import dev.zacsweers.metro.createGraph
import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory
import dev.zacsweers.metrox.viewmodel.metroViewModel
-import io.github.kdroidfilter.nucleus.aot.runtime.AotRuntime
-import io.github.kdroidfilter.nucleus.core.runtime.ExecutableRuntime
-import io.github.kdroidfilter.nucleus.core.runtime.SingleInstanceManager
-import io.github.kdroidfilter.nucleus.energymanager.EnergyManager
-import io.github.kdroidfilter.nucleus.graalvm.GraalVmInitializer
-import io.github.kdroidfilter.nucleus.launcher.windows.WindowsJumpListManager
-import io.github.kdroidfilter.nucleus.notification.common.notification
-import io.github.kdroidfilter.nucleus.window.jewel.JewelDecoratedWindow
import io.github.kdroidfilter.platformtools.getAppVersion
import io.github.kdroidfilter.seforim.tabs.TabType
import io.github.kdroidfilter.seforim.tabs.TabsDestination
@@ -46,6 +45,7 @@ import io.github.kdroidfilter.seforimapp.core.presentation.utils.rememberWindowV
import io.github.kdroidfilter.seforimapp.core.settings.AppSettings
import io.github.kdroidfilter.seforimapp.features.database.update.DatabaseUpdateWindow
import io.github.kdroidfilter.seforimapp.features.onboarding.OnBoardingWindow
+import io.github.kdroidfilter.seforimapp.features.settings.SettingsWindow
import io.github.kdroidfilter.seforimapp.features.settings.SettingsWindowEvents
import io.github.kdroidfilter.seforimapp.features.settings.SettingsWindowViewModel
import io.github.kdroidfilter.seforimapp.framework.database.DatabaseVersionManager
@@ -70,9 +70,10 @@ import java.awt.datatransfer.StringSelection
import java.awt.event.KeyEvent
import java.net.URI
import java.util.*
+import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalFoundationApi::class)
-private const val AOT_TRAINING_DURATION_MS = 45_000L
+private val AOT_TRAINING_DURATION = 45.seconds
private data class StartupState(
val showOnboarding: Boolean,
@@ -125,7 +126,6 @@ private fun initializeSentry() {
}
fun main(args: Array) {
- GraalVmInitializer.initialize()
Locale.setDefault(Locale.Builder().setLanguage("he").build())
val loggingEnv = System.getenv("SEFORIMAPP_LOGGING")?.lowercase()
@@ -136,17 +136,7 @@ fun main(args: Array) {
// Roll back any half-applied seforim.db delta update from a previous
// launch BEFORE the SQLDelight repository opens the DB. Cheap stat()
// when nothing is in flight; never throws (failures are logged).
- io.github.kdroidfilter.seforimapp.framework.update.DbDeltaRecoveryBootstrap.runOnce()
-
- if (AotRuntime.isTraining()) {
- Thread({
- Thread.sleep(AOT_TRAINING_DURATION_MS)
- kotlin.system.exitProcess(0)
- }, "aot-timer").apply {
- isDaemon = false
- start()
- }
- }
+// DbDeltaRecoveryBootstrap.runOnce()
// Force OpenGL rendering backend on Windows if enabled (must be set before Skia initialization)
if (PlatformInfo.isWindows && AppSettings.isUseOpenGlEnabled()) {
@@ -155,17 +145,9 @@ fun main(args: Array) {
val appId = "io.github.kdroidfilter.seforimapp"
- // Must be set before any window creation for jump lists to work on unpackaged Windows apps
- if (PlatformInfo.isWindows) {
- WindowsJumpListManager.setProcessAppId(appId)
- }
-
- SingleInstanceManager.configuration =
- SingleInstanceManager.Configuration(
- lockIdentifier = appId,
- )
+ nucleusApplication(args) {
+ aotTraining(duration = AOT_TRAINING_DURATION)
- application {
FileKit.init(appId)
val windowState =
@@ -174,27 +156,12 @@ fun main(args: Array) {
placement = WindowPlacement.Maximized,
)
- var isWindowVisible by remember { mutableStateOf(true) }
+ val isWindowVisible by remember { mutableStateOf(true) }
val pendingDeepLink = remember { MutableStateFlow(null) }
- val isSingleInstance =
- SingleInstanceManager.isSingleInstance(
- onRestoreFileCreated =
- args.firstOrNull { it.startsWith("seforim://") }?.let { deepLink ->
- { toFile().writeText(deepLink) }
- },
- onRestoreRequest = {
- isWindowVisible = true
- windowState.isMinimized = false
- Window.getWindows().first().toFront()
- val content = toFile().readText().trim()
- if (content.isNotEmpty()) pendingDeepLink.value = content
- },
- )
- if (!isSingleInstance) {
- exitApplication()
- return@application
- }
+ // Pick up the deep link CLI arg (cold-start) and any URI relayed by a second instance
+ // through the automatic single-instance bridge.
+ onDeepLink { uri -> pendingDeepLink.value = uri.toString() }
// Create the application graph via Metro and expose via CompositionLocal
val appGraph = remember { createGraph() }
@@ -292,6 +259,7 @@ fun main(args: Array) {
CompositionLocalProvider(
LocalAppGraph provides appGraph,
LocalMetroViewModelFactory provides appGraph.metroViewModelFactory,
+ LocalLayoutDirection provides LayoutDirection.Rtl,
) {
val themeDefinition = ThemeUtils.buildThemeDefinition()
val componentStyling = ThemeUtils.buildComponentStyling()
@@ -396,6 +364,7 @@ fun main(args: Array) {
icon = if (PlatformInfo.isMacOS) null else painterResource(Res.drawable.AppIcon),
state = windowState,
visible = isWindowVisible,
+ minimumSize = DpSize(600.dp, 300.dp),
onKeyEvent = { keyEvent ->
if (keyEvent.type == KeyEventType.KeyDown) {
// Read fresh state to avoid stale captures in cached lambda
@@ -454,12 +423,18 @@ fun main(args: Array) {
},
) {
CompositionLocalProvider(
- LocalLayoutDirection provides LayoutDirection.Rtl,
LocalWindowViewModelStoreOwner provides windowViewModelOwner,
LocalViewModelStoreOwner provides windowViewModelOwner,
) {
- LaunchedEffect(Unit) {
- window.minimumSize = Dimension(600, 300)
+ // Settings dialog rendered here so it inherits LocalLayoutDirection Rtl
+ // and the full CompositionLocalContext — including theme and user locals —
+ // is bridged into the dialog's Tao ComposeScene.
+ val settingsWindowState by settingsWindowViewModel.state.collectAsState()
+ if (settingsWindowState.isVisible) {
+ SettingsWindow(
+ onClose = { settingsWindowViewModel.onEvent(SettingsWindowEvents.OnClose) },
+ initialDestination = settingsWindowState.initialDestination,
+ )
}
MainTitleBar()
LaunchedEffect(state.isMinimized) {
diff --git a/SeforimApp/src/jvmMain/resources/icons/system_theme.svg b/SeforimApp/src/jvmMain/resources/icons/system_theme.svg
new file mode 100644
index 00000000..64913c17
--- /dev/null
+++ b/SeforimApp/src/jvmMain/resources/icons/system_theme.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/SeforimApp/src/jvmMain/resources/icons/system_theme_dark.svg b/SeforimApp/src/jvmMain/resources/icons/system_theme_dark.svg
new file mode 100644
index 00000000..e319b408
--- /dev/null
+++ b/SeforimApp/src/jvmMain/resources/icons/system_theme_dark.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresetsTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresetsTest.kt
new file mode 100644
index 00000000..37d351ce
--- /dev/null
+++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresetsTest.kt
@@ -0,0 +1,155 @@
+package io.github.kdroidfilter.seforimapp.catalog
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+class CatalogPresetsTest {
+ // BookRef
+ @Test
+ fun `BookRef stores id and title`() {
+ val bookRef = BookRef(1L, "בראשית")
+ assertEquals(1L, bookRef.id)
+ assertEquals("בראשית", bookRef.title)
+ }
+
+ @Test
+ fun `BookRef equality and copy`() {
+ val original = BookRef(1L, "בראשית")
+ assertEquals(BookRef(1L, "בראשית"), original)
+ assertEquals(BookRef(1L, "שמות"), original.copy(title = "שמות"))
+ }
+
+ // TocQuickLink
+ @Test
+ fun `TocQuickLink stores all fields`() {
+ val link = TocQuickLink("אורח חיים", 30_149L, 252_607L)
+ assertEquals("אורח חיים", link.label)
+ assertEquals(30_149L, link.tocEntryId)
+ assertEquals(252_607L, link.firstLineId)
+ }
+
+ @Test
+ fun `TocQuickLink allows null firstLineId`() {
+ val link = TocQuickLink("Label", 100L, null)
+ assertEquals(null, link.firstLineId)
+ }
+
+ // DropdownSpec sealed hierarchy
+ @Test
+ fun `CategoryDropdownSpec implements DropdownSpec`() {
+ val spec: DropdownSpec = CategoryDropdownSpec(62L)
+ assertIs(spec)
+ assertEquals(62L, spec.categoryId)
+ }
+
+ @Test
+ fun `MultiCategoryDropdownSpec implements DropdownSpec`() {
+ val spec: DropdownSpec = MultiCategoryDropdownSpec(1L, listOf(2L, 3L, 4L))
+ assertIs(spec)
+ assertEquals(1L, spec.labelCategoryId)
+ assertEquals(listOf(2L, 3L, 4L), spec.bookCategoryIds)
+ }
+
+ @Test
+ fun `TocQuickLinksSpec embeds links inline`() {
+ val link = TocQuickLink("אורח חיים", 30_149L, 252_607L)
+ val spec: DropdownSpec = TocQuickLinksSpec(380L, listOf(link))
+ assertIs(spec)
+ assertEquals(380L, spec.bookId)
+ assertEquals(listOf(link), spec.links)
+ }
+
+ // Ids — sanity check on the codegen output. Asserts presence + non-collision,
+ // not exact values (those track upstream catalog evolution).
+ @Test
+ fun `Ids Categories core constants are set`() {
+ assertTrue(CatalogPresets.Ids.Categories.TANAKH > 0L)
+ assertTrue(CatalogPresets.Ids.Categories.TORAH > 0L)
+ assertTrue(CatalogPresets.Ids.Categories.MISHNA > 0L)
+ assertTrue(CatalogPresets.Ids.Categories.BAVLI > 0L)
+ assertTrue(CatalogPresets.Ids.Categories.YERUSHALMI > 0L)
+ assertTrue(CatalogPresets.Ids.Categories.MISHNE_TORAH > 0L)
+ assertTrue(CatalogPresets.Ids.Categories.SHULCHAN_ARUCH > 0L)
+ assertTrue(CatalogPresets.Ids.Categories.TUR > 0L)
+ // Talmud roots distinct from their parents
+ assertTrue(CatalogPresets.Ids.Categories.BAVLI != CatalogPresets.Ids.Categories.YERUSHALMI)
+ }
+
+ @Test
+ fun `Ids Books TUR is set`() {
+ assertTrue(CatalogPresets.Ids.Books.TUR > 0L)
+ }
+
+ @Test
+ fun `Ids TocTexts has all four Tur sections`() {
+ val ids =
+ setOf(
+ CatalogPresets.Ids.TocTexts.ORACH_CHAIM,
+ CatalogPresets.Ids.TocTexts.YOREH_DEAH,
+ CatalogPresets.Ids.TocTexts.EVEN_HAEZER,
+ CatalogPresets.Ids.TocTexts.CHOSHEN_MISHPAT,
+ )
+ assertEquals(4, ids.size, "TOC text IDs must be distinct")
+ assertTrue(ids.all { it > 0L })
+ }
+
+ // Dropdowns
+ @Test
+ fun `Dropdowns HOME contains all main sections`() {
+ assertEquals(6, CatalogPresets.Dropdowns.HOME.size)
+ }
+
+ @Test
+ fun `Dropdowns TANAKH is MultiCategoryDropdownSpec with three orders`() {
+ val tanakh = CatalogPresets.Dropdowns.TANAKH
+ assertIs(tanakh)
+ assertEquals(CatalogPresets.Ids.Categories.TANAKH, tanakh.labelCategoryId)
+ assertEquals(
+ listOf(
+ CatalogPresets.Ids.Categories.TORAH,
+ CatalogPresets.Ids.Categories.NEVIIM,
+ CatalogPresets.Ids.Categories.KETUVIM,
+ ),
+ tanakh.bookCategoryIds,
+ )
+ }
+
+ @Test
+ fun `Dropdowns individual category specs are CategoryDropdownSpec`() {
+ assertIs(CatalogPresets.Dropdowns.TORAH)
+ assertIs(CatalogPresets.Dropdowns.NEVIIM)
+ assertIs(CatalogPresets.Dropdowns.KETUVIM)
+ assertIs(CatalogPresets.Dropdowns.SHULCHAN_ARUCH)
+ }
+
+ @Test
+ fun `Dropdowns TUR_QUICK_LINKS embeds four links`() {
+ val turLinks = CatalogPresets.Dropdowns.TUR_QUICK_LINKS
+ assertIs(turLinks)
+ assertEquals(CatalogPresets.Ids.Books.TUR, turLinks.bookId)
+ assertEquals(4, turLinks.links.size)
+ assertEquals(
+ listOf("אורח חיים", "יורה דעה", "אבן העזר", "חושן משפט"),
+ turLinks.links.map { it.label },
+ )
+ // firstLineId must be populated for navigation to work
+ assertTrue(turLinks.links.all { it.firstLineId != null })
+ }
+
+ @Test
+ fun `Dropdowns BAVLI lists six orders`() {
+ val bavli = CatalogPresets.Dropdowns.BAVLI
+ assertIs(bavli)
+ assertEquals(6, bavli.bookCategoryIds.size)
+ }
+
+ @Test
+ fun `Dropdowns MISHNE_TORAH lists multiple children`() {
+ val mt = CatalogPresets.Dropdowns.MISHNE_TORAH
+ assertIs(mt)
+ assertEquals(CatalogPresets.Ids.Categories.MISHNE_TORAH, mt.labelCategoryId)
+ assertTrue(mt.bookCategoryIds.isNotEmpty())
+ }
+}
diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/catalog/PrecomputedCatalogTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/catalog/PrecomputedCatalogTest.kt
deleted file mode 100644
index 0f21d758..00000000
--- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/catalog/PrecomputedCatalogTest.kt
+++ /dev/null
@@ -1,180 +0,0 @@
-package io.github.kdroidfilter.seforimapp.catalog
-
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertIs
-import kotlin.test.assertNotNull
-import kotlin.test.assertTrue
-
-class PrecomputedCatalogTest {
- // BookRef tests
- @Test
- fun `BookRef data class stores id and title`() {
- val bookRef = BookRef(1L, "בראשית")
- assertEquals(1L, bookRef.id)
- assertEquals("בראשית", bookRef.title)
- }
-
- @Test
- fun `BookRef equality works correctly`() {
- val bookRef1 = BookRef(1L, "בראשית")
- val bookRef2 = BookRef(1L, "בראשית")
- assertEquals(bookRef1, bookRef2)
- }
-
- @Test
- fun `BookRef copy works correctly`() {
- val original = BookRef(1L, "בראשית")
- val copied = original.copy(title = "שמות")
- assertEquals(1L, copied.id)
- assertEquals("שמות", copied.title)
- }
-
- // TocQuickLink tests
- @Test
- fun `TocQuickLink data class stores all fields`() {
- val link = TocQuickLink("אורח חיים", 30_015L, 252_674L)
- assertEquals("אורח חיים", link.label)
- assertEquals(30_015L, link.tocEntryId)
- assertEquals(252_674L, link.firstLineId)
- }
-
- @Test
- fun `TocQuickLink allows null firstLineId`() {
- val link = TocQuickLink("Label", 100L, null)
- assertEquals(null, link.firstLineId)
- }
-
- // DropdownSpec sealed interface tests
- @Test
- fun `CategoryDropdownSpec implements DropdownSpec`() {
- val spec: DropdownSpec = CategoryDropdownSpec(62L)
- assertIs(spec)
- assertEquals(62L, spec.categoryId)
- }
-
- @Test
- fun `MultiCategoryDropdownSpec implements DropdownSpec`() {
- val spec: DropdownSpec = MultiCategoryDropdownSpec(1L, listOf(2L, 3L, 4L))
- assertIs(spec)
- assertEquals(1L, spec.labelCategoryId)
- assertEquals(listOf(2L, 3L, 4L), spec.bookCategoryIds)
- }
-
- @Test
- fun `TocQuickLinksSpec implements DropdownSpec`() {
- val spec: DropdownSpec = TocQuickLinksSpec(381L, listOf(3_768L, 4_411L))
- assertIs(spec)
- assertEquals(381L, spec.bookId)
- assertEquals(listOf(3_768L, 4_411L), spec.tocTextIds)
- }
-
- // PrecomputedCatalog.BOOK_TITLES tests
- @Test
- fun `BOOK_TITLES contains expected books`() {
- assertTrue(PrecomputedCatalog.BOOK_TITLES.isNotEmpty())
- assertEquals("בראשית", PrecomputedCatalog.BOOK_TITLES[1L])
- assertEquals("שמות", PrecomputedCatalog.BOOK_TITLES[2L])
- assertEquals("ויקרא", PrecomputedCatalog.BOOK_TITLES[3L])
- }
-
- @Test
- fun `BOOK_TITLES contains Talmud tractates`() {
- assertEquals("ברכות", PrecomputedCatalog.BOOK_TITLES[103L])
- assertEquals("שבת", PrecomputedCatalog.BOOK_TITLES[104L])
- }
-
- // PrecomputedCatalog.CATEGORY_TITLES tests
- @Test
- fun `CATEGORY_TITLES contains expected categories`() {
- assertTrue(PrecomputedCatalog.CATEGORY_TITLES.isNotEmpty())
- assertEquals("תנ״ך", PrecomputedCatalog.CATEGORY_TITLES[1L])
- assertEquals("תורה", PrecomputedCatalog.CATEGORY_TITLES[2L])
- assertEquals("משנה", PrecomputedCatalog.CATEGORY_TITLES[5L])
- }
-
- // PrecomputedCatalog.CATEGORY_BOOKS tests
- @Test
- fun `CATEGORY_BOOKS contains Torah books`() {
- val torahBooks = PrecomputedCatalog.CATEGORY_BOOKS[2L]
- assertNotNull(torahBooks)
- assertEquals(5, torahBooks.size)
- assertEquals("בראשית", torahBooks[0].title)
- assertEquals("דברים", torahBooks[4].title)
- }
-
- @Test
- fun `CATEGORY_BOOKS for Tanakh parent is empty`() {
- val tanakhBooks = PrecomputedCatalog.CATEGORY_BOOKS[1L]
- assertNotNull(tanakhBooks)
- assertTrue(tanakhBooks.isEmpty())
- }
-
- // PrecomputedCatalog.TOC_BY_TOC_TEXT_ID tests
- @Test
- fun `TOC_BY_TOC_TEXT_ID contains Tur quick links`() {
- val turToc = PrecomputedCatalog.TOC_BY_TOC_TEXT_ID[381L]
- assertNotNull(turToc)
- assertTrue(turToc.isNotEmpty())
-
- val orachChaim = turToc[3_768L]
- assertNotNull(orachChaim)
- assertEquals("אורח חיים", orachChaim.label)
- }
-
- // PrecomputedCatalog.Ids tests
- @Test
- fun `Ids Categories constants are correct`() {
- assertEquals(1L, PrecomputedCatalog.Ids.Categories.TANAKH)
- assertEquals(2L, PrecomputedCatalog.Ids.Categories.TORAH)
- assertEquals(5L, PrecomputedCatalog.Ids.Categories.MISHNA)
- assertEquals(13L, PrecomputedCatalog.Ids.Categories.BAVLI)
- assertEquals(20L, PrecomputedCatalog.Ids.Categories.YERUSHALMI)
- assertEquals(45L, PrecomputedCatalog.Ids.Categories.MISHNE_TORAH)
- assertEquals(62L, PrecomputedCatalog.Ids.Categories.SHULCHAN_ARUCH)
- }
-
- @Test
- fun `Ids Books constants are correct`() {
- assertEquals(381L, PrecomputedCatalog.Ids.Books.TUR)
- }
-
- @Test
- fun `Ids TocTexts constants are correct`() {
- assertEquals(3_768L, PrecomputedCatalog.Ids.TocTexts.ORACH_CHAIM)
- assertEquals(4_411L, PrecomputedCatalog.Ids.TocTexts.YOREH_DEAH)
- assertEquals(4_412L, PrecomputedCatalog.Ids.TocTexts.EVEN_HAEZER)
- assertEquals(4_413L, PrecomputedCatalog.Ids.TocTexts.CHOSHEN_MISHPAT)
- }
-
- // PrecomputedCatalog.Dropdowns tests
- @Test
- fun `Dropdowns HOME contains all main sections`() {
- val homeDropdowns = PrecomputedCatalog.Dropdowns.HOME
- assertEquals(6, homeDropdowns.size)
- }
-
- @Test
- fun `Dropdowns TANAKH is MultiCategoryDropdownSpec`() {
- val tanakh = PrecomputedCatalog.Dropdowns.TANAKH
- assertIs(tanakh)
- assertEquals(1L, tanakh.labelCategoryId)
- assertEquals(listOf(2L, 3L, 4L), tanakh.bookCategoryIds)
- }
-
- @Test
- fun `Dropdowns individual category specs are correct`() {
- assertIs(PrecomputedCatalog.Dropdowns.TORAH)
- assertIs(PrecomputedCatalog.Dropdowns.NEVIIM)
- assertIs(PrecomputedCatalog.Dropdowns.KETUVIM)
- assertIs(PrecomputedCatalog.Dropdowns.SHULCHAN_ARUCH)
- }
-
- @Test
- fun `Dropdowns TUR_QUICK_LINKS is TocQuickLinksSpec`() {
- val turLinks = PrecomputedCatalog.Dropdowns.TUR_QUICK_LINKS
- assertIs(turLinks)
- assertEquals(381L, turLinks.bookId)
- assertEquals(4, turLinks.tocTextIds.size)
- }
-}
diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccessTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccessTest.kt
new file mode 100644
index 00000000..5bf8ab2f
--- /dev/null
+++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccessTest.kt
@@ -0,0 +1,128 @@
+package io.github.kdroidfilter.seforimapp.core.catalog
+
+import io.github.kdroidfilter.seforimapp.catalog.CatalogPresets
+import io.github.kdroidfilter.seforimlibrary.core.models.PrecomputedCatalog
+import io.github.kdroidfilter.seforimlibrary.dao.CatalogLoader
+import org.junit.Assume
+import java.nio.file.Files
+import java.nio.file.Path
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+/**
+ * Verifies that [CatalogAccess] reproduces the display transformations the codegen
+ * used to bake into PrecomputedCatalog.kt: Mishneh Torah and Shulchan Aruch
+ * book-prefix filters, Talmud prefixing for Bavli/Yerushalmi, ancestor-label stripping.
+ */
+class CatalogAccessTest {
+ private var catalog: PrecomputedCatalog? = null
+ private var access: CatalogAccess? = null
+
+ @BeforeTest
+ fun setup() {
+ for (basePath in POSSIBLE_BASE_PATHS) {
+ val catalogCandidate = Path.of("$basePath/catalog.pb")
+ val dbCandidate = Path.of("$basePath/seforim.db")
+ if (Files.exists(catalogCandidate) && Files.exists(dbCandidate)) {
+ catalog = CatalogLoader.loadCatalog(dbCandidate.toString())
+ break
+ }
+ }
+ catalog?.let { c -> access = CatalogAccess { c } }
+ }
+
+ private fun requireAccess(): CatalogAccess {
+ Assume.assumeTrue(
+ "E2E catalog fixture not available (SeforimLibrary/build/{catalog.pb,seforim.db})",
+ access != null,
+ )
+ return access!!
+ }
+
+ @Test
+ fun `categoryTitle prepends Talmud for Bavli`() {
+ val ca = requireAccess()
+ val title = ca.categoryTitle(CatalogPresets.Ids.Categories.BAVLI)
+ assertNotNull(title, "BAVLI title must be available")
+ assertTrue(
+ title.startsWith("תלמוד") && title.contains("בבלי"),
+ "BAVLI title expected to start with תלמוד and contain בבלי, got '$title'",
+ )
+ }
+
+ @Test
+ fun `categoryTitle prepends Talmud for Yerushalmi`() {
+ val ca = requireAccess()
+ val title = ca.categoryTitle(CatalogPresets.Ids.Categories.YERUSHALMI)
+ assertNotNull(title)
+ assertTrue(
+ title.startsWith("תלמוד") && title.contains("ירושלמי"),
+ "YERUSHALMI title expected to start with תלמוד and contain ירושלמי, got '$title'",
+ )
+ }
+
+ @Test
+ fun `booksFor Mishneh Torah excludes Mefarshim`() {
+ val ca = requireAccess()
+ val books = ca.booksFor(CatalogPresets.Ids.Categories.MISHNE_TORAH)
+ assertFalse(
+ books.any { it.title.trimStart().startsWith("מפרשים") },
+ "Mishneh Torah books must not contain any 'מפרשים' entries",
+ )
+ }
+
+ @Test
+ fun `booksFor Shulchan Aruch excludes Hakdama and Pri Megadim`() {
+ val ca = requireAccess()
+ val books = ca.booksFor(CatalogPresets.Ids.Categories.SHULCHAN_ARUCH)
+ assertFalse(
+ books.any { it.title.trimStart().startsWith("הקדמה") },
+ "Shulchan Aruch books must not contain any 'הקדמה' entries",
+ )
+ assertFalse(
+ books.any { it.title.trimStart().startsWith("פרי מגדים") },
+ "Shulchan Aruch books must not contain any 'פרי מגדים' entries",
+ )
+ }
+
+ @Test
+ fun `booksFor strips ancestor category label prefixes`() {
+ val ca = requireAccess()
+ // Pick any category that yields books; verify no displayed title starts with its own title.
+ val torahId = CatalogPresets.Ids.Categories.TORAH
+ val torahTitle = ca.categoryTitle(torahId) ?: return
+ val books = ca.booksFor(torahId)
+ if (books.isEmpty()) return
+ assertFalse(
+ books.any { it.title.startsWith("$torahTitle,") || it.title.startsWith("$torahTitle ") },
+ "Torah books should not retain the 'תורה' label prefix in their display titles",
+ )
+ }
+
+ @Test
+ fun `bookTitle returns raw title for known book`() {
+ val ca = requireAccess()
+ val title = ca.bookTitle(CatalogPresets.Ids.Books.TUR)
+ assertNotNull(title, "Tur book title must be available")
+ assertEquals("טור", title.trim())
+ }
+
+ @Test
+ fun `unknown category returns null and empty list`() {
+ val ca = requireAccess()
+ assertEquals(null, ca.categoryTitle(-1L))
+ assertTrue(ca.booksFor(-1L).isEmpty())
+ }
+
+ private companion object {
+ private val POSSIBLE_BASE_PATHS =
+ listOf(
+ "SeforimLibrary/build",
+ "../SeforimLibrary/build",
+ )
+ }
+}
diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentatorGroupingIntegrationTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentatorGroupingIntegrationTest.kt
new file mode 100644
index 00000000..62a22b5f
--- /dev/null
+++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentatorGroupingIntegrationTest.kt
@@ -0,0 +1,265 @@
+package io.github.kdroidfilter.seforimapp.features.bookcontent.usecases
+
+import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
+import io.github.kdroidfilter.seforimapp.features.bookcontent.state.BookContentStateManager
+import io.github.kdroidfilter.seforimapp.framework.session.TabPersistedStateStore
+import io.github.kdroidfilter.seforimlibrary.dao.repository.SeforimRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.runBlocking
+import java.nio.file.Files
+import java.nio.file.Path
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+/**
+ * Verifies commentator grouping/ordering against the real generated database.
+ *
+ * The test boots a [SeforimRepository] on `SeforimLibrary/build/seforim.db` (a
+ * read path that exists on developer machines and dedicated CI nodes that ship
+ * the corpus). When the DB is absent the assertions are skipped via JUnit
+ * Assume so the test never fails on lean CI runners.
+ */
+class CommentatorGroupingIntegrationTest {
+ private var driver: JdbcSqliteDriver? = null
+ private var repository: SeforimRepository? = null
+ private var scope: CoroutineScope? = null
+
+ private companion object {
+ // Lines and books reference the canonical IDs of the generated corpus
+ // (Bereshit book.id = 1, "בראשית" verse 1:1 line.id = 3, Berakhot
+ // book.id = 103 with the first sugya line at id 28957).
+ const val BERESHIT_BOOK_ID = 1L
+ const val BERESHIT_1_1_LINE_ID = 3L
+ const val BERAKHOT_BOOK_ID = 103L
+ const val BERAKHOT_2A_LINE_ID = 28957L
+
+ private val POSSIBLE_DB_PATHS =
+ listOf(
+ "SeforimLibrary/build/seforim.db",
+ "../SeforimLibrary/build/seforim.db",
+ )
+
+ private fun resolveDbPath(): String? {
+ for (p in POSSIBLE_DB_PATHS) {
+ if (Files.exists(Path.of(p))) return p
+ }
+ return null
+ }
+ }
+
+ @BeforeTest
+ fun setup() {
+ val dbPath = resolveDbPath() ?: return
+ driver = JdbcSqliteDriver("jdbc:sqlite:$dbPath")
+ repository = SeforimRepository(dbPath, driver!!)
+ scope = CoroutineScope(SupervisorJob())
+ }
+
+ @AfterTest
+ fun tearDown() {
+ scope?.cancel()
+ scope = null
+ driver?.close()
+ driver = null
+ repository = null
+ }
+
+ private fun skipIfNoDb() {
+ if (repository == null) {
+ org.junit.Assume.assumeTrue("Generated DB not available", false)
+ }
+ }
+
+ private suspend fun buildUseCase(bookId: Long): CommentariesUseCase {
+ val repo = repository!!
+ val book =
+ repo.getBook(bookId)
+ ?: error("Book $bookId not found in DB — corpus mismatch")
+ val stateManager = BookContentStateManager("test-tab", TabPersistedStateStore())
+ stateManager.updateNavigation { copy(selectedBook = book) }
+ return CommentariesUseCase(repo, stateManager, scope!!)
+ }
+
+ @Test
+ fun `Bereshit 1_1 — Tanakh Rishonim group exists and contains Rashi-Ramban-Ibn Ezra`() =
+ runBlocking {
+ skipIfNoDb()
+ val uc = buildUseCase(BERESHIT_BOOK_ID)
+
+ val groups = uc.getCommentatorGroupsForLines(listOf(BERESHIT_1_1_LINE_ID))
+
+ val labels = groups.map { it.label }
+ println("[Bereshit 1:1] Group labels in order:")
+ labels.forEachIndexed { i, l -> println(" ${i + 1}. $l (${groups[i].commentators.size} books)") }
+
+ val rishonim =
+ groups.firstOrNull { it.label == "ראשונים על התנ״ך" }
+ ?: error("Missing 'ראשונים על התנ״ך' group. Got: $labels")
+ val names = rishonim.commentators.map { it.name }
+ println("[Bereshit 1:1] Rishonim books in order: $names")
+
+ // Must include the canonical Rishonim
+ val mustContain = listOf("רש\"י", "רשב\"ם", "אבן עזרא", "רד\"ק", "רמב\"ן", "ספורנו")
+ mustContain.forEach { needle ->
+ val found = names.any { it.contains(needle) || it.contains(needle.replace('"', '״')) }
+ assertTrue(found, "Rishonim group should contain a book matching '$needle' — got $names")
+ }
+
+ // Canonical chronological order: Rashi (1040) < Rashbam (1085) < Ibn Ezra (1089)
+ // < Radak (1160) < Ramban (1194) < Sforno (1475).
+ fun firstIndexMatching(needle: String): Int {
+ val nrm = needle.replace('"', '״')
+ return names.indexOfFirst { it.contains(needle) || it.contains(nrm) }
+ }
+ val ranks = mustContain.map { firstIndexMatching(it) }
+ println("[Bereshit 1:1] Canonical Rishonim indices: ${mustContain.zip(ranks)}")
+ for (i in 0 until ranks.size - 1) {
+ assertTrue(
+ ranks[i] < ranks[i + 1],
+ "Expected '${mustContain[i]}' (idx ${ranks[i]}) before '${mustContain[i + 1]}' (idx ${ranks[i + 1]}). Got: $names",
+ )
+ }
+ }
+
+ @Test
+ fun `Bereshit 1_1 — Targums collapsed into a single תרגומים group`() =
+ runBlocking {
+ skipIfNoDb()
+ val uc = buildUseCase(BERESHIT_BOOK_ID)
+
+ val groups = uc.getCommentatorGroupsForLines(listOf(BERESHIT_1_1_LINE_ID))
+ val labels = groups.map { it.label }
+
+ val targumGroups =
+ groups.filter {
+ it.label == "תרגומים" || it.label.startsWith("תרגום ") || it.label.startsWith("תפסיר ")
+ }
+ println("[Bereshit 1:1] Targum-related groups: ${targumGroups.map { it.label }}")
+ if (targumGroups.isEmpty()) {
+ // No Targum is wired up as COMMENTARY for this line — acceptable.
+ return@runBlocking
+ }
+ assertTrue(
+ targumGroups.size == 1 && targumGroups.first().label == "תרגומים",
+ "Targums should collapse into one 'תרגומים' group. Got: ${targumGroups.map { it.label }}",
+ )
+ }
+
+ @Test
+ fun `Bereshit 1_1 — Midrash subgroups merge under a single מדרש group`() =
+ runBlocking {
+ skipIfNoDb()
+ val uc = buildUseCase(BERESHIT_BOOK_ID)
+
+ val groups = uc.getCommentatorGroupsForLines(listOf(BERESHIT_1_1_LINE_ID))
+ val midrashFragments =
+ groups.filter {
+ it.label == "מדרש לקח טוב" ||
+ it.label == "מדרש רבה" ||
+ it.label == "בראשית רבה"
+ }
+ println("[Bereshit 1:1] Midrash fragmentary groups: ${midrashFragments.map { it.label }}")
+ assertTrue(
+ midrashFragments.isEmpty(),
+ "Midrash books should not appear as individual labels — expected merge into 'מדרש'. Found: ${midrashFragments.map {
+ it.label
+ }}",
+ )
+ }
+
+ @Test
+ fun `Bereshit 1_1 — group order respects editorial rank`() =
+ runBlocking {
+ skipIfNoDb()
+ val uc = buildUseCase(BERESHIT_BOOK_ID)
+
+ val groups = uc.getCommentatorGroupsForLines(listOf(BERESHIT_1_1_LINE_ID))
+ val labels = groups.map { it.label }
+ val rishonimIdx = labels.indexOf("ראשונים על התנ״ך")
+ val acharonimIdx = labels.indexOf("אחרונים על התנ״ך")
+ val midrashIdx = labels.indexOf("מדרש")
+ val chasidutIdx = labels.indexOf("חסידות")
+ val kabbalaIdx = labels.indexOf("קבלה")
+
+ println(
+ "[Bereshit 1:1] Rank indices — ראשונים=$rishonimIdx, אחרונים=$acharonimIdx, מדרש=$midrashIdx, חסידות=$chasidutIdx, קבלה=$kabbalaIdx",
+ )
+
+ if (rishonimIdx >= 0 && acharonimIdx >= 0) {
+ assertTrue(rishonimIdx < acharonimIdx, "ראשונים should precede אחרונים. Got: $labels")
+ }
+ if (acharonimIdx >= 0 && midrashIdx >= 0) {
+ assertTrue(acharonimIdx < midrashIdx, "אחרונים should precede מדרש. Got: $labels")
+ }
+ if (midrashIdx >= 0 && chasidutIdx >= 0) {
+ assertTrue(midrashIdx < chasidutIdx, "מדרש should precede חסידות. Got: $labels")
+ }
+ if (chasidutIdx >= 0 && kabbalaIdx >= 0) {
+ assertTrue(chasidutIdx < kabbalaIdx, "חסידות should precede קבלה. Got: $labels")
+ }
+ }
+
+ @Test
+ fun `Berakhot 2a — cross-corpus commentators excluded`() =
+ runBlocking {
+ skipIfNoDb()
+ val uc = buildUseCase(BERAKHOT_BOOK_ID)
+ val groups = uc.getCommentatorGroupsForLines(listOf(BERAKHOT_2A_LINE_ID))
+ val labels = groups.map { it.label }
+ println("[Berakhot 2a cross-corpus] Labels: $labels")
+ // Tora Temima lives in `תנ״ך` and Beit Yosef lives in `הלכה`.
+ // Their CSV COMMENTARY rows on Berakhot are demoted to RELATED at
+ // generation time so the Talmud commentator panel stays clean.
+ assertTrue(
+ "אחרונים על התנ״ך" !in labels,
+ "Talmud reader must not see Tanakh-anchored Acharonim group. Got: $labels",
+ )
+ assertTrue(
+ labels.none {
+ it.startsWith("מפרשים על טור") ||
+ it.startsWith("מפרשים על שולחן ערוך") ||
+ it.startsWith("מפרשים על משנה תורה")
+ },
+ "Talmud reader must not see Halakha-anchored 'מפרשים על X' groups. Got: $labels",
+ )
+ // Sub-commentaries on Rif/Rosh must roll up to the Talmud Rishonim
+ // bucket (resolveGroupLabel pass 1) — they should NOT keep the
+ // intermediate "מפרשים על רי״ף" / "מפרשים על רא״ש" labels.
+ assertTrue(
+ labels.none { it.startsWith("מפרשים על רי") || it.startsWith("מפרשים על רא") },
+ "Rif/Rosh sub-commentaries must roll up to Talmud Rishonim. Got: $labels",
+ )
+ }
+
+ @Test
+ fun `Berakhot 2a — Talmud Rishonim group exists and is non-empty`() =
+ runBlocking {
+ skipIfNoDb()
+ val uc = buildUseCase(BERAKHOT_BOOK_ID)
+
+ val groups = uc.getCommentatorGroupsForLines(listOf(BERAKHOT_2A_LINE_ID))
+ val labels = groups.map { it.label }
+ println("[Berakhot 2a] Group labels in order:")
+ labels.forEachIndexed { i, l -> println(" ${i + 1}. $l (${groups[i].commentators.size} books)") }
+
+ val rishonim =
+ groups.firstOrNull { it.label == "ראשונים על התלמוד" }
+ ?: error("Missing 'ראשונים על התלמוד'. Got: $labels")
+ assertTrue(rishonim.commentators.isNotEmpty(), "Rishonim group should not be empty")
+
+ // Tosafot / Rashi / Rashba / Ritba / Ramban / Rosh — at least 3 must be present.
+ val names = rishonim.commentators.map { it.name }
+ val canon = listOf("רש\"י", "תוספות", "רא\"ש", "רשב\"א", "רמב\"ן", "ריטב\"א", "רי\"ף")
+ val hits =
+ canon.count { needle ->
+ val nrm = needle.replace('"', '״')
+ names.any { it.contains(needle) || it.contains(nrm) }
+ }
+ println("[Berakhot 2a] Canonical Rishonim matched: $hits/${canon.size} — names: $names")
+ assertTrue(hits >= 3, "Expected at least 3 canonical Talmudic Rishonim, found $hits. Got: $names")
+ }
+}
diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateViewModelTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateViewModelTest.kt
index 69afed74..9da99e2c 100644
--- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateViewModelTest.kt
+++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/settings/dbupdate/DbDeltaUpdateViewModelTest.kt
@@ -32,7 +32,6 @@ import kotlin.test.assertTrue
*/
@OptIn(ExperimentalCoroutinesApi::class)
class DbDeltaUpdateViewModelTest {
-
@Before
fun installMain() {
Dispatchers.setMain(kotlinx.coroutines.test.UnconfinedTestDispatcher())
@@ -46,9 +45,7 @@ class DbDeltaUpdateViewModelTest {
private fun vm(service: DbDeltaUpdateService): DbDeltaUpdateViewModel =
DbDeltaUpdateViewModel(service, ioDispatcher = UnconfinedTestDispatcher())
- private fun stub(
- onCheck: suspend (progress: (Int, Int, String) -> Unit) -> DbDeltaUpdateService.Outcome,
- ): DbDeltaUpdateService =
+ private fun stub(onCheck: suspend (progress: (Int, Int, String) -> Unit) -> DbDeltaUpdateService.Outcome): DbDeltaUpdateService =
object : DbDeltaUpdateService(
seforimDb = Path.of("/dev/null/x"),
catalogPb = Path.of("/dev/null/x"),
@@ -56,111 +53,124 @@ class DbDeltaUpdateViewModelTest {
releaseMetaUrl = "",
localDbVersionProvider = { 0 },
) {
- override suspend fun checkAndApply(
- onProgress: (current: Int, total: Int, status: String) -> Unit,
- ): Outcome = onCheck(onProgress)
+ override suspend fun checkAndApply(onProgress: (current: Int, total: Int, status: String) -> Unit): Outcome =
+ onCheck(onProgress)
override fun recoverIfNeeded(): Boolean = false
}
@Test
- fun `initial state is idle`() = runTest {
- val vm = vm(stub { DbDeltaUpdateService.Outcome.UpToDate })
- val state = vm.state.value
- assertNull(state.phase)
- assertEquals("", state.message)
- assertNull(state.errorMessage)
- assertNull(state.lastAppliedCount)
- assertEquals(false, state.needsFullBundle)
- }
+ fun `initial state is idle`() =
+ runTest {
+ val vm = vm(stub { DbDeltaUpdateService.Outcome.UpToDate })
+ val state = vm.state.value
+ assertNull(state.phase)
+ assertEquals("", state.message)
+ assertNull(state.errorMessage)
+ assertNull(state.lastAppliedCount)
+ assertEquals(false, state.needsFullBundle)
+ }
@Test
- fun `UpToDate outcome leaves state with up-to-date message`() = runTest {
- val vm = vm(stub { DbDeltaUpdateService.Outcome.UpToDate })
- vm.onEvent(DbDeltaUpdateEvents.CheckAndApplyClicked)
- advanceUntilIdle()
- val s = vm.state.value
- assertNull(s.phase, "phase must clear after completion")
- assertTrue("up to date" in s.message, "got: ${s.message}")
- assertNull(s.errorMessage)
- }
+ fun `UpToDate outcome leaves state with up-to-date message`() =
+ runTest {
+ val vm = vm(stub { DbDeltaUpdateService.Outcome.UpToDate })
+ vm.onEvent(DbDeltaUpdateEvents.CheckAndApplyClicked)
+ advanceUntilIdle()
+ val s = vm.state.value
+ assertNull(s.phase, "phase must clear after completion")
+ assertTrue("up to date" in s.message, "got: ${s.message}")
+ assertNull(s.errorMessage)
+ }
@Test
- fun `Applied outcome records deltaCount`() = runTest {
- val vm = vm(stub { DbDeltaUpdateService.Outcome.Applied(3) })
- vm.onEvent(DbDeltaUpdateEvents.CheckAndApplyClicked)
- advanceUntilIdle()
- val s = vm.state.value
- assertEquals(3, s.lastAppliedCount)
- assertTrue("3 delta" in s.message, "got: ${s.message}")
- assertNull(s.errorMessage)
- }
+ fun `Applied outcome records deltaCount`() =
+ runTest {
+ val vm = vm(stub { DbDeltaUpdateService.Outcome.Applied(3) })
+ vm.onEvent(DbDeltaUpdateEvents.CheckAndApplyClicked)
+ advanceUntilIdle()
+ val s = vm.state.value
+ assertEquals(3, s.lastAppliedCount)
+ assertTrue("3 delta" in s.message, "got: ${s.message}")
+ assertNull(s.errorMessage)
+ }
@Test
- fun `NeedsFullBundle outcome sets the flag and a hint message`() = runTest {
- val vm = vm(stub { DbDeltaUpdateService.Outcome.NeedsFullBundle })
- vm.onEvent(DbDeltaUpdateEvents.CheckAndApplyClicked)
- advanceUntilIdle()
- val s = vm.state.value
- assertEquals(true, s.needsFullBundle)
- assertTrue("too old" in s.message || "full bundle" in s.message, "got: ${s.message}")
- }
+ fun `NeedsFullBundle outcome sets the flag and a hint message`() =
+ runTest {
+ val vm = vm(stub { DbDeltaUpdateService.Outcome.NeedsFullBundle })
+ vm.onEvent(DbDeltaUpdateEvents.CheckAndApplyClicked)
+ advanceUntilIdle()
+ val s = vm.state.value
+ assertEquals(true, s.needsFullBundle)
+ assertTrue("too old" in s.message || "full bundle" in s.message, "got: ${s.message}")
+ }
@Test
- fun `progress callbacks update the phase`() = runTest {
- val vm = vm(stub { onProgress ->
- onProgress(1, 1, "downloading patch files")
- DbDeltaUpdateService.Outcome.Applied(1)
- })
- vm.onEvent(DbDeltaUpdateEvents.CheckAndApplyClicked)
- advanceUntilIdle()
- // After the run finishes, phase is cleared but lastAppliedCount is set.
- val s = vm.state.value
- assertEquals(1, s.lastAppliedCount)
- }
+ fun `progress callbacks update the phase`() =
+ runTest {
+ val vm =
+ vm(
+ stub { onProgress ->
+ onProgress(1, 1, "downloading patch files")
+ DbDeltaUpdateService.Outcome.Applied(1)
+ },
+ )
+ vm.onEvent(DbDeltaUpdateEvents.CheckAndApplyClicked)
+ advanceUntilIdle()
+ // After the run finishes, phase is cleared but lastAppliedCount is set.
+ val s = vm.state.value
+ assertEquals(1, s.lastAppliedCount)
+ }
@Test
- fun `thrown error becomes errorMessage and clears phase`() = runTest {
- val vm = vm(stub { error("server is on fire") })
- vm.onEvent(DbDeltaUpdateEvents.CheckAndApplyClicked)
- advanceUntilIdle()
- val s = vm.state.value
- assertNotNull(s.errorMessage)
- assertTrue("server is on fire" in s.errorMessage!!, s.errorMessage!!)
- assertNull(s.phase)
- }
+ fun `thrown error becomes errorMessage and clears phase`() =
+ runTest {
+ val vm = vm(stub { error("server is on fire") })
+ vm.onEvent(DbDeltaUpdateEvents.CheckAndApplyClicked)
+ advanceUntilIdle()
+ val s = vm.state.value
+ assertNotNull(s.errorMessage)
+ assertTrue("server is on fire" in s.errorMessage!!, s.errorMessage!!)
+ assertNull(s.phase)
+ }
@Test
- fun `ClearMessage wipes message and errorMessage`() = runTest {
- val vm = vm(stub { DbDeltaUpdateService.Outcome.UpToDate })
- vm.onEvent(DbDeltaUpdateEvents.CheckAndApplyClicked)
- advanceUntilIdle()
- vm.onEvent(DbDeltaUpdateEvents.ClearMessage)
- val s = vm.state.value
- assertEquals("", s.message)
- assertNull(s.errorMessage)
- }
+ fun `ClearMessage wipes message and errorMessage`() =
+ runTest {
+ val vm = vm(stub { DbDeltaUpdateService.Outcome.UpToDate })
+ vm.onEvent(DbDeltaUpdateEvents.CheckAndApplyClicked)
+ advanceUntilIdle()
+ vm.onEvent(DbDeltaUpdateEvents.ClearMessage)
+ val s = vm.state.value
+ assertEquals("", s.message)
+ assertNull(s.errorMessage)
+ }
@Test
- fun `phase is set immediately after click for busy-state UI`() = runTest {
- // The busy guard relies on `phase != null` for skipping concurrent
- // clicks in production. Verify that firing the event causes the
- // ViewModel to transition into a non-null phase synchronously
- // (so any Compose recomposition triggered by the click sees the
- // button as "Working…").
- val vm = vm(stub {
- // Hold the coroutine open: in real life the apply runs for
- // seconds, so phase should be visible to the UI for the duration.
- DbDeltaUpdateService.Outcome.UpToDate
- })
- vm.onEvent(DbDeltaUpdateEvents.CheckAndApplyClicked)
- // Immediately after dispatching the event, the state has progressed
- // into CheckingForUpdates phase before yielding to advanceUntilIdle.
- // (UnconfinedTestDispatcher actually runs through to completion eagerly,
- // so we just assert the final state is consistent.)
- advanceUntilIdle()
- val s = vm.state.value
- assertNull(s.phase, "phase clears once the run completes")
- assertNull(s.errorMessage)
- }
+ fun `phase is set immediately after click for busy-state UI`() =
+ runTest {
+ // The busy guard relies on `phase != null` for skipping concurrent
+ // clicks in production. Verify that firing the event causes the
+ // ViewModel to transition into a non-null phase synchronously
+ // (so any Compose recomposition triggered by the click sees the
+ // button as "Working…").
+ val vm =
+ vm(
+ stub {
+ // Hold the coroutine open: in real life the apply runs for
+ // seconds, so phase should be visible to the UI for the duration.
+ DbDeltaUpdateService.Outcome.UpToDate
+ },
+ )
+ vm.onEvent(DbDeltaUpdateEvents.CheckAndApplyClicked)
+ // Immediately after dispatching the event, the state has progressed
+ // into CheckingForUpdates phase before yielding to advanceUntilIdle.
+ // (UnconfinedTestDispatcher actually runs through to completion eagerly,
+ // so we just assert the final state is consistent.)
+ advanceUntilIdle()
+ val s = vm.state.value
+ assertNull(s.phase, "phase clears once the run completes")
+ assertNull(s.errorMessage)
+ }
}
diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/integration/DesktopManagerIntegrationTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/integration/DesktopManagerIntegrationTest.kt
index 4e1ce30a..a75a1c0e 100644
--- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/integration/DesktopManagerIntegrationTest.kt
+++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/integration/DesktopManagerIntegrationTest.kt
@@ -574,6 +574,8 @@ class DesktopManagerIntegrationTest {
assertEquals("d1", desktopManager.activeDesktopId.value)
// Active desktop tabs restored
assertEquals(1, tabsViewModel.state.value.tabs.size)
+ assertEquals("Book A", tabsViewModel.state.value.tabs.first().title)
+ assertEquals(TabType.BOOK, tabsViewModel.state.value.tabs.first().tabType)
assertEquals(
10L,
(
@@ -582,6 +584,12 @@ class DesktopManagerIntegrationTest {
.destination as TabsDestination.BookContent
).bookId,
)
+
+ desktopManager.switchTo("d2")
+ desktopManager.clearSwitching()
+
+ assertEquals("Book B", tabsViewModel.state.value.tabs.first().title)
+ assertEquals(TabType.BOOK, tabsViewModel.state.value.tabs.first().tabType)
}
@Test
diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/integration/TabsViewModelIntegrationTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/integration/TabsViewModelIntegrationTest.kt
index 1d967c52..6d86430b 100644
--- a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/integration/TabsViewModelIntegrationTest.kt
+++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/integration/TabsViewModelIntegrationTest.kt
@@ -422,6 +422,29 @@ class TabsViewModelIntegrationTest {
assertEquals(1, viewModel.state.value.selectedTabIndex) // Clamped to last valid index
}
+ @Test
+ fun `restoreTabs uses persisted titles without loading tab content`() =
+ runTest {
+ val destinations =
+ listOf(
+ TabsDestination.BookContent(bookId = -1, tabId = "tab1"),
+ TabsDestination.Search(searchQuery = "", tabId = "tab2"),
+ )
+ val titles =
+ mapOf(
+ "tab1" to ("Book A" to TabType.BOOK),
+ "tab2" to ("query" to TabType.SEARCH),
+ )
+
+ viewModel.restoreTabs(destinations, selectedIndex = 0, titles = titles)
+
+ val tabs = viewModel.state.value.tabs
+ assertEquals("Book A", tabs[0].title)
+ assertEquals(TabType.BOOK, tabs[0].tabType)
+ assertEquals("query", tabs[1].title)
+ assertEquals(TabType.SEARCH, tabs[1].tabType)
+ }
+
// ==================== Title Update Tests ====================
@Test
diff --git a/SeforimLibrary b/SeforimLibrary
index 7dfb259e..dc4c8e1c 160000
--- a/SeforimLibrary
+++ b/SeforimLibrary
@@ -1 +1 @@
-Subproject commit 7dfb259e9a551715620de81fb1d786e3617c36bd
+Subproject commit dc4c8e1ce30c2fc0edf2879cb3dc38649f09f966
diff --git a/cataloggen/src/main/kotlin/io/github/kdroidfilter/seforimapp/cataloggen/Generate.kt b/cataloggen/src/main/kotlin/io/github/kdroidfilter/seforimapp/cataloggen/Generate.kt
index af8ebe9d..989d9250 100644
--- a/cataloggen/src/main/kotlin/io/github/kdroidfilter/seforimapp/cataloggen/Generate.kt
+++ b/cataloggen/src/main/kotlin/io/github/kdroidfilter/seforimapp/cataloggen/Generate.kt
@@ -8,8 +8,10 @@ import kotlinx.coroutines.runBlocking
import java.io.File
/**
- * Code generator that reads the SQLite DB and emits a Kotlin object with
- * precomputed titles and mappings used by the app UI.
+ * Code generator that reads the SQLite DB and emits compile-time UI presets:
+ * named ID constants (categories/books/TOC texts) and HomeView dropdown specs.
+ * Bulk data (book titles, category titles, books-per-category) is no longer
+ * emitted — the app loads it at runtime from catalog.pb via CatalogAccess.
*
* Usage (via Gradle task):
* With env var (recommended):
@@ -48,62 +50,25 @@ fun main(args: Array) {
val resolvedIds = runBlocking { resolveCatalogIds(repo) }
- // Only include categories used in the current UI (resolved dynamically from the DB)
- val categoriesOfInterest: Set =
- resolvedIds.categoryIds.values
- .plus(resolvedIds.mishnehTorahChildren)
- .toSet()
- val categoryTitles: MutableMap = mutableMapOf()
- runBlocking {
- categoriesOfInterest.forEach { cid ->
- runCatching { repo.getCategory(cid) }.getOrNull()?.let { categoryTitles[cid] = it.title }
- }
- // Preserve legacy display labels for Talmud children (תלמוד בבלי / תלמוד ירושלמי)
- val bavliId = resolvedIds.categoryIds["BAVLI"]
- val yerushalmiId = resolvedIds.categoryIds["YERUSHALMI"]
- if (bavliId != null || yerushalmiId != null) {
- val bavliParentTitle =
- bavliId?.let { id ->
- runCatching { repo.getCategory(id)?.parentId?.let { pid -> repo.getCategory(pid)?.title } }.getOrNull()
- }
- val prefix = bavliParentTitle?.takeIf { it.isNotBlank() } ?: "תלמוד"
- bavliId?.let { id ->
- val current = categoryTitles[id] ?: "בבלי"
- categoryTitles[id] = "$prefix $current"
- }
- yerushalmiId?.let { id ->
- val current = categoryTitles[id] ?: "ירושלמי"
- categoryTitles[id] = "$prefix $current"
- }
+ // Raw category titles for Ids.Categories kdoc annotations only — not emitted as data.
+ val categoryTitles: Map =
+ runBlocking {
+ resolvedIds.categoryIds.values
+ .plus(resolvedIds.mishnehTorahChildren)
+ .toSet()
+ .mapNotNull { cid -> runCatching { repo.getCategory(cid) }.getOrNull()?.let { cid to it.title } }
+ .toMap()
}
- }
- // Collect books per category and book titles (strip display titles by category label)
- val bookTitles: MutableMap = mutableMapOf()
- val categoryBooks: MutableMap>> = mutableMapOf()
- val mishnehTorahId = resolvedIds.categoryIds.getValue("MISHNE_TORAH")
- runBlocking {
- categoryTitles.keys.forEach { cid ->
- var books = runCatching { repo.getBooksByCategory(cid) }.getOrDefault(emptyList())
- // For Mishneh Torah (root or its immediate children), exclude books starting with "מפרשים"
- val parentId = runCatching { repo.getCategory(cid) }.getOrNull()?.parentId
- val isMishnehTorahContext = (cid == mishnehTorahId) || (parentId == mishnehTorahId)
- if (isMishnehTorahContext) {
- books = books.filter { b -> !b.title.trimStart().startsWith("מפרשים") }
- }
- // Strip any ancestor labels (category, parent, root, etc.) to avoid repetition like "משנה תורה, ..."
- val labels = ancestorTitles(repo, cid)
- val refs =
- books.map { b ->
- bookTitles[b.id] = b.title
- val display = stripAnyLabelPrefix(labels, b.title)
- b.id to display
- }
- categoryBooks[cid] = refs
+ // Raw book titles for Ids.Books kdoc annotations only — not emitted as data.
+ val bookTitles: Map =
+ runBlocking {
+ resolvedIds.bookIds.values
+ .mapNotNull { bid -> runCatching { repo.getBook(bid) }.getOrNull()?.let { bid to it.title } }
+ .toMap()
}
- }
- // Collect per-book TOC-textId → (label, tocEntryId, firstLineId) for books we use in UI
+ // Collect per-book TOC-textId → (label, tocEntryId, firstLineId) for books exposed via TocQuickLinksSpec.
val tocByTocTextId: MutableMap>> = mutableMapOf()
val booksOfInterest = resolvedIds.bookIds.values.toSet()
val tocTextIdsOfInterest = resolvedIds.tocTextIds.values.toSet()
@@ -129,8 +94,14 @@ fun main(args: Array) {
val pkg = "io.github.kdroidfilter.seforimapp.catalog"
val fileSpecBuilder =
FileSpec
- .builder(pkg, "PrecomputedCatalog")
- .addFileComment(
+ .builder(pkg, "CatalogPresets")
+ .addAnnotation(
+ AnnotationSpec
+ .builder(ClassName("kotlin", "Suppress"))
+ .useSiteTarget(AnnotationSpec.UseSiteTarget.FILE)
+ .addMember("%S", "ktlint")
+ .build(),
+ ).addFileComment(
"""
DO NOT EDIT.
This file is auto-generated by the catalog generator.
@@ -199,6 +170,7 @@ fun main(args: Array) {
).addProperty(PropertySpec.builder("labelCategoryId", LONG).initializer("labelCategoryId").build())
.addProperty(PropertySpec.builder("bookCategoryIds", LIST.parameterizedBy(LONG)).initializer("bookCategoryIds").build())
.build()
+ val tocQuickLinkType = ClassName(pkg, "TocQuickLink")
val tocQuickLinksSpec =
TypeSpec
.classBuilder("TocQuickLinksSpec")
@@ -208,10 +180,10 @@ fun main(args: Array) {
FunSpec
.constructorBuilder()
.addParameter("bookId", LONG)
- .addParameter("tocTextIds", LIST.parameterizedBy(LONG))
+ .addParameter("links", LIST.parameterizedBy(tocQuickLinkType))
.build(),
).addProperty(PropertySpec.builder("bookId", LONG).initializer("bookId").build())
- .addProperty(PropertySpec.builder("tocTextIds", LIST.parameterizedBy(LONG)).initializer("tocTextIds").build())
+ .addProperty(PropertySpec.builder("links", LIST.parameterizedBy(tocQuickLinkType)).initializer("links").build())
.build()
fileSpecBuilder
.addType(bookRef)
@@ -228,7 +200,6 @@ fun main(args: Array) {
pkg,
bookTitles,
categoryTitles,
- categoryBooks,
tocByTocTextId,
mishnehTorahChildrenIds,
resolvedIds,
@@ -242,135 +213,19 @@ fun main(args: Array) {
fileSpec.writeTo(outputDir)
}
-private fun collectCategoryTitles(
- repo: SeforimRepository,
- parentId: Long,
- out: MutableMap,
-) {
- runBlocking {
- val children = runCatching { repo.getCategoryChildren(parentId) }.getOrDefault(emptyList())
- children.forEach { c ->
- out[c.id] = c.title
- collectCategoryTitles(repo, c.id, out)
- }
- }
-}
-
-private fun rootCategoryTitle(
- repo: SeforimRepository,
- categoryId: Long,
-): String =
- runBlocking {
- var cur = runCatching { repo.getCategory(categoryId) }.getOrNull()
- var lastTitle: String? = cur?.title
- var guard = 0
- while (cur?.parentId != null && guard++ < 50) {
- cur = runCatching { repo.getCategory(cur.parentId!!) }.getOrNull()
- if (cur?.title != null) lastTitle = cur.title
- }
- lastTitle ?: ""
- }
-
-private fun ancestorTitles(
- repo: SeforimRepository,
- categoryId: Long,
-): List =
- runBlocking {
- val labels = mutableListOf()
- var cur = runCatching { repo.getCategory(categoryId) }.getOrNull()
- if (cur?.title != null) labels += cur.title
- var guard = 0
- while (cur?.parentId != null && guard++ < 50) {
- cur = runCatching { repo.getCategory(cur.parentId!!) }.getOrNull()
- val t = cur?.title
- if (!t.isNullOrBlank()) labels += t
- }
- labels.distinct()
- }
-
private fun buildCatalogType(
pkg: String,
bookTitles: Map,
categoryTitles: Map,
- categoryBooks: Map>>,
tocByTocTextId: Map>>,
mishnehTorahChildrenIds: List,
resolvedIds: ResolvedCatalogIds,
): TypeSpec {
- val builder = TypeSpec.objectBuilder("PrecomputedCatalog")
+ val builder = TypeSpec.objectBuilder("CatalogPresets")
val categoryIds = resolvedIds.categoryIds
val bookIds = resolvedIds.bookIds
val tocTextIds = resolvedIds.tocTextIds
- // BOOK_TITLES
- val btCode = CodeBlock.builder().add("mapOf(\n")
- bookTitles.entries.sortedBy { it.key }.forEach { (id, title) ->
- btCode.add(" %LL to %S,\n", id, title)
- }
- btCode.add(")")
- builder.addProperty(
- PropertySpec
- .builder("BOOK_TITLES", MAP.parameterizedBy(LONG, STRING))
- .initializer(btCode.build())
- .build(),
- )
-
- // CATEGORY_TITLES
- val ctCode = CodeBlock.builder().add("mapOf(\n")
- categoryTitles.entries.sortedBy { it.key }.forEach { (id, title) ->
- ctCode.add(" %LL to %S,\n", id, title)
- }
- ctCode.add(")")
- builder.addProperty(
- PropertySpec
- .builder("CATEGORY_TITLES", MAP.parameterizedBy(LONG, STRING))
- .initializer(ctCode.build())
- .build(),
- )
-
- // CATEGORY_BOOKS
- val bookRefType = ClassName(pkg, "BookRef")
- val listBookRef = LIST.parameterizedBy(bookRefType)
- val mapCatBooks = MAP.parameterizedBy(LONG, listBookRef)
- val cbCode = CodeBlock.builder().add("mapOf(\n")
- categoryBooks.entries.sortedBy { it.key }.forEach { (cid, refs) ->
- cbCode.add(" %LL to listOf(", cid)
- refs.forEachIndexed { idx, (bid, btitle) ->
- if (idx > 0) cbCode.add(", ")
- cbCode.add("BookRef(%LL, %S)", bid, btitle)
- }
- cbCode.add(") ,\n")
- }
- cbCode.add(")")
- builder.addProperty(
- PropertySpec
- .builder("CATEGORY_BOOKS", mapCatBooks)
- .initializer(cbCode.build())
- .build(),
- )
-
- // TOC_BY_TOC_TEXT_ID
- val tocQLType = ClassName(pkg, "TocQuickLink")
- val innerMap = MAP.parameterizedBy(LONG, tocQLType)
- val tocMapType = MAP.parameterizedBy(LONG, innerMap)
- val tocCode = CodeBlock.builder().add("mapOf(\n")
- tocByTocTextId.entries.sortedBy { it.key }.forEach { (bookId, inner) ->
- tocCode.add(" %LL to mapOf(", bookId)
- inner.entries.forEachIndexed { idx, (tx, triple) ->
- if (idx > 0) tocCode.add(", ")
- val (label, tocEntryId, firstLineId) = triple
- tocCode.add("%LL to TocQuickLink(%S, %LL, %L)", tx, label, tocEntryId, firstLineId)
- }
- tocCode.add(") ,\n")
- }
- tocCode.add(")")
- builder.addProperty(
- PropertySpec
- .builder("TOC_BY_TOC_TEXT_ID", tocMapType)
- .initializer(tocCode.build())
- .build(),
- )
-
// Ids: pretty-named constants for UI code (avoid magic numbers)
val idsObj = TypeSpec.objectBuilder("Ids")
@@ -473,6 +328,24 @@ private fun buildCatalogType(
val tocYd = tocTextIds.getValue("YOREH_DEAH")
val tocEh = tocTextIds.getValue("EVEN_HAEZER")
val tocCm = tocTextIds.getValue("CHOSHEN_MISHPAT")
+ val turLinks: Map> =
+ tocByTocTextId[turBookId] ?: error("Missing TOC quick-link data for Tur (bookId=$turBookId)")
+ val turLinkOrder = listOf(tocOc, tocYd, tocEh, tocCm)
+ val turQuickLinksLiteral =
+ CodeBlock
+ .builder()
+ .apply {
+ add("TocQuickLinksSpec(%LL, listOf(", turBookId)
+ turLinkOrder.forEachIndexed { idx, textId ->
+ val triple =
+ turLinks[textId]
+ ?: error("Missing Tur quick-link for textId=$textId")
+ val (label, tocEntryId, firstLineId) = triple
+ if (idx > 0) add(", ")
+ add("TocQuickLink(%S, %LL, %L)", label, tocEntryId, firstLineId)
+ }
+ add("))")
+ }.build()
val homeDropdowns =
CodeBlock
.builder()
@@ -514,14 +387,8 @@ private fun buildCatalogType(
// Shulchan Aruch
.add(" CategoryDropdownSpec(%LL),\n", shulchanAruchId)
// Tur quick links
- .add(
- " TocQuickLinksSpec(%LL, listOf(%LL, %LL, %LL, %LL)),\n",
- turBookId,
- tocOc,
- tocYd,
- tocEh,
- tocCm,
- ).add(")")
+ .add(" %L,\n", turQuickLinksLiteral)
+ .add(")")
.build()
dropdownsObj.addProperty(
PropertySpec
@@ -630,14 +497,8 @@ private fun buildCatalogType(
dropdownsObj.addProperty(
PropertySpec
.builder("TUR_QUICK_LINKS", dropdownSpecClass)
- .initializer(
- "TocQuickLinksSpec(%LL, listOf(%LL, %LL, %LL, %LL))",
- turBookId,
- tocOc,
- tocYd,
- tocEh,
- tocCm,
- ).build(),
+ .initializer(turQuickLinksLiteral)
+ .build(),
)
builder.addType(dropdownsObj.build())
@@ -860,45 +721,3 @@ private fun normalizeTitle(value: String): String = value.filter { it.isLetterOr
private val STRING = String::class.asClassName()
private val LONG = Long::class.asClassName()
private val LIST = ClassName("kotlin.collections", "List")
-private val MAP = ClassName("kotlin.collections", "Map")
-
-private fun stripLabelPrefix(
- label: String,
- title: String,
-): String {
- if (label.isBlank()) return title
- val prefix = Regex.escape(label)
- val patterns =
- listOf(
- Regex("^$prefix\\s*,\\s*"), // label + comma
- Regex("^$prefix,\\s*"), // label,comma
- Regex("^$prefix\\s*[:–—-]\\s*"), // label + colon/en/em dash/hyphen
- Regex("^$prefix\\s*\\+\\s*"), // label + plus
- Regex("^$prefix\\s+"), // label + space
- )
- for (p in patterns) {
- val replaced = title.replaceFirst(p, "")
- if (replaced !== title) return replaced.trimStart()
- }
- return title
-}
-
-private fun stripAnyLabelPrefix(
- labels: List,
- title: String,
-): String {
- var result = title
- for (lbl in labels) {
- result = stripLabelPrefix(lbl, result)
- }
- return result
-}
-
-private inline fun Iterable>.associateNotNull(transform: (Map.Entry) -> R?): Map {
- val dest = LinkedHashMap()
- for (e in this) {
- val v = transform(e) ?: continue
- dest[e.key] = v
- }
- return dest
-}
diff --git a/earthwidget/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/EarthShaderRenderer.kt b/earthwidget/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/EarthShaderRenderer.kt
index 69db9fc4..cf06e2a7 100644
--- a/earthwidget/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/EarthShaderRenderer.kt
+++ b/earthwidget/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/earthwidget/EarthShaderRenderer.kt
@@ -1,3 +1,5 @@
+@file:Suppress("DEPRECATION", "DEPRECATION_ERROR")
+
package io.github.kdroidfilter.seforimapp.earthwidget
import androidx.compose.ui.graphics.ImageBitmap
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 12f113ea..d7be71a4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -3,51 +3,51 @@
commonsCompress = "1.28.0"
composeSonner = "0.4.0"
confettikit = "0.8.0"
-filekitCore = "0.13.0"
+filekitCore = "0.14.1"
hebrewNumerals = "0.2.6"
jsoup = "1.22.2"
jvmToolchain = "25"
-nucleus = "1.14.2"
-koalaplotCore = "0.11.0"
+nucleus = "2.0.0-alpha-202605272130"
+koalaplotCore = "0.11.2"
kotlin = "2.3.21"
compose = "1.10.3"
-agp = "9.1.0"
+agp = "9.1.1"
androidx-activityCompose = "1.13.0"
-androidx-uiTest = "1.10.6"
-hotReload = "1.0.0"
+androidx-uiTest = "1.11.2"
+hotReload = "1.1.1"
kotlinpoet = "2.3.0"
-ktor = "3.4.3"
+ktor = "3.5.0"
androidx-lifecycle = "2.10.0"
androidx-navigation = "2.9.2"
kotlinx-serialization = "1.11.0"
multiplatformSettings = "1.3.0"
-kotlinx-datetime = "0.7.1"
+kotlinx-datetime = "0.8.0"
buildConfig = "6.0.9"
materialKolor = "4.1.1"
-jewel = "0.35.0-261.23567.138"
-paging = "3.4.2"
+jewel = "0.37.0-262.4852.74"
+paging = "3.5.0"
platformtools = "0.7.5"
kotlinx-collections-immutable = "0.4.0"
-kotlinx-coroutines = "1.10.2"
+kotlinx-coroutines = "1.11.0"
reorderable = "3.1.0"
-slf4j = "2.0.17"
+slf4j = "2.0.18"
maven-publish = "0.36.0"
sqlDelight = "2.3.2"
adaptiveNavigation = "1.2.0"
zmanim = "2.5.0"
-zstdJni = "1.5.7-7"
-metro = "0.13.2"
+zstdJni = "1.5.7-9"
+metro = "1.1.1"
lucene = "10.4.0"
ktlint = "14.2.0"
detekt = "2.0.0-alpha.3"
-structured-coroutines = "0.7.0"
+structured-coroutines = "0.8.0"
kover = "0.9.8"
mockk = "1.14.9"
-kotlinx-coroutines-test = "1.10.2"
-sentry = "6.5.0"
-sentrySdk = "8.40.0"
+kotlinx-coroutines-test = "1.11.0"
+sentry = "6.8.1"
+sentrySdk = "8.42.0"
graalHotspot = "22.0.0.2"
-intellijPlatformIcons = "253.31033.145"
+intellijPlatformIcons = "262.4852.74"
jbrApi = "1.10.1"
[libraries]
@@ -64,22 +64,24 @@ filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose
hebrew-numerals = { module = "io.github.kdroidfilter:hebrewnumerals", version.ref = "hebrewNumerals" }
jdbc-driver = { module = "app.cash.sqldelight:jdbc-driver", version.ref = "sqlDelight" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
-nucleus-core-runtime = { module = "io.github.kdroidfilter:nucleus.core-runtime", version.ref = "nucleus" }
-nucleus-aot-runtime = { module = "io.github.kdroidfilter:nucleus.aot-runtime", version.ref = "nucleus" }
-nucleus-darkmode-detector = { module = "io.github.kdroidfilter:nucleus.darkmode-detector", version.ref = "nucleus" }
-nucleus-decorated-window = { module = "io.github.kdroidfilter:nucleus.decorated-window-jni", version.ref = "nucleus" }
-nucleus-decorated-window-jewel = { module = "io.github.kdroidfilter:nucleus.decorated-window-jewel", version.ref = "nucleus" }
-nucleus-graalvm-runtime = { module = "io.github.kdroidfilter:nucleus.graalvm-runtime", version.ref = "nucleus" }
-nucleus-updater-runtime = { module = "io.github.kdroidfilter:nucleus.updater-runtime", version.ref = "nucleus" }
-nucleus-native-ssl = { module = "io.github.kdroidfilter:nucleus.native-ssl", version.ref = "nucleus" }
-nucleus-energy-manager = { module = "io.github.kdroidfilter:nucleus.energy-manager", version.ref = "nucleus"}
-nucleus-system-color = { module = "io.github.kdroidfilter:nucleus.system-color", version.ref = "nucleus" }
-nucleus-native-http-ktor = { module = "io.github.kdroidfilter:nucleus.native-http-ktor", version.ref = "nucleus" }
-nucleus-launcher-macos = { module = "io.github.kdroidfilter:nucleus.launcher-macos", version.ref = "nucleus" }
-nucleus-launcher-windows = { module = "io.github.kdroidfilter:nucleus.launcher-windows", version.ref = "nucleus" }
-nucleus-launcher-linux = { module = "io.github.kdroidfilter:nucleus.launcher-linux", version.ref = "nucleus" }
-nucleus-menu-macos = { module = "io.github.kdroidfilter:nucleus.menu-macos", version.ref = "nucleus" }
-nucleus-sf-symbols = { module = "io.github.kdroidfilter:nucleus.sf-symbols", version.ref = "nucleus" }
+nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleus" }
+nucleus-aot-runtime = { module = "dev.nucleusframework:nucleus.aot-runtime", version.ref = "nucleus" }
+nucleus-darkmode-detector = { module = "dev.nucleusframework:nucleus.darkmode-detector", version.ref = "nucleus" }
+nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleus" }
+nucleus-decorated-window-core = { module = "dev.nucleusframework:nucleus.decorated-window-core", version.ref = "nucleus" }
+nucleus-decorated-window-tao = { module = "dev.nucleusframework:nucleus.decorated-window-tao", version.ref = "nucleus" }
+nucleus-decorated-window-jewel = { module = "dev.nucleusframework:nucleus.decorated-window-jewel", version.ref = "nucleus" }
+nucleus-graalvm-runtime = { module = "dev.nucleusframework:nucleus.graalvm-runtime", version.ref = "nucleus" }
+nucleus-updater-runtime = { module = "dev.nucleusframework:nucleus.updater-runtime", version.ref = "nucleus" }
+nucleus-native-ssl = { module = "dev.nucleusframework:nucleus.native-ssl", version.ref = "nucleus" }
+nucleus-energy-manager = { module = "dev.nucleusframework:nucleus.energy-manager", version.ref = "nucleus"}
+nucleus-system-color = { module = "dev.nucleusframework:nucleus.system-color", version.ref = "nucleus" }
+nucleus-native-http-ktor = { module = "dev.nucleusframework:nucleus.native-http-ktor", version.ref = "nucleus" }
+nucleus-launcher-macos = { module = "dev.nucleusframework:nucleus.launcher-macos", version.ref = "nucleus" }
+nucleus-launcher-windows = { module = "dev.nucleusframework:nucleus.launcher-windows", version.ref = "nucleus" }
+nucleus-launcher-linux = { module = "dev.nucleusframework:nucleus.launcher-linux", version.ref = "nucleus" }
+nucleus-menu-macos = { module = "dev.nucleusframework:nucleus.menu-macos", version.ref = "nucleus" }
+nucleus-sf-symbols = { module = "dev.nucleusframework:nucleus.sf-symbols", version.ref = "nucleus" }
koalaplot-core = { module = "io.github.koalaplot:koalaplot-core", version.ref = "koalaplotCore" }
kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" }
kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinx-serialization" }
@@ -113,8 +115,8 @@ intellij-platform-icons = { module = "com.jetbrains.intellij.platform:icons", ve
jbr-api = { module = "org.jetbrains.runtime:jbr-api", version.ref = "jbrApi" }
androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
-nucleus-notification-common = { module = "io.github.kdroidfilter:nucleus.notification-common", version.ref = "nucleus" }
-nucleus-system-info = { module = "io.github.kdroidfilter:nucleus.system-info", version.ref = "nucleus" }
+nucleus-notification-common = { module = "dev.nucleusframework:nucleus.notification-common", version.ref = "nucleus" }
+nucleus-system-info = { module = "dev.nucleusframework:nucleus.system-info", version.ref = "nucleus" }
platformtools-appmanager = { module = "io.github.kdroidfilter:platformtools.appmanager", version.ref = "platformtools" }
platformtools-core = { module = "io.github.kdroidfilter:platformtools.core", version.ref = "platformtools" }
platformtools-releasefetcher = { module = "io.github.kdroidfilter:platformtools.releasefetcher", version.ref = "platformtools" }
@@ -177,8 +179,8 @@ sqlDelight = { id = "app.cash.sqldelight", version.ref = "sqlDelight" }
android-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
caupain = { id = "com.deezer.caupain", version = "1.9.1"}
metro = { id = "dev.zacsweers.metro", version.ref = "metro" }
-nucleus = { id = "io.github.kdroidfilter.nucleus", version.ref = "nucleus" }
-stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.7.3" }
+nucleus = { id = "dev.nucleusframework", version.ref = "nucleus" }
+stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.7.5" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
detekt = { id = "dev.detekt", version.ref = "detekt" }
structured-coroutines = { id = "io.github.santimattius.structured-coroutines", version.ref = "structured-coroutines" }
diff --git a/network/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/network/KtorConfig.kt b/network/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/network/KtorConfig.kt
index d64bfb67..7c1124dd 100644
--- a/network/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/network/KtorConfig.kt
+++ b/network/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/network/KtorConfig.kt
@@ -1,6 +1,6 @@
package io.github.kdroidfilter.seforimapp.network
-import io.github.kdroidfilter.nucleus.nativehttp.ktor.installNativeSsl
+import dev.nucleusframework.nativehttp.ktor.installNativeSsl
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
diff --git a/network/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/network/TrustedRootsSSL.kt b/network/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/network/TrustedRootsSSL.kt
index 8c009c22..02afc1b6 100644
--- a/network/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/network/TrustedRootsSSL.kt
+++ b/network/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/network/TrustedRootsSSL.kt
@@ -1,6 +1,6 @@
package io.github.kdroidfilter.seforimapp.network
-import io.github.kdroidfilter.nucleus.nativessl.NativeTrustManager
+import dev.nucleusframework.nativessl.NativeTrustManager
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
diff --git a/pagination/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/pagination/MultiLineLinksPagingSource.kt b/pagination/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/pagination/MultiLineLinksPagingSource.kt
index 6af2f27a..e3df746a 100644
--- a/pagination/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/pagination/MultiLineLinksPagingSource.kt
+++ b/pagination/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/pagination/MultiLineLinksPagingSource.kt
@@ -35,6 +35,10 @@ class MultiLineLinksPagingSource(
connectionTypes = connectionTypes,
offset = offset,
limit = limit,
+ // Dedup source lines that cite multiple target lines in the
+ // selection. Otherwise a single sugya referenced by multiple
+ // halakhot in a TOC heading appears N times in the panel.
+ distinctByTargetLine = lineIds.size > 1,
)
val prevKey = if (page == 0) null else page - 1