From 9e0a45ba52ac95958811ba8c98bf985cf77400e6 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 17 May 2026 02:03:55 +0300 Subject: [PATCH 01/16] feat: migrate to Nucleus 2.0, Compose 1.11, Jewel 0.37 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename all nucleus deps from io.github.kdroidfilter → dev.nucleusframework - Replace application{} + SingleInstanceManager + AotRuntime with nucleusApplication { aotTraining() } - Replace manual deep-link single-instance bridge with onDeepLink {} - Split nucleus-decorated-window into core + tao + jewel artifacts - Add LocalTextContextMenu workaround for Jewel 0.37 / Compose 1.11 NoSuchMethodError - Move window minimumSize from LaunchedEffect to JewelDecoratedWindow param - Fix Key.Home → Key.MoveHome for Alt+Home shortcut - Bump Compose 1.10.3 → 1.11.0, Jewel 0.35 → 0.37, IntelliJ icons 253 → 262 --- .run/SeforimApp [run].run.xml | 1 + SeforimApp/build.gradle.kts | 10 +- .../coroutines/EfficiencyCoreDispatcher.kt | 2 +- .../presentation/components/AppDockMenu.kt | 6 +- .../presentation/components/AppJumpList.kt | 6 +- .../components/AppLinuxQuicklist.kt | 8 +- .../components/AppNativeMenuBar.kt | 12 +- .../presentation/components/MainTitleBar.kt | 12 +- .../components/TitleBarActionsButtonsView.kt | 11 +- .../core/presentation/theme/AccentColor.kt | 2 +- .../core/presentation/theme/ThemeUtils.kt | 2 +- .../features/bookcontent/BookContentScreen.kt | 4 +- .../ui/panels/bookcontent/views/HomeView.kt | 2 +- .../usecases/CommentariesUseCase.kt | 27 +++-- .../database/update/DatabaseUpdateWindow.kt | 12 +- .../features/onboarding/OnBoardingWindow.kt | 12 +- .../diskspace/AvailableDiskSpaceUseCase.kt | 2 +- .../features/settings/SettingsWindow.kt | 13 ++- .../settings/ui/GeneralSettingsScreen.kt | 2 +- .../framework/update/AppUpdateChecker.kt | 2 +- .../io/github/kdroidfilter/seforimapp/main.kt | 103 ++++++++---------- .../earthwidget/EarthShaderRenderer.kt | 2 + gradle/libs.versions.toml | 48 ++++---- .../seforimapp/network/KtorConfig.kt | 2 +- .../seforimapp/network/TrustedRootsSSL.kt | 2 +- 25 files changed, 151 insertions(+), 154 deletions(-) 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..ce3489bc 100644 --- a/SeforimApp/build.gradle.kts +++ b/SeforimApp/build.gradle.kts @@ -1,7 +1,7 @@ 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 dev.nucleusframework.desktop.application.dsl.ReleaseChannel +import dev.nucleusframework.desktop.application.dsl.ReleaseType +import dev.nucleusframework.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) 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/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..401beb04 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,7 +8,6 @@ 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 @@ -129,7 +128,7 @@ fun TitleBarActionsButtonsView() { when (theme) { IntUiThemes.Light -> AllIconsKeys.MeetNewUi.LightTheme IntUiThemes.Dark -> AllIconsKeys.MeetNewUi.DarkTheme - IntUiThemes.System -> AllIconsKeys.MeetNewUi.SystemTheme + IntUiThemes.System -> AllIconsKeys.General.Settings }, contentDescription = iconDescription, onClick = { @@ -154,10 +153,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/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/BookContentScreen.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentScreen.kt index f9954c2a..cb3228a5 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentScreen.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentScreen.kt @@ -89,10 +89,10 @@ private val TextSearchContextMenuIconKey = PathIconKey("icons/lucide_text_search private class ContextMenuItemOptionWithKeybinding( val icon: org.jetbrains.jewel.ui.icon.IconKey? = null, val keybinding: Set? = null, - val enabled: Boolean = true, + enabled: Boolean = true, label: String, action: () -> Unit, -) : ContextMenuItem(label, action) +) : ContextMenuItem(label, enabled, action) @OptIn(InternalJewelApi::class) private object BookContentContextMenuRepresentationWithKeybindings : ComposeContextMenuRepresentation { diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/HomeView.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/HomeView.kt index 98a6ef6b..c438fe40 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/HomeView.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/HomeView.kt @@ -74,7 +74,7 @@ import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* import org.jetbrains.jewel.ui.icons.AllIconsKeys import org.jetbrains.jewel.ui.theme.menuStyle -import org.jetbrains.skiko.Cursor +import java.awt.Cursor import seforimapp.seforimapp.generated.resources.* import kotlin.math.roundToInt import kotlin.time.Duration.Companion.milliseconds 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..ca4778e9 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 @@ -662,20 +662,17 @@ 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) + .getCommentarySummariesForLines(baseIds, includeSources = true) .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 +691,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, includeSources = includeSources) 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() 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..6d538e4d 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,13 @@ 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.ControlButtonsDirection +import dev.nucleusframework.window.jewel.JewelDecoratedWindow +import dev.nucleusframework.window.jewel.JewelTitleBar +import dev.nucleusframework.window.newFullscreenControls 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 +35,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, ) { 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..c0e833ad 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,13 @@ 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.ControlButtonsDirection +import dev.nucleusframework.window.jewel.JewelDecoratedWindow +import dev.nucleusframework.window.jewel.JewelTitleBar +import dev.nucleusframework.window.newFullscreenControls 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 +35,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() }, 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/ui/GeneralSettingsScreen.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/settings/ui/GeneralSettingsScreen.kt index c16f3be5..5f0bee60 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 @@ -26,7 +26,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.zacsweers.metrox.viewmodel.metroViewModel -import io.github.kdroidfilter.nucleus.updater.UpdaterConfig +import dev.nucleusframework.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 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/main.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt index 42dd1b11..7a920673 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt @@ -5,29 +5,29 @@ package io.github.kdroidfilter.seforimapp import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.LocalTextContextMenu import androidx.compose.runtime.* 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.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 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 io.github.kdroidfilter.platformtools.getAppVersion import io.github.kdroidfilter.seforim.tabs.TabType import io.github.kdroidfilter.seforim.tabs.TabsDestination @@ -46,6 +46,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 @@ -55,6 +56,7 @@ import io.github.kdroidfilter.seforimapp.framework.di.LocalAppGraph import io.github.kdroidfilter.seforimapp.framework.platform.PlatformInfo import io.github.kdroidfilter.seforimapp.framework.session.SessionManager import io.github.kdroidfilter.seforimapp.framework.update.AppUpdateChecker +import io.github.kdroidfilter.seforimapp.framework.update.DbDeltaRecoveryBootstrap import io.github.kdroidfilter.seforimapp.logger.infoln import io.github.kdroidfilter.seforimapp.logger.isDevEnv import io.github.kdroidfilter.seforimlibrary.core.text.HebrewTextUtils @@ -70,9 +72,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 +128,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 +138,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 +147,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 +158,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() } @@ -296,10 +265,23 @@ fun main(args: Array) { val themeDefinition = ThemeUtils.buildThemeDefinition() val componentStyling = ThemeUtils.buildComponentStyling() + // Snapshot Compose's default TextContextMenu before IntUiTheme installs Jewel's + // override. Jewel 0.37 is compiled against Compose 1.10; under Compose 1.11 its + // TextContextMenu.Area() crashes with NoSuchMethodError on + // TextManager.getCut() (return type changed Function0 → TextContextMenu.Action). + // Restoring the captured Default below IntUiTheme keeps the rest of Jewel's styling + // untouched and only neutralises the broken Selection menu provider. + @OptIn(ExperimentalFoundationApi::class) + val defaultTextContextMenu = LocalTextContextMenu.current + IntUiTheme( theme = themeDefinition, styling = componentStyling, ) { + @OptIn(ExperimentalFoundationApi::class) + androidx.compose.runtime.CompositionLocalProvider( + LocalTextContextMenu provides defaultTextContextMenu, + ) { if (showOnboarding) { OnBoardingWindow() } else if (showDatabaseUpdate) { @@ -315,6 +297,16 @@ fun main(args: Array) { val settingsWindowViewModel: SettingsWindowViewModel = metroViewModel(viewModelStoreOwner = windowViewModelOwner) + // Settings dialog is hosted here so JewelDecoratedDialog can resolve its + // NucleusApplicationScope receiver (Nucleus 2.0 backend-agnostic variant). + val settingsWindowState by settingsWindowViewModel.state.collectAsState() + if (settingsWindowState.isVisible) { + SettingsWindow( + onClose = { settingsWindowViewModel.onEvent(SettingsWindowEvents.OnClose) }, + initialDestination = settingsWindowState.initialDestination, + ) + } + if (PlatformInfo.isMacOS) { // Native macOS menu bar (no-op on other platforms) AppNativeMenuBar( @@ -396,6 +388,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 @@ -417,7 +410,7 @@ fun main(args: Array) { tabsVm.onEvent(TabsEvents.OnSelect(newIndex)) } true - } else if ((keyEvent.isAltPressed && keyEvent.key == Key.Home) || + } else if ((keyEvent.isAltPressed && keyEvent.key == Key.MoveHome) || (keyEvent.isMetaPressed && keyEvent.isShiftPressed && keyEvent.key == Key.H) ) { val currentTabId = currentTabs.getOrNull(currentIndex)?.destination?.tabId @@ -458,9 +451,6 @@ fun main(args: Array) { LocalWindowViewModelStoreOwner provides windowViewModelOwner, LocalViewModelStoreOwner provides windowViewModelOwner, ) { - LaunchedEffect(Unit) { - window.minimumSize = Dimension(600, 300) - } MainTitleBar() LaunchedEffect(state.isMinimized) { if (state.isMinimized) { @@ -554,7 +544,7 @@ fun main(args: Array) { true } // Alt + Home (Windows) or Cmd + Shift + H (macOS) => go Home on current tab - (keyEvent.isAltPressed && keyEvent.key == Key.Home) || + (keyEvent.isAltPressed && keyEvent.key == Key.MoveHome) || ( keyEvent.isMetaPressed && keyEvent.isShiftPressed && @@ -614,6 +604,7 @@ fun main(args: Array) { } } } + } } } } 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..7b2edb8b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,10 +7,10 @@ filekitCore = "0.13.0" hebrewNumerals = "0.2.6" jsoup = "1.22.2" jvmToolchain = "25" -nucleus = "1.14.2" +nucleus = "2.0.0-alpha-202605151436" koalaplotCore = "0.11.0" kotlin = "2.3.21" -compose = "1.10.3" +compose = "1.11.0" agp = "9.1.0" androidx-activityCompose = "1.13.0" androidx-uiTest = "1.10.6" @@ -24,7 +24,7 @@ multiplatformSettings = "1.3.0" kotlinx-datetime = "0.7.1" buildConfig = "6.0.9" materialKolor = "4.1.1" -jewel = "0.35.0-261.23567.138" +jewel = "0.37.0-262.4852.74" paging = "3.4.2" platformtools = "0.7.5" kotlinx-collections-immutable = "0.4.0" @@ -47,7 +47,7 @@ kotlinx-coroutines-test = "1.10.2" sentry = "6.5.0" sentrySdk = "8.40.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,7 +179,7 @@ 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" } +nucleus = { id = "dev.nucleusframework", version.ref = "nucleus" } stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.7.3" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } detekt = { id = "dev.detekt", version.ref = "detekt" } 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 From 423e4f520342f608940f67631286b780e1b55fe2 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 17 May 2026 02:06:33 +0300 Subject: [PATCH 02/16] chore: bump nucleus 2.0.0-alpha to 202605151813, update SeforimLibrary --- SeforimLibrary | 2 +- gradle/libs.versions.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SeforimLibrary b/SeforimLibrary index 7dfb259e..60f142f0 160000 --- a/SeforimLibrary +++ b/SeforimLibrary @@ -1 +1 @@ -Subproject commit 7dfb259e9a551715620de81fb1d786e3617c36bd +Subproject commit 60f142f0d495052d84536dfaf391f36a6e54901f diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b2edb8b..172fb616 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ filekitCore = "0.13.0" hebrewNumerals = "0.2.6" jsoup = "1.22.2" jvmToolchain = "25" -nucleus = "2.0.0-alpha-202605151436" +nucleus = "2.0.0-alpha-202605151813" koalaplotCore = "0.11.0" kotlin = "2.3.21" compose = "1.11.0" From 5ca61fcfab67b6c600fa8327bda326c03952fc50 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Wed, 20 May 2026 16:40:00 +0300 Subject: [PATCH 03/16] fix: preserve source order in UI and update SeforimLibrary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove sortedBy calls in source panels that were overriding SQL ORDER BY ranking. Ensure sources appear in declared-base priority order: Tanakh → Mishnah → Bavli → Yerushalmi. Dedup source lines in TOC heading selections. Update to SeforimLibrary with improved source ranking and density-based link chaining. --- .../bookcontent/views/LineTargumView.kt | 20 ++++++++---- .../usecases/CommentariesUseCase.kt | 31 ++++++++++++------- .../io/github/kdroidfilter/seforimapp/main.kt | 20 ++++++------ SeforimLibrary | 2 +- .../pagination/MultiLineLinksPagingSource.kt | 4 +++ 5 files changed, 49 insertions(+), 28 deletions(-) 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 ca4778e9..2b0f1b77 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 @@ -306,21 +306,30 @@ 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() } } 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 7a920673..8f302964 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt @@ -297,16 +297,6 @@ fun main(args: Array) { val settingsWindowViewModel: SettingsWindowViewModel = metroViewModel(viewModelStoreOwner = windowViewModelOwner) - // Settings dialog is hosted here so JewelDecoratedDialog can resolve its - // NucleusApplicationScope receiver (Nucleus 2.0 backend-agnostic variant). - val settingsWindowState by settingsWindowViewModel.state.collectAsState() - if (settingsWindowState.isVisible) { - SettingsWindow( - onClose = { settingsWindowViewModel.onEvent(SettingsWindowEvents.OnClose) }, - initialDestination = settingsWindowState.initialDestination, - ) - } - if (PlatformInfo.isMacOS) { // Native macOS menu bar (no-op on other platforms) AppNativeMenuBar( @@ -451,6 +441,16 @@ fun main(args: Array) { LocalWindowViewModelStoreOwner provides windowViewModelOwner, LocalViewModelStoreOwner provides windowViewModelOwner, ) { + // 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) { if (state.isMinimized) { diff --git a/SeforimLibrary b/SeforimLibrary index 60f142f0..521838d6 160000 --- a/SeforimLibrary +++ b/SeforimLibrary @@ -1 +1 @@ -Subproject commit 60f142f0d495052d84536dfaf391f36a6e54901f +Subproject commit 521838d64b1eaf0933f352292ed9755ccf386a90 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 From 161d7889400d6535421502cdbbf0694b7c32edf3 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 24 May 2026 22:35:57 +0300 Subject: [PATCH 04/16] refactor: move catalog data from code generation to runtime access Replace large pre-computed maps in cataloggen with hybrid approach: - Slim down generated CatalogPresets.kt from 41 KB to 6 KB (IDs and dropdown specs only) - Introduce CatalogAccess for lazy-loaded catalog access with display transformations - Apply Talmud prefixing for Bavli/Yerushalmi, book filtering, and ancestor label stripping - Embed TocQuickLink data directly in TocQuickLinksSpec instead of separate maps - Reduce memory footprint and eliminate drift risk from stable DB IDs Benefits: JAR -35 KB, -1-3 MB RAM, centralized display logic, fully testable transformations. Performance: ~30-80ms one-shot build on first catalog access. --- .../seforimapp/catalog/CatalogPresets.kt | 245 +++++ .../seforimapp/catalog/PrecomputedCatalog.kt | 947 ------------------ .../seforimapp/core/catalog/CatalogAccess.kt | 175 ++++ .../components/CatalogDropdown.kt | 44 +- .../components/TocJumpDropdown.kt | 46 +- .../bookcontent/components/CatalogRow.kt | 19 +- .../seforimapp/framework/di/AppGraph.kt | 2 + .../framework/di/modules/AppCoreBindings.kt | 6 + .../seforimapp/catalog/CatalogPresetsTest.kt | 155 +++ .../catalog/PrecomputedCatalogTest.kt | 180 ---- .../core/catalog/CatalogAccessTest.kt | 128 +++ .../seforimapp/cataloggen/Generate.kt | 287 +----- 12 files changed, 807 insertions(+), 1427 deletions(-) create mode 100644 SeforimApp/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresets.kt delete mode 100644 SeforimApp/src/commonMain/kotlin/io/github/kdroidfilter/seforimapp/catalog/PrecomputedCatalog.kt create mode 100644 SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccess.kt create mode 100644 SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/catalog/CatalogPresetsTest.kt delete mode 100644 SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/catalog/PrecomputedCatalogTest.kt create mode 100644 SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/core/catalog/CatalogAccessTest.kt 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/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/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/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/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/modules/AppCoreBindings.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/modules/AppCoreBindings.kt index e8569c81..aa36f0d1 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() 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/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 -} From a1a766af7fa80117d1fef3ce589bddb0c48f81c4 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 24 May 2026 23:14:33 +0300 Subject: [PATCH 05/16] revert: drop Compose 1.11, stabilize on Compose 1.10.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert Compose 1.11.0 → 1.10.3 (restores compatibility with Jewel 0.35 API) - Remove LocalTextContextMenu workaround added for Jewel 0.37/Compose 1.11 NoSuchMethodError - Restore Key.Home for Alt+Home shortcut (reverts Key.MoveHome change) - Revert ContextMenuItem constructor to pre-1.11 signature (label, action) - Ship systemTheme.svg locally since IntelliJ icons 262 dropped it; use PathIconKey - Keep Nucleus 2.0, Jewel 0.37, minimumSize on JewelDecoratedWindow (Nucleus API) --- .../components/TitleBarActionsButtonsView.kt | 8 +++++++- .../features/bookcontent/BookContentScreen.kt | 4 ++-- .../io/github/kdroidfilter/seforimapp/main.kt | 19 ++----------------- .../jvmMain/resources/icons/system_theme.svg | 6 ++++++ .../resources/icons/system_theme_dark.svg | 6 ++++++ gradle/libs.versions.toml | 4 ++-- 6 files changed, 25 insertions(+), 22 deletions(-) create mode 100644 SeforimApp/src/jvmMain/resources/icons/system_theme.svg create mode 100644 SeforimApp/src/jvmMain/resources/icons/system_theme_dark.svg 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 401beb04..e1b00a71 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 @@ -13,9 +13,15 @@ import io.github.kdroidfilter.seforimapp.features.settings.SettingsWindowViewMod 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 @@ -128,7 +134,7 @@ fun TitleBarActionsButtonsView() { when (theme) { IntUiThemes.Light -> AllIconsKeys.MeetNewUi.LightTheme IntUiThemes.Dark -> AllIconsKeys.MeetNewUi.DarkTheme - IntUiThemes.System -> AllIconsKeys.General.Settings + IntUiThemes.System -> SystemTheme }, contentDescription = iconDescription, onClick = { diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentScreen.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentScreen.kt index cb3228a5..f9954c2a 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentScreen.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/BookContentScreen.kt @@ -89,10 +89,10 @@ private val TextSearchContextMenuIconKey = PathIconKey("icons/lucide_text_search private class ContextMenuItemOptionWithKeybinding( val icon: org.jetbrains.jewel.ui.icon.IconKey? = null, val keybinding: Set? = null, - enabled: Boolean = true, + val enabled: Boolean = true, label: String, action: () -> Unit, -) : ContextMenuItem(label, enabled, action) +) : ContextMenuItem(label, action) @OptIn(InternalJewelApi::class) private object BookContentContextMenuRepresentationWithKeybindings : ComposeContextMenuRepresentation { 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 8f302964..accac523 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt @@ -5,7 +5,6 @@ package io.github.kdroidfilter.seforimapp import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.text.LocalTextContextMenu import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -265,23 +264,10 @@ fun main(args: Array) { val themeDefinition = ThemeUtils.buildThemeDefinition() val componentStyling = ThemeUtils.buildComponentStyling() - // Snapshot Compose's default TextContextMenu before IntUiTheme installs Jewel's - // override. Jewel 0.37 is compiled against Compose 1.10; under Compose 1.11 its - // TextContextMenu.Area() crashes with NoSuchMethodError on - // TextManager.getCut() (return type changed Function0 → TextContextMenu.Action). - // Restoring the captured Default below IntUiTheme keeps the rest of Jewel's styling - // untouched and only neutralises the broken Selection menu provider. - @OptIn(ExperimentalFoundationApi::class) - val defaultTextContextMenu = LocalTextContextMenu.current - IntUiTheme( theme = themeDefinition, styling = componentStyling, ) { - @OptIn(ExperimentalFoundationApi::class) - androidx.compose.runtime.CompositionLocalProvider( - LocalTextContextMenu provides defaultTextContextMenu, - ) { if (showOnboarding) { OnBoardingWindow() } else if (showDatabaseUpdate) { @@ -400,7 +386,7 @@ fun main(args: Array) { tabsVm.onEvent(TabsEvents.OnSelect(newIndex)) } true - } else if ((keyEvent.isAltPressed && keyEvent.key == Key.MoveHome) || + } else if ((keyEvent.isAltPressed && keyEvent.key == Key.Home) || (keyEvent.isMetaPressed && keyEvent.isShiftPressed && keyEvent.key == Key.H) ) { val currentTabId = currentTabs.getOrNull(currentIndex)?.destination?.tabId @@ -544,7 +530,7 @@ fun main(args: Array) { true } // Alt + Home (Windows) or Cmd + Shift + H (macOS) => go Home on current tab - (keyEvent.isAltPressed && keyEvent.key == Key.MoveHome) || + (keyEvent.isAltPressed && keyEvent.key == Key.Home) || ( keyEvent.isMetaPressed && keyEvent.isShiftPressed && @@ -604,7 +590,6 @@ fun main(args: Array) { } } } - } } } } 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 172fb616..1caa2afa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,10 +7,10 @@ filekitCore = "0.13.0" hebrewNumerals = "0.2.6" jsoup = "1.22.2" jvmToolchain = "25" -nucleus = "2.0.0-alpha-202605151813" +nucleus = "2.0.0-alpha-202605241801" koalaplotCore = "0.11.0" kotlin = "2.3.21" -compose = "1.11.0" +compose = "1.10.3" agp = "9.1.0" androidx-activityCompose = "1.13.0" androidx-uiTest = "1.10.6" From 0ff26865eeb01356be87e4472f68d42b637f8c0c Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 25 May 2026 06:35:21 +0300 Subject: [PATCH 06/16] chore: update dependencies and SeforimLibrary --- gradle/libs.versions.toml | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1caa2afa..b36b000b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,49 +3,49 @@ 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 = "2.0.0-alpha-202605241801" -koalaplotCore = "0.11.0" +nucleus = "2.0.0-alpha-202605242012" +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.37.0-262.4852.74" paging = "3.4.2" 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 = "262.4852.74" jbrApi = "1.10.1" @@ -180,7 +180,7 @@ android-library = { id = "com.android.kotlin.multiplatform.library", version.ref caupain = { id = "com.deezer.caupain", version = "1.9.1"} metro = { id = "dev.zacsweers.metro", version.ref = "metro" } nucleus = { id = "dev.nucleusframework", version.ref = "nucleus" } -stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.7.3" } +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" } From e414de208e8218101b8a0597f2a5e3ab03a7769c Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 25 May 2026 06:43:17 +0300 Subject: [PATCH 07/16] fix: use function syntax for Metro provider types --- .../seforimapp/framework/di/AppMetroViewModelFactory.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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() From ea724f99c4e874d3d5661a74297a30179beb260d Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 25 May 2026 06:44:05 +0300 Subject: [PATCH 08/16] chore: bump paging version to 3.5.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b36b000b..de21db34 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,7 +25,7 @@ kotlinx-datetime = "0.8.0" buildConfig = "6.0.9" materialKolor = "4.1.1" jewel = "0.37.0-262.4852.74" -paging = "3.4.2" +paging = "3.5.0" platformtools = "0.7.5" kotlinx-collections-immutable = "0.4.0" kotlinx-coroutines = "1.11.0" From b3449a1dbc04c4d889a26aabb64698732ecc032d Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 25 May 2026 16:00:21 +0300 Subject: [PATCH 09/16] fix: revert Cursor import from java.awt back to org.jetbrains.skiko The java.awt.Cursor was a temporary constraint during the Compose 1.11 migration attempt. Revert to skiko.Cursor which is the proper import. --- .../bookcontent/ui/panels/bookcontent/views/HomeView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/HomeView.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/HomeView.kt index c438fe40..98a6ef6b 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/HomeView.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/ui/panels/bookcontent/views/HomeView.kt @@ -74,7 +74,7 @@ import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* import org.jetbrains.jewel.ui.icons.AllIconsKeys import org.jetbrains.jewel.ui.theme.menuStyle -import java.awt.Cursor +import org.jetbrains.skiko.Cursor import seforimapp.seforimapp.generated.resources.* import kotlin.math.roundToInt import kotlin.time.Duration.Companion.milliseconds From 3d4822f350b6203ebf361aafbec7bef17730c72f Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 25 May 2026 17:48:37 +0300 Subject: [PATCH 10/16] fix: gate Flow.sample ticker behind scroll state to avoid idle redraws The scroll position saving logic used Flow.sample(200) to periodically persist position during active scrolling. However, fixedPeriodTicker (used internally by sample) ticks continuously even when the flow doesn't emit, causing 5 Hz wakeups of the FlushCoroutineDispatcher on every tab's BookContentView composable. Each wakeup triggered a frame redraw, leading to 10-15 redraws/sec at idle (confirmed by JFR profiling). Gate the sample ticker behind isScrollInProgress so the ticker only exists during active scroll, terminating with emptyFlow when idle. This eliminates the continuous frame invalidation. Also: - Add .catch {} to all flow.collect chains to prevent unhandled exceptions from killing LaunchedEffect scopes - Auto-format via ktlint --- SeforimApp/build.gradle.kts | 2 +- .../components/TitleBarActionsButtonsView.kt | 1 + .../bookcontent/views/BookContentView.kt | 54 ++- .../usecases/CommentariesUseCase.kt | 327 +++++++++++++++--- .../settings/dbupdate/DbDeltaUpdateSection.kt | 11 +- .../dbupdate/DbDeltaUpdateViewModel.kt | 82 +++-- .../settings/ui/GeneralSettingsScreen.kt | 5 +- .../framework/di/modules/AppCoreBindings.kt | 5 +- .../update/DbDeltaRecoveryBootstrap.kt | 9 +- .../framework/update/DbDeltaUpdateService.kt | 162 ++++----- .../io/github/kdroidfilter/seforimapp/main.kt | 7 +- .../CommentatorGroupingIntegrationTest.kt | 233 +++++++++++++ .../dbupdate/DbDeltaUpdateViewModelTest.kt | 194 ++++++----- gradle/libs.versions.toml | 2 +- 14 files changed, 807 insertions(+), 287 deletions(-) create mode 100644 SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentatorGroupingIntegrationTest.kt diff --git a/SeforimApp/build.gradle.kts b/SeforimApp/build.gradle.kts index ce3489bc..5fd55870 100644 --- a/SeforimApp/build.gradle.kts +++ b/SeforimApp/build.gradle.kts @@ -1,7 +1,7 @@ -import io.github.kdroidfilter.buildsrc.Versioning 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 org.jetbrains.compose.reload.gradle.ComposeHotRun plugins { 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 e1b00a71..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 @@ -20,6 +20,7 @@ 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 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..05a549fe 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 @@ -67,6 +67,7 @@ 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.delay import kotlinx.coroutines.flow.* @@ -75,8 +76,9 @@ 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.time.Duration.Companion.milliseconds -@OptIn(FlowPreview::class) +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @Suppress( "ComposeUnstableCollections", "ParamsComparedByRef", @@ -187,6 +189,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 +214,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 +238,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 +272,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 +284,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 +314,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 +329,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 } } } @@ -407,10 +417,21 @@ fun BookContentView( } // 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 +440,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 +462,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) { 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 2b0f1b77..b6e33c24 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 @@ -317,17 +319,22 @@ class CommentariesUseCase( suspend fun getAvailableSourcesForLines(lineIds: List): Map { if (lineIds.isEmpty()) return emptyMap() return runSuspendCatching { - val selectedBook = stateManager.state.first().navigation.selectedBook + val selectedBook = + stateManager.state + .first() + .navigation.selectedBook if (selectedBook?.hasSourceConnection != true) return@runSuspendCatching emptyMap() - val allBaseIds = lineIds - .flatMap { resolveBaseLineIds(it) } - .distinct() + 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 } + val links = + repository + .getCommentarySummariesForLines(allBaseIds, includeSources = true) + .filter { it.link.connectionType == ConnectionType.SOURCE } buildSourceMap(links, selectedBook.title.trim()) }.getOrElse { emptyMap() } @@ -379,13 +386,26 @@ 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 "" + + // Walk from leaf to root, applying the most specific bucket first. + for ((idx, category) in chain.withIndex()) { currentCoroutineContext().ensureActive() - val category = loadCategory(currentId) ?: break val title = category.title - // Prefer high-level "commentaries on ..." buckets + // 1. Explicit "X על Y" buckets keep their full label (most informative). if ( title.contains("על התנ״ך") || title.contains("על התלמוד") || @@ -397,40 +417,241 @@ 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 "חברותא" - } + // 2. Hard-coded multi-book families (chevruta, dictionaries, contemporary authors). + if (title == "ביאור חברותא" || title == "הערות על ביאור חברותא") return "חברותא" + if (title.contains("מילונים")) return title + if (title == "מחברי זמננו") return title - // Generic "מפרשים" bucket (e.g., for משנה תורה) + // 3. "מפרשים" → use the parent text to disambiguate ("מפרשים על משנה תורה"). if (title == "מפרשים") { - val parent = - category.parentId?.let { parentId -> - loadCategory(parentId) - } + val parent = chain.getOrNull(idx + 1) if (parent != null && parent.title.isNotBlank()) { return "מפרשים על ${parent.title}" } return title } - currentId = category.parentId + // 4. Bare "ראשונים" / "אחרונים" under a non-canonical parent + // (e.g. "אחרונים < מחשבת ישראל") becomes "אחרונים על מחשבת ישראל". + if (title == "ראשונים" || title == "אחרונים") { + val parent = chain.getOrNull(idx + 1) + if (parent != null && + parent.title.isNotBlank() && + parent.title != "תנ״ך" && + parent.title != "תלמוד" && + parent.title != "משנה" && + parent.title != "ש\"ס" && + parent.title != "ש״ס" + ) { + return "$title על ${parent.title}" + } + return title + } + } + + // 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 "חסידות" + } + + // 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, + ) - val baseCategory = loadCategory(book.categoryId) - return baseCategory?.title ?: "" + // 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( @@ -511,30 +732,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, ) @@ -542,7 +773,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( @@ -671,7 +905,10 @@ class CommentariesUseCase( suspend fun getAvailableSources(lineId: Long): Map = runSuspendCatching { - val selectedBook = stateManager.state.first().navigation.selectedBook + 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() 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 5f0bee60..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.zacsweers.metrox.viewmodel.metroViewModel import dev.nucleusframework.updater.UpdaterConfig +import dev.zacsweers.metrox.viewmodel.metroViewModel 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/modules/AppCoreBindings.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/di/modules/AppCoreBindings.kt index aa36f0d1..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 @@ -110,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/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 accac523..6883ed16 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt @@ -18,15 +18,15 @@ import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.kdroid.gematria.converter.toHebrewNumeral -import dev.zacsweers.metro.createGraph -import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory -import dev.zacsweers.metrox.viewmodel.metroViewModel 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.platformtools.getAppVersion import io.github.kdroidfilter.seforim.tabs.TabType import io.github.kdroidfilter.seforim.tabs.TabsDestination @@ -55,7 +55,6 @@ import io.github.kdroidfilter.seforimapp.framework.di.LocalAppGraph import io.github.kdroidfilter.seforimapp.framework.platform.PlatformInfo import io.github.kdroidfilter.seforimapp.framework.session.SessionManager import io.github.kdroidfilter.seforimapp.framework.update.AppUpdateChecker -import io.github.kdroidfilter.seforimapp.framework.update.DbDeltaRecoveryBootstrap import io.github.kdroidfilter.seforimapp.logger.infoln import io.github.kdroidfilter.seforimapp.logger.isDevEnv import io.github.kdroidfilter.seforimlibrary.core.text.HebrewTextUtils 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..2301e38e --- /dev/null +++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/features/bookcontent/usecases/CommentatorGroupingIntegrationTest.kt @@ -0,0 +1,233 @@ +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 — 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index de21db34..0b0a4fe6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ filekitCore = "0.14.1" hebrewNumerals = "0.2.6" jsoup = "1.22.2" jvmToolchain = "25" -nucleus = "2.0.0-alpha-202605242012" +nucleus = "2.0.0-alpha-202605251335" koalaplotCore = "0.11.2" kotlin = "2.3.21" compose = "1.10.3" From 114e932c22b7befb77068b8abb767ba3fdaacc51 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 25 May 2026 20:11:00 +0300 Subject: [PATCH 11/16] fix: improve commentator grouping resolution with 4-pass label logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pass 1: explicit corpus anchors (על התלמוד, על התנ״ך) have top priority - Pass 2: hard-coded families (חברותא, מילונים, מחברי זמננו) - Pass 3: מפרשים detection with upstream ראשונים resolution to corpus - Pass 4: bare ראשונים/אחרונים with corpus ancestor lookup and ה-prefixing - Add integration test verifying cross-corpus commentators excluded from Talmud - Fix: rif/rosh sub-commentaries now correctly roll up to ראשונים על התלמוד - Fix: ensure proper corpus labels (התלמוד, התנ״ך) instead of bare corpus names --- .../usecases/CommentariesUseCase.kt | 90 +++++++++++++++---- .../CommentatorGroupingIntegrationTest.kt | 32 +++++++ 2 files changed, 103 insertions(+), 19 deletions(-) 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 b6e33c24..9be178e4 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 @@ -400,12 +400,15 @@ class CommentariesUseCase( } if (chain.isEmpty()) return "" - // Walk from leaf to root, applying the most specific bucket first. - for ((idx, category) in chain.withIndex()) { + // 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 title = category.title - - // 1. Explicit "X על Y" buckets keep their full label (most informative). if ( title.contains("על התנ״ך") || title.contains("על התלמוד") || @@ -416,34 +419,83 @@ class CommentariesUseCase( ) { return title } + } - // 2. Hard-coded multi-book families (chevruta, dictionaries, contemporary authors). + // 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 + } - // 3. "מפרשים" → use the parent text to disambiguate ("מפרשים על משנה תורה"). - if (title == "מפרשים") { + // 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 title + return "מפרשים" } + } - // 4. Bare "ראשונים" / "אחרונים" under a non-canonical parent - // (e.g. "אחרונים < מחשבת ישראל") becomes "אחרונים על מחשבת ישראל". + // 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() && - parent.title != "תנ״ך" && - parent.title != "תלמוד" && - parent.title != "משנה" && - parent.title != "ש\"ס" && - parent.title != "ש״ס" - ) { - return "$title על ${parent.title}" + if (parent != null && parent.title.isNotBlank()) { + val parentLabel = when (parent.title) { + "תנ״ך", "תנך" -> "התנ״ך" + "תלמוד" -> "התלמוד" + "משנה" -> "המשנה" + "ש\"ס", "ש״ס", "שס" -> "הש\"ס" + else -> parent.title + } + return "$title על $parentLabel" } return title } 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 index 2301e38e..62a22b5f 100644 --- 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 @@ -203,6 +203,38 @@ class CommentatorGroupingIntegrationTest { } } + @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 { From 8bb71a6ce086e851621b7b95dd8cba94270e2fae Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 25 May 2026 20:16:45 +0300 Subject: [PATCH 12/16] chore: update SeforimLibrary ref to cross-corpus filtering implementation --- SeforimLibrary | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SeforimLibrary b/SeforimLibrary index 521838d6..3dcb603e 160000 --- a/SeforimLibrary +++ b/SeforimLibrary @@ -1 +1 @@ -Subproject commit 521838d64b1eaf0933f352292ed9755ccf386a90 +Subproject commit 3dcb603ee75b8ad73a34a41af9372d66ec64d674 From a5298f0199218a667b425e4798b7c165024c3349 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 25 May 2026 20:24:52 +0300 Subject: [PATCH 13/16] chore: update SeforimLibrary submodule refs --- SeforimLibrary | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SeforimLibrary b/SeforimLibrary index 3dcb603e..8ddabbd9 160000 --- a/SeforimLibrary +++ b/SeforimLibrary @@ -1 +1 @@ -Subproject commit 3dcb603ee75b8ad73a34a41af9372d66ec64d674 +Subproject commit 8ddabbd99cda4e96898bf1feca67ee955e99b68d From 4c7e712cdca731baa551cdab622954e99ca5f9f0 Mon Sep 17 00:00:00 2001 From: "Elie G." Date: Tue, 26 May 2026 06:37:43 +0300 Subject: [PATCH 14/16] update(nucleus): upgrade to latest alpha and refactor title bars --- .../usecases/CommentariesUseCase.kt | 4 +-- .../database/update/DatabaseUpdateWindow.kt | 32 ++++++++---------- .../features/onboarding/OnBoardingWindow.kt | 33 ++++++++----------- .../io/github/kdroidfilter/seforimapp/main.kt | 2 +- gradle/libs.versions.toml | 2 +- 5 files changed, 32 insertions(+), 41 deletions(-) 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 9be178e4..ded1a75a 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 @@ -967,7 +967,7 @@ class CommentariesUseCase( val baseIds = resolveBaseLineIds(lineId) val links = repository - .getCommentarySummariesForLines(baseIds, includeSources = true) + .getCommentarySummariesForLines(baseIds) .filter { it.link.connectionType == ConnectionType.SOURCE } val currentBookTitle = selectedBook.title.trim() @@ -996,7 +996,7 @@ class CommentariesUseCase( // mirror query on hot navigation paths. val includeSources = selectedBook?.hasSourceConnection == true - val allConnections = repository.getCommentarySummariesForLines(allBaseIds, includeSources = includeSources) + val allConnections = repository.getCommentarySummariesForLines(allBaseIds,) if (allConnections.isEmpty()) return distinctIds.associateWith { LineConnectionsSnapshot() } val connectionsBySource = allConnections.groupBy { it.link.sourceLineId } 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 6d538e4d..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 @@ -10,10 +10,13 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.navigation.compose.rememberNavController 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.jewel.JewelTitleBar 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 @@ -53,8 +56,6 @@ fun NucleusApplicationScope.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 NucleusApplicationScope.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 NucleusApplicationScope.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 NucleusApplicationScope.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 c0e833ad..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 @@ -10,10 +10,13 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.navigation.compose.rememberNavController 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.jewel.JewelTitleBar 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 @@ -50,8 +53,6 @@ fun NucleusApplicationScope.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 NucleusApplicationScope.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 NucleusApplicationScope.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 NucleusApplicationScope.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/main.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt index 6883ed16..bdf91beb 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/main.kt @@ -259,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() @@ -422,7 +423,6 @@ fun main(args: Array) { }, ) { CompositionLocalProvider( - LocalLayoutDirection provides LayoutDirection.Rtl, LocalWindowViewModelStoreOwner provides windowViewModelOwner, LocalViewModelStoreOwner provides windowViewModelOwner, ) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0b0a4fe6..6f623c39 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ filekitCore = "0.14.1" hebrewNumerals = "0.2.6" jsoup = "1.22.2" jvmToolchain = "25" -nucleus = "2.0.0-alpha-202605251335" +nucleus = "2.0.0-alpha-202605251453" koalaplotCore = "0.11.2" kotlin = "2.3.21" compose = "1.10.3" From bc17c8eaef7bd894eb60df4fb30948a92b54fdff Mon Sep 17 00:00:00 2001 From: "Elie G." Date: Tue, 26 May 2026 13:25:17 +0300 Subject: [PATCH 15/16] chore: bump nucleus to 2.0.0-alpha-202605260518 in libs.versions.toml --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6f623c39..9bbe74ae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ filekitCore = "0.14.1" hebrewNumerals = "0.2.6" jsoup = "1.22.2" jvmToolchain = "25" -nucleus = "2.0.0-alpha-202605251453" +nucleus = "2.0.0-alpha-202605260518" koalaplotCore = "0.11.2" kotlin = "2.3.21" compose = "1.10.3" From aaa2c34f664469adea0a51f43b9a808d9dd6999c Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Thu, 28 May 2026 14:02:44 +0300 Subject: [PATCH 16/16] chore: bump nucleus version to 2.0.0-alpha-202605272130 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9bbe74ae..d7be71a4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ filekitCore = "0.14.1" hebrewNumerals = "0.2.6" jsoup = "1.22.2" jvmToolchain = "25" -nucleus = "2.0.0-alpha-202605260518" +nucleus = "2.0.0-alpha-202605272130" koalaplotCore = "0.11.2" kotlin = "2.3.21" compose = "1.10.3"