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