diff --git a/SeforimMagicIndexer b/SeforimMagicIndexer index 34434570..147b3a7b 160000 --- a/SeforimMagicIndexer +++ b/SeforimMagicIndexer @@ -1 +1 @@ -Subproject commit 344345705e36ca5a175b7408eef0ecdb071423c8 +Subproject commit 147b3a7b2fdabd579e3b0cf0adc6733a3be064eb diff --git a/build.gradle.kts b/build.gradle.kts index b01c0b05..3344fbea 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,6 +15,7 @@ tasks.register("generateSeforimDb") { dependsOn(":otzariasqlite:appendOtzaria") dependsOn(":otzariasqlite:generateHavroutaLinks") dependsOn(":sefariasqlite:renameCategories") + dependsOn(":sefariasqlite:seedGenerations") dependsOn(":catalog:buildCatalog") dependsOn(":searchindex:buildLuceneIndexDefault") dependsOn(":packaging:writeReleaseInfo") @@ -32,6 +33,7 @@ project(":generator-common").tasks.matching { it.name == "stampSchemaVersion" }. mustRunAfter(":packaging:writeReleaseInfo") mustRunAfter(":catalog:buildCatalog") mustRunAfter(":searchindex:buildLuceneIndexDefault") + mustRunAfter(":sefariasqlite:seedGenerations") } // Ensure ordering inside the pipeline task graph @@ -53,8 +55,16 @@ project(":sefariasqlite").tasks.matching { it.name == "renameCategories" }.confi project(":otzariasqlite").tasks.matching { it.name == "generateHavroutaLinks" }.configureEach { mustRunAfter(":otzariasqlite:appendOtzaria") } +// seedGenerations runs after all book-writing stages so it can link both +// Sefaria- and Otzaria-sourced books in a single pass. +project(":sefariasqlite").tasks.matching { it.name == "seedGenerations" }.configureEach { + mustRunAfter(":otzariasqlite:appendOtzaria") + mustRunAfter(":otzariasqlite:generateHavroutaLinks") + mustRunAfter(":sefariasqlite:renameCategories") +} project(":catalog").tasks.matching { it.name == "buildCatalog" }.configureEach { mustRunAfter(":otzariasqlite:generateHavroutaLinks") + mustRunAfter(":sefariasqlite:seedGenerations") } project(":searchindex").tasks.matching { it.name == "buildLuceneIndexDefault" }.configureEach { mustRunAfter(":catalog:buildCatalog") @@ -105,6 +115,10 @@ tasks.register("publishRelease") { description = "generateSeforimDb + producePatchAndVerify (+ release_meta.json upsert)." dependsOn("generateSeforimDb") finalizedBy(":generator-common:producePatchAndVerify") + val prevReleaseDb = providers.gradleProperty("prevReleaseDb") + val buildStatePath = providers.systemProperty("buildStatePath") + .orElse(providers.environmentVariable("BUILD_STATE_PATH")) + .orElse(layout.buildDirectory.file("seforim.db.buildstate").map { it.asFile.absolutePath }) // Operator footgun guard: if -PprevReleaseDb is set we're producing a // delta against a previous release, which requires the IdAllocator to // be seeded from that release's build_state. Without it, the allocator @@ -112,15 +126,9 @@ tasks.register("publishRelease") { // would then emit a "delta" containing the entire corpus, useless as // an incremental patch. Fail fast with the path the operator forgot. doFirst { - val prev = project.findProperty("prevReleaseDb") as String? + val prev = prevReleaseDb.orNull if (prev != null) { - val explicit = System.getProperty("buildStatePath") - ?: System.getenv("BUILD_STATE_PATH") - val buildStateFile = if (explicit != null) { - file(explicit) - } else { - layout.buildDirectory.file("seforim.db.buildstate").get().asFile - } + val buildStateFile = java.io.File(buildStatePath.get()) check(buildStateFile.exists()) { "publishRelease: -PprevReleaseDb=$prev was set but " + "$buildStateFile is missing. Copy the previous release's " + @@ -137,16 +145,16 @@ project(":generator-common").tasks.matching { it.name == "producePatchAndVerify" // generateSeforimDb to exist in :generator-common. mustRunAfter(rootProject.tasks.named("generateSeforimDb")) // Map the umbrella task's -P props onto the CLI's gradle props. - val prev = project.findProperty("prevReleaseDb") as String? - val from = project.findProperty("fromVersion") as String? - val to = project.findProperty("toVersion") as String? + val prev = providers.gradleProperty("prevReleaseDb").orNull + val from = providers.gradleProperty("fromVersion").orNull + val to = providers.gradleProperty("toVersion").orNull if (prev != null && from != null && to != null) { val out = rootProject.layout.buildDirectory .file("patch-v${from}-v${to}.db").get().asFile.absolutePath val new = rootProject.layout.buildDirectory.file("seforim.db").get().asFile.absolutePath - this.extensions.extraProperties.set("prevDb", prev) - this.extensions.extraProperties.set("newDb", new) - this.extensions.extraProperties.set("out", out) + (this as JavaExec).systemProperty("prevDb", prev) + (this as JavaExec).systemProperty("newDb", new) + (this as JavaExec).systemProperty("out", out) } } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 3250fa53..3a2a25c0 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -12,11 +12,13 @@ kotlin { jvmToolchain(libs.versions.jvmToolchain.get().toInt()) androidLibrary { - namespace = "io.github.kdroidfilter.seforimlibrary" - compileSdk = 35 + namespace = "io.github.kdroidfilter.seforimlibrary.core" + compileSdk = 36 minSdk = 21 } jvm() + iosArm64() + iosSimulatorArm64() sourceSets { commonMain.dependencies { diff --git a/core/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/core/models/Book.kt b/core/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/core/models/Book.kt index 4bf7f107..71c0bdf1 100644 --- a/core/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/core/models/Book.kt +++ b/core/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/core/models/Book.kt @@ -17,6 +17,7 @@ import kotlinx.serialization.Serializable * @property pubPlaces The list of publication places for this book * @property pubDates The list of publication dates for this book * @property heShortDesc A short description of the book in Hebrew + * @property heDesc The full (long) description of the book in Hebrew * @property order The display order of the book within its category * @property totalLines The total number of lines in the book * @property hasAltStructures Indicates if the book has alternative TOC structures (e.g., Parasha) @@ -36,6 +37,7 @@ data class Book( val pubPlaces: List = emptyList(), val pubDates: List = emptyList(), val heShortDesc: String? = null, + val heDesc: String? = null, // Optional notes content: when a companion file named "הערות על " exists, // its content is attached here instead of being inserted as a separate book. val notesContent: String? = null, diff --git a/dao/build.gradle.kts b/dao/build.gradle.kts index efff270f..a96259e2 100644 --- a/dao/build.gradle.kts +++ b/dao/build.gradle.kts @@ -11,18 +11,31 @@ group = "io.github.kdroidfilter.seforimlibrary" kotlin { + // Re-apply the default hierarchy (ios/native/apple wiring); adding the manual androidJvmMain + // dependsOn edges below would otherwise disable it and orphan the iOS actuals. + applyDefaultHierarchyTemplate() + jvmToolchain(libs.versions.jvmToolchain.get().toInt()) androidLibrary { - namespace = "io.github.kdroidfilter.seforimlibrary" - compileSdk = 35 + namespace = "io.github.kdroidfilter.seforimlibrary.dao" + compileSdk = 36 minSdk = 21 } jvm() + iosArm64() + iosSimulatorArm64() sourceSets { + // Shared JVM+Android source set: Android is JVM-based, so the java.util.concurrent.ConcurrentHashMap + // (newConcurrentMap) and System.getenv (Env) actuals live here once. + val androidJvmMain by creating { dependsOn(commonMain.get()) } + jvmMain.get().dependsOn(androidJvmMain) + androidMain.get().dependsOn(androidJvmMain) + commonMain.dependencies { api(project(":core")) + implementation(libs.filekit.core) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.test) implementation(libs.kotlinx.serialization.json) diff --git a/dao/src/androidJvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/PlatformSupport.jvmAndroid.kt b/dao/src/androidJvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/PlatformSupport.jvmAndroid.kt new file mode 100644 index 00000000..fa56d0fd --- /dev/null +++ b/dao/src/androidJvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/PlatformSupport.jvmAndroid.kt @@ -0,0 +1,4 @@ +package io.github.kdroidfilter.seforimlibrary.dao.repository + +// Shared by the JVM and Android targets (both JVM-based): java.* is on the classpath. +internal actual fun <K, V> newConcurrentMap(): MutableMap<K, V> = java.util.concurrent.ConcurrentHashMap() diff --git a/dao/src/androidMain/kotlin/io/github/kdroidfilter/seforimlibrary/env/EnvAndroid.kt b/dao/src/androidJvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/env/EnvJvmAndroid.kt similarity index 99% rename from dao/src/androidMain/kotlin/io/github/kdroidfilter/seforimlibrary/env/EnvAndroid.kt rename to dao/src/androidJvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/env/EnvJvmAndroid.kt index f632acb0..68b92557 100644 --- a/dao/src/androidMain/kotlin/io/github/kdroidfilter/seforimlibrary/env/EnvAndroid.kt +++ b/dao/src/androidJvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/env/EnvJvmAndroid.kt @@ -1,4 +1,3 @@ package io.github.kdroidfilter.seforimlibrary.env actual fun getEnvironmentVariable(name: String): String? = System.getenv(name) - diff --git a/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/CatalogLoader.kt b/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/CatalogLoader.kt index cd9990a3..6d01441d 100644 --- a/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/CatalogLoader.kt +++ b/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/CatalogLoader.kt @@ -1,12 +1,11 @@ package io.github.kdroidfilter.seforimlibrary.dao import io.github.kdroidfilter.seforimlibrary.core.models.PrecomputedCatalog +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.exists +import io.github.vinceglb.filekit.readBytes import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.protobuf.ProtoBuf -import java.io.File -import java.nio.file.Path -import kotlin.io.path.exists -import kotlin.io.path.readBytes /** * Loader for the precomputed catalog tree. @@ -15,48 +14,26 @@ import kotlin.io.path.readBytes object CatalogLoader { /** - * Loads the precomputed catalog from a file next to the database. + * Loads the precomputed catalog from a `catalog.pb` file next to the database, using FileKit + * for cross-platform file access. * * @param dbPath Path to the database file. The catalog file should be in the same directory. * @return The precomputed catalog, or null if the file doesn't exist or can't be loaded */ @OptIn(ExperimentalSerializationApi::class) - fun loadCatalog(dbPath: String): PrecomputedCatalog? { + suspend fun loadCatalog(dbPath: String): PrecomputedCatalog? { return try { - val dbFile = File(dbPath) - val catalogFile = File(dbFile.parentFile, "catalog.pb") + // catalog.pb sits next to the db file; handle both '/' and '\' separators. + val sep = maxOf(dbPath.lastIndexOf('/'), dbPath.lastIndexOf('\\')) + val catalogPath = if (sep < 0) "catalog.pb" else dbPath.substring(0, sep + 1) + "catalog.pb" - if (!catalogFile.exists()) { - println("Catalog file not found: ${catalogFile.absolutePath}") - return null - } - - val bytes = catalogFile.readBytes() - val catalog = ProtoBuf.decodeFromByteArray(PrecomputedCatalog.serializer(), bytes) - - println("Loaded precomputed catalog: ${catalog.totalCategories} categories, ${catalog.totalBooks} books") - catalog - } catch (e: Exception) { - println("Failed to load precomputed catalog: ${e.message}") - e.printStackTrace() - null - } - } - - /** - * Loads the precomputed catalog from a specific Path. - * - * @param catalogPath Direct path to the catalog.pb file - * @return The precomputed catalog, or null if the file doesn't exist or can't be loaded - */ - fun loadCatalogFromPath(catalogPath: Path): PrecomputedCatalog? { - return try { - if (!catalogPath.exists()) { + val file = PlatformFile(catalogPath) + if (!file.exists()) { println("Catalog file not found: $catalogPath") return null } - val bytes = catalogPath.readBytes() + val bytes = file.readBytes() val catalog = ProtoBuf.decodeFromByteArray(PrecomputedCatalog.serializer(), bytes) println("Loaded precomputed catalog: ${catalog.totalCategories} categories, ${catalog.totalBooks} books") @@ -67,16 +44,4 @@ object CatalogLoader { null } } - - /** - * Checks if a catalog file exists next to the database. - * - * @param dbPath Path to the database file - * @return true if catalog.pb exists, false otherwise - */ - fun catalogExists(dbPath: String): Boolean { - val dbFile = File(dbPath) - val catalogFile = File(dbFile.parentFile, "catalog.pb") - return catalogFile.exists() - } } diff --git a/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/extensions/ModelExtensions.kt b/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/extensions/ModelExtensions.kt index 9afb43cc..2336504a 100644 --- a/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/extensions/ModelExtensions.kt +++ b/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/extensions/ModelExtensions.kt @@ -84,6 +84,7 @@ fun io.github.kdroidfilter.seforimlibrary.db.Book.toModel(json: Json, authors: L pubPlaces = pubPlaces, pubDates = pubDates, heShortDesc = heShortDesc, + heDesc = heDesc, notesContent = notesContent, order = orderIndex.toFloat(), totalLines = totalLines.toInt(), diff --git a/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/PlatformSupport.kt b/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/PlatformSupport.kt new file mode 100644 index 00000000..5eeba76b --- /dev/null +++ b/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/PlatformSupport.kt @@ -0,0 +1,10 @@ +package io.github.kdroidfilter.seforimlibrary.dao.repository + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +/** Dispatcher for blocking DB I/O. */ +internal val ioDispatcher: CoroutineDispatcher = Dispatchers.IO + +/** Thread-safe map (ConcurrentHashMap on JVM/Android, NSLock-backed map on iOS). */ +internal expect fun <K, V> newConcurrentMap(): MutableMap<K, V> diff --git a/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/SeforimRepository.kt b/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/SeforimRepository.kt index ae94ca89..59683e1a 100644 --- a/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/SeforimRepository.kt +++ b/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/SeforimRepository.kt @@ -25,7 +25,6 @@ import io.github.kdroidfilter.seforimlibrary.core.models.Topic import io.github.kdroidfilter.seforimlibrary.dao.extensions.toModel import io.github.kdroidfilter.seforimlibrary.db.SeforimDb import io.github.kdroidfilter.seforimlibrary.env.getEnvironmentVariable -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -44,14 +43,14 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L // Category rows are immutable at runtime, so a plain read-through cache avoids // paying a SQLite prepare + connection round-trip for every breadcrumb/tree walk. // Profiling (see JFR 2026-04-23) showed 70 `sqlite3_prepare` calls in 20 s all - // coming from `getCategory`. Both KMP targets (JVM + Android) are JVM-based so - // `ConcurrentHashMap` is safe to use directly from commonMain. - private val categoryCache = java.util.concurrent.ConcurrentHashMap<Long, Category>() + // coming from `getCategory`. newConcurrentMap is a ConcurrentHashMap on JVM/Android; + // the iOS actual is NSLock-backed. + private val categoryCache = newConcurrentMap<Long, Category>() // book.orderIndex is denormalized onto each link row at insert time (targetBookOrderIndex) // so the commentaries path can sort without JOINing book. Books are immutable once inserted // and exist before any link references them, so a read-through cache is safe during import. - private val bookOrderIndexCache = java.util.concurrent.ConcurrentHashMap<Long, Long>() + private val bookOrderIndexCache = newConcurrentMap<Long, Long>() private fun resolveBookOrderIndex(bookId: Long): Long = bookOrderIndexCache.getOrPut(bookId) { @@ -101,7 +100,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Maps a line to the TOC entry it belongs to. Upserts on conflict. */ - suspend fun upsertLineToc(lineId: Long, tocEntryId: Long) = withContext(Dispatchers.IO) { + suspend fun upsertLineToc(lineId: Long, tocEntryId: Long) = withContext(ioDispatcher) { database.lineTocQueriesQueries.upsert(lineId, tocEntryId) } @@ -113,7 +112,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L // --- ID helpers --- - suspend fun getMaxBookId(): Long = withContext(Dispatchers.IO) { + suspend fun getMaxBookId(): Long = withContext(ioDispatcher) { driver.executeQuery( identifier = null, sql = "SELECT COALESCE(MAX(id),0) FROM book", @@ -125,7 +124,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L ).value } - suspend fun getMaxLineId(): Long = withContext(Dispatchers.IO) { + suspend fun getMaxLineId(): Long = withContext(ioDispatcher) { driver.executeQuery( identifier = null, sql = "SELECT COALESCE(MAX(id),0) FROM line", @@ -137,7 +136,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L ).value } - suspend fun getMaxTocEntryId(): Long = withContext(Dispatchers.IO) { + suspend fun getMaxTocEntryId(): Long = withContext(ioDispatcher) { driver.executeQuery( identifier = null, sql = "SELECT COALESCE(MAX(id),0) FROM tocEntry", @@ -149,14 +148,14 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L ).value } - suspend fun setSynchronous(mode: String) = withContext(Dispatchers.IO) { + suspend fun setSynchronous(mode: String) = withContext(ioDispatcher) { driver.execute(null, "PRAGMA synchronous=$mode", 0) } suspend fun setSynchronousOff() = setSynchronous("OFF") suspend fun setSynchronousNormal() = setSynchronous("NORMAL") - suspend fun setJournalMode(mode: String) = withContext(Dispatchers.IO) { + suspend fun setJournalMode(mode: String) = withContext(ioDispatcher) { driver.execute(null, "PRAGMA journal_mode=$mode", 0) } suspend fun setJournalModeOff() = setJournalMode("OFF") @@ -166,7 +165,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Inserts multiple line_toc mappings in a single batch. * Assumes the database is in a fresh import state (no existing mappings for these lineIds). */ - suspend fun insertLineTocBatch(mappings: List<Pair<Long, Long>>) = withContext(Dispatchers.IO) { + suspend fun insertLineTocBatch(mappings: List<Pair<Long, Long>>) = withContext(ioDispatcher) { if (mappings.isEmpty()) return@withContext database.transaction { mappings.forEach { (lineId, tocEntryId) -> @@ -178,7 +177,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } } - suspend fun bulkUpsertLineToc(pairs: List<Pair<Long, Long>>) = withContext(Dispatchers.IO) { + suspend fun bulkUpsertLineToc(pairs: List<Pair<Long, Long>>) = withContext(ioDispatcher) { if (pairs.isEmpty()) return@withContext for ((lineId, tocEntryId) in pairs) { database.lineTocQueriesQueries.upsert(lineId, tocEntryId) @@ -188,14 +187,14 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Gets the tocEntryId associated with a line via the mapping table. */ - override suspend fun getTocEntryIdForLine(lineId: Long): Long? = withContext(Dispatchers.IO) { + override suspend fun getTocEntryIdForLine(lineId: Long): Long? = withContext(ioDispatcher) { database.lineTocQueriesQueries.selectTocEntryIdByLineId(lineId).executeAsOneOrNull() } /** * Gets the TocEntry model associated with a line via the mapping table. */ - suspend fun getTocEntryForLine(lineId: Long): TocEntry? = withContext(Dispatchers.IO) { + suspend fun getTocEntryForLine(lineId: Long): TocEntry? = withContext(ioDispatcher) { val tocId = database.lineTocQueriesQueries.selectTocEntryIdByLineId(lineId).executeAsOneOrNull() ?: return@withContext null database.tocQueriesQueries.selectTocById(tocId).executeAsOneOrNull()?.toModel() @@ -204,28 +203,28 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Returns all TOC entries for a book (flat list). */ - suspend fun getTocEntriesForBook(bookId: Long): List<TocEntry> = withContext(Dispatchers.IO) { + suspend fun getTocEntriesForBook(bookId: Long): List<TocEntry> = withContext(ioDispatcher) { database.tocQueriesQueries.selectByBookId(bookId).executeAsList().map { it.toModel() } } /** * Returns the TOC entry whose heading line is the given line id, or null if not a TOC heading. */ - override suspend fun getHeadingTocEntryByLineId(lineId: Long): TocEntry? = withContext(Dispatchers.IO) { + override suspend fun getHeadingTocEntryByLineId(lineId: Long): TocEntry? = withContext(ioDispatcher) { database.tocQueriesQueries.selectByLineId(lineId).executeAsOneOrNull()?.toModel() } /** * Returns all line ids that belong to the given TOC entry (section), ordered by lineIndex. */ - override suspend fun getLineIdsForTocEntry(tocEntryId: Long): List<Long> = withContext(Dispatchers.IO) { + override suspend fun getLineIdsForTocEntry(tocEntryId: Long): List<Long> = withContext(ioDispatcher) { database.lineTocQueriesQueries.selectLineIdsByTocEntryId(tocEntryId).executeAsList() } /** * Returns mappings (lineId -> tocEntryId) for a book ordered by line index. */ - suspend fun getLineTocMappingsForBook(bookId: Long): List<LineTocMapping> = withContext(Dispatchers.IO) { + suspend fun getLineTocMappingsForBook(bookId: Long): List<LineTocMapping> = withContext(ioDispatcher) { database.lineTocQueriesQueries.selectByBookId(bookId).executeAsList().map { // The generated type exposes columns as properties with same names LineTocMapping(lineId = it.lineId, tocEntryId = it.tocEntryId) @@ -237,7 +236,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * the latest TOC entry whose start line index is <= line's index. * This is useful for backfilling existing databases. */ - suspend fun rebuildLineTocForBook(bookId: Long) = withContext(Dispatchers.IO) { + suspend fun rebuildLineTocForBook(bookId: Long) = withContext(ioDispatcher) { // Clear existing mappings for the book database.lineTocQueriesQueries.deleteByBookId(bookId) @@ -272,23 +271,23 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L */ suspend fun getCategory(id: Long): Category? { categoryCache[id]?.let { return it } - val loaded = withContext(Dispatchers.IO) { + val loaded = withContext(ioDispatcher) { database.categoryQueriesQueries.selectById(id).executeAsOneOrNull()?.toModel() } ?: return null - return categoryCache.putIfAbsent(id, loaded) ?: loaded + return categoryCache.getOrPut(id) { loaded } } /** * Retrieves a category by its exact title. */ - suspend fun getCategoryByTitle(title: String): Category? = withContext(Dispatchers.IO) { + suspend fun getCategoryByTitle(title: String): Category? = withContext(ioDispatcher) { database.categoryQueriesQueries.selectByTitle(title).executeAsOneOrNull()?.toModel() } /** * Retrieves best-matching category by name, trying exact, normalized, then LIKE. */ - suspend fun findCategoryByTitlePreferExact(title: String): Category? = withContext(Dispatchers.IO) { + suspend fun findCategoryByTitlePreferExact(title: String): Category? = withContext(ioDispatcher) { database.categoryQueriesQueries.selectByTitle(title).executeAsOneOrNull()?.toModel() ?: database.categoryQueriesQueries.selectByTitleLike("%$title%").executeAsOneOrNull()?.toModel() } @@ -298,7 +297,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * * @return A list of root categories */ - suspend fun getRootCategories(): List<Category> = withContext(Dispatchers.IO) { + suspend fun getRootCategories(): List<Category> = withContext(ioDispatcher) { database.categoryQueriesQueries.selectRoot().executeAsList().map { it.toModel() } } @@ -308,7 +307,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * @param parentId The ID of the parent category * @return A list of child categories */ - suspend fun getCategoryChildren(parentId: Long): List<Category> = withContext(Dispatchers.IO) { + suspend fun getCategoryChildren(parentId: Long): List<Category> = withContext(ioDispatcher) { database.categoryQueriesQueries.selectByParentId(parentId).executeAsList().map { it.toModel() } } @@ -316,7 +315,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Returns all descendant category IDs (including the category itself) using the * category_closure table. This is a bulk way to scope by category without recursive calls. */ - suspend fun getDescendantCategoryIds(ancestorId: Long): List<Long> = withContext(Dispatchers.IO) { + suspend fun getDescendantCategoryIds(ancestorId: Long): List<Long> = withContext(ioDispatcher) { database.categoryClosureQueriesQueries.selectDescendants(ancestorId).executeAsList() } @@ -324,14 +323,14 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Returns all ancestor category IDs (including the category itself) using the * category_closure table. Used for pre-indexing ancestors in search indexes. */ - suspend fun getAncestorCategoryIds(categoryId: Long): List<Long> = withContext(Dispatchers.IO) { + suspend fun getAncestorCategoryIds(categoryId: Long): List<Long> = withContext(ioDispatcher) { database.categoryClosureQueriesQueries.selectAncestors(categoryId).executeAsList() } /** * Finds categories whose title matches the LIKE pattern. Use %term% for contains. */ - suspend fun findCategoriesByTitleLike(pattern: String, limit: Int = 20): List<Category> = withContext(Dispatchers.IO) { + suspend fun findCategoriesByTitleLike(pattern: String, limit: Int = 20): List<Category> = withContext(ioDispatcher) { database.categoryQueriesQueries.selectManyByTitleLike(pattern, limit.toLong()).executeAsList().map { it.toModel() } } @@ -347,7 +346,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L */ // Dans SeforimRepository.kt, remplacez la méthode insertCategory par celle-ci : - suspend fun insertCategory(category: Category): Long = withContext(Dispatchers.IO) { + suspend fun insertCategory(category: Category): Long = withContext(ioDispatcher) { logger.d { "🔧 Repository: Attempting to insert category '${category.title}'" } logger.d { "🔧 Category details: parentId=${category.parentId}, level=${category.level}" } @@ -430,7 +429,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Rebuilds the category_closure table from the current category tree. * Inserts self-pairs and ancestor-descendant pairs for fast descendant filtering. */ - suspend fun rebuildCategoryClosure() = withContext(Dispatchers.IO) { + suspend fun rebuildCategoryClosure() = withContext(ioDispatcher) { // Clear existing closure data database.categoryClosureQueriesQueries.clear() // Load all categories (id, parentId) @@ -463,7 +462,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * @param id The ID of the book to retrieve * @return The book if found, null otherwise */ - suspend fun getBook(id: Long): Book? = withContext(Dispatchers.IO) { + suspend fun getBook(id: Long): Book? = withContext(ioDispatcher) { val bookData = database.bookQueriesQueries.selectById(id).executeAsOneOrNull() ?: return@withContext null val authors = getBookAuthors(bookData.id) val topics = getBookTopics(bookData.id) @@ -479,7 +478,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Suitable for search suggestions, navigation trees and other scenarios where * only id/title/category information is required. */ - suspend fun getBookCore(id: Long): Book? = withContext(Dispatchers.IO) { + suspend fun getBookCore(id: Long): Book? = withContext(ioDispatcher) { val bookData = database.bookQueriesQueries.selectById(id).executeAsOneOrNull() ?: return@withContext null return@withContext bookData.toModel(json) } @@ -488,7 +487,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Lightweight helper for commentary flows: loads core book data and publication * dates without joining authors, topics, or publication places. */ - suspend fun getBookWithPubDates(id: Long): Book? = withContext(Dispatchers.IO) { + suspend fun getBookWithPubDates(id: Long): Book? = withContext(ioDispatcher) { val bookData = database.bookQueriesQueries.selectById(id).executeAsOneOrNull() ?: return@withContext null val pubDates = getBookPubDates(bookData.id) return@withContext bookData.toModel(json, pubDates = pubDates) @@ -500,7 +499,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * @param categoryId The ID of the category * @return A list of books in the category */ - suspend fun getBooksByCategory(categoryId: Long): List<Book> = withContext(Dispatchers.IO) { + suspend fun getBooksByCategory(categoryId: Long): List<Book> = withContext(ioDispatcher) { val books = database.bookQueriesQueries.selectByCategoryId(categoryId).executeAsList() return@withContext books.map { bookData -> val authors = getBookAuthors(bookData.id) @@ -515,7 +514,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Retrieves all books under the given ancestor category (including the category itself) * using the category_closure table in a single query. */ - suspend fun getBooksUnderCategoryTree(ancestorCategoryId: Long): List<Book> = withContext(Dispatchers.IO) { + suspend fun getBooksUnderCategoryTree(ancestorCategoryId: Long): List<Book> = withContext(ioDispatcher) { val rows = database.bookQueriesQueries.selectByAncestorCategory(ancestorCategoryId).executeAsList() rows.map { bookData -> val authors = getBookAuthors(bookData.id) @@ -526,7 +525,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } } - suspend fun getAllBookAltFlags(): Map<Long, Boolean> = withContext(Dispatchers.IO) { + suspend fun getAllBookAltFlags(): Map<Long, Boolean> = withContext(ioDispatcher) { database.bookQueriesQueries.selectAltFlags().executeAsList() .associate { row -> row.id to (row.hasAltStructures == 1L) } } @@ -537,7 +536,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * This version loads full metadata (authors, topics, publication info) for each match. * For lightweight use cases (e.g., typeahead suggestions), prefer [findBooksByTitleLikeCore]. */ - suspend fun findBooksByTitleLike(pattern: String, limit: Int = 20): List<Book> = withContext(Dispatchers.IO) { + suspend fun findBooksByTitleLike(pattern: String, limit: Int = 20): List<Book> = withContext(ioDispatcher) { val rows = database.bookQueriesQueries.selectManyByTitleLike(pattern, limit.toLong()).executeAsList() rows.map { bookData -> val authors = getBookAuthors(bookData.id) @@ -552,7 +551,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Lightweight variant of [findBooksByTitleLike] that returns only core book metadata * without issuing extra queries for authors, topics, publication places or dates. */ - suspend fun findBooksByTitleLikeCore(pattern: String, limit: Int = 20): List<Book> = withContext(Dispatchers.IO) { + suspend fun findBooksByTitleLikeCore(pattern: String, limit: Int = 20): List<Book> = withContext(ioDispatcher) { val rows = database.bookQueriesQueries.selectManyByTitleLike(pattern, limit.toLong()).executeAsList() rows.map { bookData -> bookData.toModel(json) } } @@ -561,7 +560,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L - suspend fun searchBooksByAuthor(authorName: String): List<Book> = withContext(Dispatchers.IO) { + suspend fun searchBooksByAuthor(authorName: String): List<Book> = withContext(ioDispatcher) { val books = database.bookQueriesQueries.selectByAuthor("%$authorName%").executeAsList() return@withContext books.map { bookData -> val authors = getBookAuthors(bookData.id) @@ -573,7 +572,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } // Get all authors for a book - private suspend fun getBookAuthors(bookId: Long): List<Author> = withContext(Dispatchers.IO) { + private suspend fun getBookAuthors(bookId: Long): List<Author> = withContext(ioDispatcher) { logger.d{"Getting authors for book ID: $bookId"} val authors = database.authorQueriesQueries.selectByBookId(bookId).executeAsList() logger.d{"Found ${authors.size} authors for book ID: $bookId"} @@ -581,7 +580,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } // Get all topics for a book - private suspend fun getBookTopics(bookId: Long): List<Topic> = withContext(Dispatchers.IO) { + private suspend fun getBookTopics(bookId: Long): List<Topic> = withContext(ioDispatcher) { logger.d{"Getting topics for book ID: $bookId"} val topics = database.topicQueriesQueries.selectByBookId(bookId).executeAsList() logger.d{"Found ${topics.size} topics for book ID: $bookId"} @@ -589,7 +588,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } // Get all publication places for a book - private suspend fun getBookPubPlaces(bookId: Long): List<PubPlace> = withContext(Dispatchers.IO) { + private suspend fun getBookPubPlaces(bookId: Long): List<PubPlace> = withContext(ioDispatcher) { logger.d{"Getting publication places for book ID: $bookId"} val pubPlaces = database.pubPlaceQueriesQueries.selectByBookId(bookId).executeAsList() logger.d{"Found ${pubPlaces.size} publication places for book ID: $bookId"} @@ -597,7 +596,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } // Get all publication dates for a book - private suspend fun getBookPubDates(bookId: Long): List<PubDate> = withContext(Dispatchers.IO) { + private suspend fun getBookPubDates(bookId: Long): List<PubDate> = withContext(ioDispatcher) { logger.d{"Getting publication dates for book ID: $bookId"} val pubDates = database.pubDateQueriesQueries.selectByBookId(bookId).executeAsList() logger.d{"Found ${pubDates.size} publication dates for book ID: $bookId"} @@ -605,7 +604,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } // Get an author by name, returns null if not found - suspend fun getAuthorByName(name: String): Author? = withContext(Dispatchers.IO) { + suspend fun getAuthorByName(name: String): Author? = withContext(ioDispatcher) { logger.d{"Looking for author with name: $name"} val author = database.authorQueriesQueries.selectByName(name).executeAsOneOrNull() if (author != null) { @@ -617,7 +616,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } // Insert an author and return its ID - suspend fun insertAuthor(name: String): Long = withContext(Dispatchers.IO) { + suspend fun insertAuthor(name: String): Long = withContext(ioDispatcher) { logger.d{"Inserting author: $name"} // Check if author already exists @@ -665,13 +664,13 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } // Link an author to a book - suspend fun linkAuthorToBook(authorId: Long, bookId: Long) = withContext(Dispatchers.IO) { + suspend fun linkAuthorToBook(authorId: Long, bookId: Long) = withContext(ioDispatcher) { logger.d{"Linking author $authorId to book $bookId"} database.authorQueriesQueries.linkBookAuthor(bookId, authorId) logger.d{"Linked author $authorId to book $bookId"} } - suspend fun getBookByTitle(title: String): Book? = withContext(Dispatchers.IO) { + suspend fun getBookByTitle(title: String): Book? = withContext(ioDispatcher) { val bookData = database.bookQueriesQueries.selectByTitle(title).executeAsOneOrNull() ?: return@withContext null val authors = getBookAuthors(bookData.id) val topics = getBookTopics(bookData.id) @@ -684,7 +683,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Retrieves a book by its stable Hebrew reference identifier (heRef). * Returns null if no book with the given heRef exists. */ - suspend fun getBookByHeRef(heRef: String): Book? = withContext(Dispatchers.IO) { + suspend fun getBookByHeRef(heRef: String): Book? = withContext(ioDispatcher) { val bookData = database.bookQueriesQueries.selectByHeRef(heRef).executeAsOneOrNull() ?: return@withContext null val authors = getBookAuthors(bookData.id) val topics = getBookTopics(bookData.id) @@ -696,7 +695,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Retrieves a book by approximate title (exact, normalized, or LIKE). */ - suspend fun findBookByTitlePreferExact(title: String): Book? = withContext(Dispatchers.IO) { + suspend fun findBookByTitlePreferExact(title: String): Book? = withContext(ioDispatcher) { val row = database.bookQueriesQueries.selectByTitle(title).executeAsOneOrNull() ?: database.bookQueriesQueries.selectByTitleLike("%$title%").executeAsOneOrNull() row?.let { bookData -> @@ -709,7 +708,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } // Get a topic by name, returns null if not found - suspend fun getTopicByName(name: String): Topic? = withContext(Dispatchers.IO) { + suspend fun getTopicByName(name: String): Topic? = withContext(ioDispatcher) { logger.d{"Looking for topic with name: $name"} val topic = database.topicQueriesQueries.selectByName(name).executeAsOneOrNull() if (topic != null) { @@ -721,7 +720,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } // Get a publication place by name, returns null if not found - suspend fun getPubPlaceByName(name: String): PubPlace? = withContext(Dispatchers.IO) { + suspend fun getPubPlaceByName(name: String): PubPlace? = withContext(ioDispatcher) { logger.d{"Looking for publication place with name: $name"} val pubPlace = database.pubPlaceQueriesQueries.selectByName(name).executeAsOneOrNull() if (pubPlace != null) { @@ -733,7 +732,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } // Get a publication date by date, returns null if not found - suspend fun getPubDateByDate(date: String): PubDate? = withContext(Dispatchers.IO) { + suspend fun getPubDateByDate(date: String): PubDate? = withContext(ioDispatcher) { logger.d{"Looking for publication date with date: $date"} val pubDate = database.pubDateQueriesQueries.selectByDate(date).executeAsOneOrNull() if (pubDate != null) { @@ -745,7 +744,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } // Insert a topic and return its ID - suspend fun insertTopic(name: String): Long = withContext(Dispatchers.IO) { + suspend fun insertTopic(name: String): Long = withContext(ioDispatcher) { logger.d{"Inserting topic: $name"} // Check if topic already exists @@ -793,14 +792,14 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } // Link a topic to a book - suspend fun linkTopicToBook(topicId: Long, bookId: Long) = withContext(Dispatchers.IO) { + suspend fun linkTopicToBook(topicId: Long, bookId: Long) = withContext(ioDispatcher) { logger.d{"Linking topic $topicId to book $bookId"} database.topicQueriesQueries.linkBookTopic(bookId, topicId) logger.d{"Linked topic $topicId to book $bookId"} } // Insert a publication place and return its ID - suspend fun insertPubPlace(name: String): Long = withContext(Dispatchers.IO) { + suspend fun insertPubPlace(name: String): Long = withContext(ioDispatcher) { logger.d{"Inserting publication place: $name"} // Check if publication place already exists @@ -837,7 +836,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } // Insert a publication date and return its ID - suspend fun insertPubDate(date: String): Long = withContext(Dispatchers.IO) { + suspend fun insertPubDate(date: String): Long = withContext(ioDispatcher) { logger.d{"Inserting publication date: $date"} // Check if publication date already exists @@ -874,14 +873,14 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } // Link a publication place to a book - suspend fun linkPubPlaceToBook(pubPlaceId: Long, bookId: Long) = withContext(Dispatchers.IO) { + suspend fun linkPubPlaceToBook(pubPlaceId: Long, bookId: Long) = withContext(ioDispatcher) { logger.d{"Linking publication place $pubPlaceId to book $bookId"} database.pubPlaceQueriesQueries.linkBookPubPlace(bookId, pubPlaceId) logger.d{"Linked publication place $pubPlaceId to book $bookId"} } // Link a publication date to a book - suspend fun linkPubDateToBook(pubDateId: Long, bookId: Long) = withContext(Dispatchers.IO) { + suspend fun linkPubDateToBook(pubDateId: Long, bookId: Long) = withContext(ioDispatcher) { logger.d{"Linking publication date $pubDateId to book $bookId"} database.pubDateQueriesQueries.linkBookPubDate(bookId, pubDateId) logger.d{"Linked publication date $pubDateId to book $bookId"} @@ -894,7 +893,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * @param book The book to insert * @return The ID of the inserted book */ - suspend fun insertBook(book: Book): Long = withContext(Dispatchers.IO) { + suspend fun insertBook(book: Book): Long = withContext(ioDispatcher) { logger.d{"Repository inserting book '${book.title}' with ID: ${book.id} and categoryId: ${book.categoryId}"} // Use the ID from the book object if it's greater than 0 @@ -906,6 +905,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L title = book.title, heRef = book.heRef, heShortDesc = book.heShortDesc, + heDesc = book.heDesc, notesContent = book.notesContent, orderIndex = book.order.toLong(), totalLines = book.totalLines.toLong(), @@ -964,6 +964,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L title = book.title, heRef = book.heRef, heShortDesc = book.heShortDesc, + heDesc = book.heDesc, notesContent = book.notesContent, orderIndex = book.order.toLong(), totalLines = book.totalLines.toLong(), @@ -1025,21 +1026,21 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Returns a Source by name, or null if not found. */ - suspend fun getSourceByName(name: String): Source? = withContext(Dispatchers.IO) { + suspend fun getSourceByName(name: String): Source? = withContext(ioDispatcher) { database.sourceQueriesQueries.selectByName(name).executeAsOneOrNull()?.toModel() } /** * Returns a Source by id, or null if not found. */ - suspend fun getSourceById(id: Long): Source? = withContext(Dispatchers.IO) { + suspend fun getSourceById(id: Long): Source? = withContext(ioDispatcher) { database.sourceQueriesQueries.selectById(id).executeAsOneOrNull()?.toModel() } /** * Inserts a source if missing and returns its id. */ - suspend fun insertSource(name: String): Long = withContext(Dispatchers.IO) { + suspend fun insertSource(name: String): Long = withContext(ioDispatcher) { // Check existing val existing = database.sourceQueriesQueries.selectByName(name).executeAsOneOrNull() if (existing != null) return@withContext existing.id @@ -1055,33 +1056,33 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L id } - suspend fun updateBookTotalLines(bookId: Long, totalLines: Int) = withContext(Dispatchers.IO) { + suspend fun updateBookTotalLines(bookId: Long, totalLines: Int) = withContext(ioDispatcher) { database.bookQueriesQueries.updateTotalLines(totalLines.toLong(), bookId) } - suspend fun updateBookCategoryId(bookId: Long, categoryId: Long) = withContext(Dispatchers.IO) { + suspend fun updateBookCategoryId(bookId: Long, categoryId: Long) = withContext(ioDispatcher) { logger.d{"Updating book $bookId with categoryId: $categoryId"} database.bookQueriesQueries.updateCategoryId(categoryId, bookId) logger.d{"Updated book $bookId with categoryId: $categoryId"} } - suspend fun updateHasAltStructures(bookId: Long, hasAltStructures: Boolean) = withContext(Dispatchers.IO) { + suspend fun updateHasAltStructures(bookId: Long, hasAltStructures: Boolean) = withContext(ioDispatcher) { database.bookQueriesQueries.updateHasAltStructures(if (hasAltStructures) 1 else 0, bookId) } // --- Lines --- - override suspend fun getLine(id: Long): Line? = withContext(Dispatchers.IO) { + override suspend fun getLine(id: Long): Line? = withContext(ioDispatcher) { database.lineQueriesQueries.selectById(id).executeAsOneOrNull()?.toModel() } - suspend fun getLineByIndex(bookId: Long, lineIndex: Int): Line? = withContext(Dispatchers.IO) { + suspend fun getLineByIndex(bookId: Long, lineIndex: Int): Line? = withContext(ioDispatcher) { database.lineQueriesQueries.selectByBookIdAndIndex(bookId, lineIndex.toLong()) .executeAsOneOrNull()?.toModel() } override suspend fun getLines(bookId: Long, startIndex: Int, endIndex: Int): List<Line> = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { database.lineQueriesQueries.selectByBookIdRange( bookId = bookId, lineIndex = startIndex.toLong(), @@ -1090,7 +1091,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } suspend fun getLinesByIds(ids: Collection<Long>): List<Line> = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { if (ids.isEmpty()) return@withContext emptyList() database.lineQueriesQueries.selectByIds(ids).executeAsList().map { it.toModel() } } @@ -1102,7 +1103,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * capacity changes (resize, font swap, text-size tweak). Returned as an * `IntArray` so the prefix sum can run without allocating boxed integers. */ - suspend fun getBookCharCounts(bookId: Long): IntArray = withContext(Dispatchers.IO) { + suspend fun getBookCharCounts(bookId: Long): IntArray = withContext(ioDispatcher) { val rows = database.lineQueriesQueries .selectCharCountsByBookId(bookId) .executeAsList() @@ -1116,7 +1117,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * @param currentLineIndex The index of the current line * @return The previous line, or null if there is no previous line */ - override suspend fun getPreviousLine(bookId: Long, currentLineIndex: Int): Line? = withContext(Dispatchers.IO) { + override suspend fun getPreviousLine(bookId: Long, currentLineIndex: Int): Line? = withContext(ioDispatcher) { if (currentLineIndex <= 0) return@withContext null val previousIndex = currentLineIndex - 1 @@ -1131,7 +1132,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * @param currentLineIndex The index of the current line * @return The next line, or null if there is no next line */ - override suspend fun getNextLine(bookId: Long, currentLineIndex: Int): Line? = withContext(Dispatchers.IO) { + override suspend fun getNextLine(bookId: Long, currentLineIndex: Int): Line? = withContext(ioDispatcher) { val nextIndex = currentLineIndex + 1 database.lineQueriesQueries.selectByBookIdAndIndex(bookId, nextIndex.toLong()) .executeAsOneOrNull()?.toModel() @@ -1141,7 +1142,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Inserts multiple lines in a single transaction. * This is optimized for bulk import where IDs are provided explicitly. */ - suspend fun insertLinesBatch(lines: List<Line>) = withContext(Dispatchers.IO) { + suspend fun insertLinesBatch(lines: List<Line>) = withContext(ioDispatcher) { if (lines.isEmpty()) return@withContext database.transaction { @@ -1160,7 +1161,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } } - suspend fun insertLine(line: Line): Long = withContext(Dispatchers.IO) { + suspend fun insertLine(line: Line): Long = withContext(ioDispatcher) { logger.d{"Repository inserting line with bookId: ${line.bookId}"} // Use the ID from the line object if it's greater than 0 @@ -1205,7 +1206,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } } - suspend fun updateLineTocEntry(lineId: Long, tocEntryId: Long) = withContext(Dispatchers.IO) { + suspend fun updateLineTocEntry(lineId: Long, tocEntryId: Long) = withContext(ioDispatcher) { logger.d{"Repository updating line $lineId with tocEntryId: $tocEntryId"} database.lineQueriesQueries.updateTocEntryId(tocEntryId, lineId) logger.d{"Repository updated line $lineId with tocEntryId: $tocEntryId"} @@ -1214,10 +1215,10 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Bulk variant of [updateLineTocEntry] for the Otzaria importer hot path. * Each pair is (lineId, tocEntryId). Wrapped in a single transaction — - * avoids one Dispatchers.IO context switch per row (the per-row + * avoids one ioDispatcher context switch per row (the per-row * `withContext` was burning park/unpark cycles on 4M+ header lines). */ - suspend fun bulkUpdateLineTocEntryIds(pairs: List<Pair<Long, Long>>) = withContext(Dispatchers.IO) { + suspend fun bulkUpdateLineTocEntryIds(pairs: List<Pair<Long, Long>>) = withContext(ioDispatcher) { if (pairs.isEmpty()) return@withContext database.transaction { for ((lineId, tocEntryId) in pairs) { @@ -1229,7 +1230,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Bulk variant of [updateTocEntryLineId]. Each pair is (tocEntryId, lineId). */ - suspend fun bulkUpdateTocEntryLineIds(pairs: List<Pair<Long, Long>>) = withContext(Dispatchers.IO) { + suspend fun bulkUpdateTocEntryLineIds(pairs: List<Pair<Long, Long>>) = withContext(ioDispatcher) { if (pairs.isEmpty()) return@withContext database.transaction { for ((tocEntryId, lineId) in pairs) { @@ -1240,42 +1241,42 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L // --- Table of Contents --- - override suspend fun getTocEntry(id: Long): TocEntry? = withContext(Dispatchers.IO) { + override suspend fun getTocEntry(id: Long): TocEntry? = withContext(ioDispatcher) { database.tocQueriesQueries.selectTocById(id).executeAsOneOrNull()?.toModel() } - suspend fun getBookToc(bookId: Long): List<TocEntry> = withContext(Dispatchers.IO) { + suspend fun getBookToc(bookId: Long): List<TocEntry> = withContext(ioDispatcher) { database.tocQueriesQueries.selectByBookId(bookId).executeAsList().map { it.toModel() } } - suspend fun getBookRootToc(bookId: Long): List<TocEntry> = withContext(Dispatchers.IO) { + suspend fun getBookRootToc(bookId: Long): List<TocEntry> = withContext(ioDispatcher) { database.tocQueriesQueries.selectRootByBookId(bookId).executeAsList().map { it.toModel() } } - suspend fun getTocChildren(parentId: Long): List<TocEntry> = withContext(Dispatchers.IO) { + suspend fun getTocChildren(parentId: Long): List<TocEntry> = withContext(ioDispatcher) { database.tocQueriesQueries.selectChildren(parentId).executeAsList().map { it.toModel() } } - override suspend fun getAncestorPath(tocId: Long): List<TocEntry> = withContext(Dispatchers.IO) { + override suspend fun getAncestorPath(tocId: Long): List<TocEntry> = withContext(ioDispatcher) { database.tocQueriesQueries.selectAncestorPath(tocId).executeAsList().map { it.toModel() } } - suspend fun getFirstLeafTocId(tocId: Long): Long? = withContext(Dispatchers.IO) { + suspend fun getFirstLeafTocId(tocId: Long): Long? = withContext(ioDispatcher) { database.tocQueriesQueries.selectFirstLeafUnder(tocId).executeAsOneOrNull() } // --- Alternative TOC structures --- - suspend fun getAltTocStructuresForBook(bookId: Long): List<AltTocStructure> = withContext(Dispatchers.IO) { + suspend fun getAltTocStructuresForBook(bookId: Long): List<AltTocStructure> = withContext(ioDispatcher) { database.altTocStructureQueriesQueries.selectByBookId(bookId).executeAsList().map { it.toModel() } } - suspend fun clearAltTocForBook(bookId: Long) = withContext(Dispatchers.IO) { + suspend fun clearAltTocForBook(bookId: Long) = withContext(ioDispatcher) { database.altTocStructureQueriesQueries.deleteByBookId(bookId) database.lineAltTocQueriesQueries.deleteByBookId(bookId) } - suspend fun upsertAltTocStructure(structure: AltTocStructure): Long = withContext(Dispatchers.IO) { + suspend fun upsertAltTocStructure(structure: AltTocStructure): Long = withContext(ioDispatcher) { val existing = database.altTocStructureQueriesQueries.selectByBookId(structure.bookId) .executeAsList() .firstOrNull { it.key == structure.key } @@ -1309,7 +1310,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L id } - suspend fun insertAltTocEntry(entry: AltTocEntry): Long = withContext(Dispatchers.IO) { + suspend fun insertAltTocEntry(entry: AltTocEntry): Long = withContext(ioDispatcher) { val textId = entry.textId ?: getOrCreateTocText(entry.text) if (entry.id > 0) { database.altTocEntryQueriesQueries.insertWithId( @@ -1345,45 +1346,45 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L id } - suspend fun updateAltTocEntryHasChildren(altTocEntryId: Long, hasChildren: Boolean) = withContext(Dispatchers.IO) { + suspend fun updateAltTocEntryHasChildren(altTocEntryId: Long, hasChildren: Boolean) = withContext(ioDispatcher) { database.altTocEntryQueriesQueries.updateHasChildren(if (hasChildren) 1 else 0, altTocEntryId) } - suspend fun updateAltTocEntryIsLastChild(altTocEntryId: Long, isLastChild: Boolean) = withContext(Dispatchers.IO) { + suspend fun updateAltTocEntryIsLastChild(altTocEntryId: Long, isLastChild: Boolean) = withContext(ioDispatcher) { database.altTocEntryQueriesQueries.updateIsLastChild(if (isLastChild) 1 else 0, altTocEntryId) } - suspend fun updateAltTocEntryLineId(altTocEntryId: Long, lineId: Long) = withContext(Dispatchers.IO) { + suspend fun updateAltTocEntryLineId(altTocEntryId: Long, lineId: Long) = withContext(ioDispatcher) { database.altTocEntryQueriesQueries.updateLineId(lineId, altTocEntryId) } - suspend fun getAltTocEntry(id: Long): AltTocEntry? = withContext(Dispatchers.IO) { + suspend fun getAltTocEntry(id: Long): AltTocEntry? = withContext(ioDispatcher) { database.altTocEntryQueriesQueries.selectAltTocEntryById(id).executeAsOneOrNull()?.toModel() } - suspend fun getAltRootToc(structureId: Long): List<AltTocEntry> = withContext(Dispatchers.IO) { + suspend fun getAltRootToc(structureId: Long): List<AltTocEntry> = withContext(ioDispatcher) { database.altTocEntryQueriesQueries.selectAltRootByStructureId(structureId).executeAsList().map { it.toModel() } } - suspend fun getAltTocChildren(parentId: Long): List<AltTocEntry> = withContext(Dispatchers.IO) { + suspend fun getAltTocChildren(parentId: Long): List<AltTocEntry> = withContext(ioDispatcher) { database.altTocEntryQueriesQueries.selectAltChildren(parentId).executeAsList().map { it.toModel() } } - suspend fun getAltTocEntriesForStructure(structureId: Long): List<AltTocEntry> = withContext(Dispatchers.IO) { + suspend fun getAltTocEntriesForStructure(structureId: Long): List<AltTocEntry> = withContext(ioDispatcher) { database.altTocEntryQueriesQueries.selectAltEntriesByStructureId(structureId).executeAsList().map { it.toModel() } } // --- Alternative line ⇄ TOC mapping --- - suspend fun upsertLineAltToc(lineId: Long, structureId: Long, altTocEntryId: Long) = withContext(Dispatchers.IO) { + suspend fun upsertLineAltToc(lineId: Long, structureId: Long, altTocEntryId: Long) = withContext(ioDispatcher) { database.lineAltTocQueriesQueries.upsert(lineId, structureId, altTocEntryId) } - suspend fun getAltTocEntryIdForLine(lineId: Long, structureId: Long): Long? = withContext(Dispatchers.IO) { + suspend fun getAltTocEntryIdForLine(lineId: Long, structureId: Long): Long? = withContext(ioDispatcher) { database.lineAltTocQueriesQueries.selectAltTocEntryIdByLineAndStructure(lineId, structureId).executeAsOneOrNull() } - suspend fun getLineAltTocMappings(structureId: Long): List<LineAltTocMapping> = withContext(Dispatchers.IO) { + suspend fun getLineAltTocMappings(structureId: Long): List<LineAltTocMapping> = withContext(ioDispatcher) { database.lineAltTocQueriesQueries.selectByStructure(structureId).executeAsList().map { LineAltTocMapping( lineId = it.lineId, @@ -1393,20 +1394,20 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } } - suspend fun getLineIdsForAltTocEntry(altTocEntryId: Long): List<Long> = withContext(Dispatchers.IO) { + suspend fun getLineIdsForAltTocEntry(altTocEntryId: Long): List<Long> = withContext(ioDispatcher) { database.lineAltTocQueriesQueries.selectLineIdsByAltTocEntry(altTocEntryId).executeAsList() } // --- TocText methods --- // Returns all distinct tocText values using generated SQLDelight query - suspend fun getAllTocTexts(): List<String> = withContext(Dispatchers.IO) { + suspend fun getAllTocTexts(): List<String> = withContext(ioDispatcher) { logger.d { "Getting all tocText values (using generated query)" } database.tocTextQueriesQueries.selectAll().executeAsList().map { it.text } } // Get or create a tocText entry and return its ID - private suspend fun getOrCreateTocText(text: String): Long = withContext(Dispatchers.IO) { + private suspend fun getOrCreateTocText(text: String): Long = withContext(ioDispatcher) { // Truncate text for logging if it's too long val truncatedText = if (text.length > 50) "${text.take(50)}..." else text logger.d{"Getting or creating tocText entry for text: '$truncatedText'"} @@ -1462,7 +1463,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } } - suspend fun insertTocEntry(entry: TocEntry): Long = withContext(Dispatchers.IO) { + suspend fun insertTocEntry(entry: TocEntry): Long = withContext(ioDispatcher) { logger.d{"Repository inserting TOC entry with bookId: ${entry.bookId}, lineId: ${entry.lineId}, hasChildren: ${entry.hasChildren}"} // Get or create the tocText entry @@ -1518,18 +1519,18 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } // Nouvelle méthode pour mettre à jour hasChildren - suspend fun updateTocEntryHasChildren(tocEntryId: Long, hasChildren: Boolean) = withContext(Dispatchers.IO) { + suspend fun updateTocEntryHasChildren(tocEntryId: Long, hasChildren: Boolean) = withContext(ioDispatcher) { logger.d{"Repository updating TOC entry $tocEntryId with hasChildren: $hasChildren"} database.tocQueriesQueries.updateHasChildren(if (hasChildren) 1 else 0, tocEntryId) logger.d{"Repository updated TOC entry $tocEntryId with hasChildren: $hasChildren"} } - suspend fun updateTocEntryLineId(tocEntryId: Long, lineId: Long) = withContext(Dispatchers.IO) { + suspend fun updateTocEntryLineId(tocEntryId: Long, lineId: Long) = withContext(ioDispatcher) { logger.d{"Repository updating TOC entry $tocEntryId with lineId: $lineId"} database.tocQueriesQueries.updateLineId(lineId, tocEntryId) logger.d{"Repository updated TOC entry $tocEntryId with lineId: $lineId"} } - suspend fun updateTocEntryIsLastChild(tocEntryId: Long, isLastChild: Boolean) = withContext(Dispatchers.IO) { + suspend fun updateTocEntryIsLastChild(tocEntryId: Long, isLastChild: Boolean) = withContext(ioDispatcher) { logger.d{"Repository updating TOC entry $tocEntryId with isLastChild: $isLastChild"} database.tocQueriesQueries.updateIsLastChild(if (isLastChild) 1 else 0, tocEntryId) logger.d{"Repository updated TOC entry $tocEntryId with isLastChild: $isLastChild"} @@ -1544,7 +1545,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L suspend fun bulkUpdateTocEntryFlags( hasChildrenIds: Collection<Long>, lastChildIds: Collection<Long>, - ) = withContext(Dispatchers.IO) { + ) = withContext(ioDispatcher) { if (hasChildrenIds.isEmpty() && lastChildIds.isEmpty()) return@withContext database.transaction { for (id in hasChildrenIds) { @@ -1564,7 +1565,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * @param name The name of the connection type * @return The ID of the connection type */ - private suspend fun getOrCreateConnectionType(name: String): Long = withContext(Dispatchers.IO) { + private suspend fun getOrCreateConnectionType(name: String): Long = withContext(ioDispatcher) { logger.d{"Getting or creating connection type: $name"} // Check if the connection type already exists @@ -1601,7 +1602,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * * @return A list of all connection types */ - suspend fun getAllConnectionTypes(): List<ConnectionType> = withContext(Dispatchers.IO) { + suspend fun getAllConnectionTypes(): List<ConnectionType> = withContext(ioDispatcher) { database.connectionTypeQueriesQueries.selectAll().executeAsList().map { ConnectionType.fromString(it.name) } @@ -1609,11 +1610,11 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L // --- Links --- - suspend fun getLink(id: Long): Link? = withContext(Dispatchers.IO) { + suspend fun getLink(id: Long): Link? = withContext(ioDispatcher) { database.linkQueriesQueries.selectLinkById(id).executeAsOneOrNull()?.toModel() } - suspend fun countLinks(): Long = withContext(Dispatchers.IO) { + suspend fun countLinks(): Long = withContext(ioDispatcher) { logger.d{"Counting links in database"} val count = database.linkQueriesQueries.countAllLinks().executeAsOne() logger.d{"Found $count links in database"} @@ -1624,7 +1625,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L lineIds: List<Long>, activeCommentatorIds: Set<Long> = emptySet(), includeSources: Boolean = false, - ): List<CommentaryWithText> = withContext(Dispatchers.IO) { + ): List<CommentaryWithText> = withContext(ioDispatcher) { if (lineIds.isEmpty()) return@withContext emptyList() val forward = database.linkQueriesQueries.selectLinksBySourceLineIds(lineIds).executeAsList() .filter { activeCommentatorIds.isEmpty() || it.targetBookId in activeCommentatorIds } @@ -1673,7 +1674,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L lineIds: List<Long>, activeCommentatorIds: Set<Long> = emptySet(), includeSources: Boolean = false, - ): List<CommentarySummary> = withContext(Dispatchers.IO) { + ): List<CommentarySummary> = withContext(ioDispatcher) { if (lineIds.isEmpty()) return@withContext emptyList() val forward = database.linkQueriesQueries.selectLinkSummariesBySourceLineIds(lineIds).executeAsList() .filter { activeCommentatorIds.isEmpty() || it.targetBookId in activeCommentatorIds } @@ -1712,7 +1713,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } suspend fun getAvailableCommentators(bookId: Long): List<CommentatorInfo> = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { database.linkQueriesQueries.selectCommentatorsByBook(bookId).executeAsList() .map { CommentatorInfo( @@ -1732,7 +1733,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L offset: Int, limit: Int, distinctByTargetLine: Boolean = false - ): List<CommentaryWithText> = withContext(Dispatchers.IO) { + ): List<CommentaryWithText> = withContext(ioDispatcher) { if (lineIds.isEmpty()) return@withContext emptyList() if (connectionTypes.isEmpty()) return@withContext emptyList() // SOURCE is a virtual type: route to mirror queries that read inverse @@ -1962,7 +1963,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L lineIds: List<Long>, activeCommentatorIds: Set<Long> = emptySet(), connectionTypes: Set<ConnectionType> = setOf(ConnectionType.COMMENTARY), - ): List<Int> = withContext(Dispatchers.IO) { + ): List<Int> = withContext(ioDispatcher) { if (lineIds.isEmpty() || connectionTypes.isEmpty()) return@withContext emptyList() if (ConnectionType.SOURCE in connectionTypes) { require(connectionTypes.size == 1) { @@ -2027,7 +2028,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L bookId: Long, offset: Int, limit: Int - ): List<CommentatorInfo> = withContext(Dispatchers.IO) { + ): List<CommentatorInfo> = withContext(ioDispatcher) { database.linkQueriesQueries.selectCommentatorsByBook(bookId) .executeAsList() .drop(offset) @@ -2042,7 +2043,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } } - suspend fun insertLink(link: Link): Long = withContext(Dispatchers.IO) { + suspend fun insertLink(link: Link): Long = withContext(ioDispatcher) { logger.d{"Repository inserting link from book ${link.sourceBookId} to book ${link.targetBookId}"} logger.d{"Link details - sourceLineId: ${link.sourceLineId}, targetLineId: ${link.targetLineId}, connectionType: ${link.connectionType.name}"} @@ -2115,7 +2116,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Inserts multiple links in a single transaction. * Optimized for bulk import; assumes no duplicate (sourceBookId, targetBookId, sourceLineId, targetLineId) rows. */ - suspend fun insertLinksBatch(links: List<Link>) = withContext(Dispatchers.IO) { + suspend fun insertLinksBatch(links: List<Link>) = withContext(ioDispatcher) { if (links.isEmpty()) return@withContext // Pre-resolve connection type IDs per type name to avoid repeated lookups. @@ -2169,7 +2170,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Migrates existing links to use the new connection_type table. * This should be called once after updating the database schema. */ - suspend fun migrateConnectionTypes() = withContext(Dispatchers.IO) { + suspend fun migrateConnectionTypes() = withContext(ioDispatcher) { logger.d{"Starting migration of connection types"} try { @@ -2214,7 +2215,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * * @param sql The SQL query to execute */ - suspend fun executeRawQuery(sql: String) = withContext(Dispatchers.IO) { + suspend fun executeRawQuery(sql: String) = withContext(ioDispatcher) { logger.d { "Executing raw SQL query: $sql" } driver.execute(null, sql, 0) logger.d { "Raw SQL query executed successfully" } @@ -2229,7 +2230,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * @param hasSourceLinks Whether the book has source links (true) or not (false) * @param hasTargetLinks Whether the book has target links (true) or not (false) */ - suspend fun updateBookHasLinks(bookId: Long, hasSourceLinks: Boolean, hasTargetLinks: Boolean) = withContext(Dispatchers.IO) { + suspend fun updateBookHasLinks(bookId: Long, hasSourceLinks: Boolean, hasTargetLinks: Boolean) = withContext(ioDispatcher) { logger.d { "Updating book_has_links for book $bookId: hasSourceLinks=$hasSourceLinks, hasTargetLinks=$hasTargetLinks" } val hasSourceLinksInt = if (hasSourceLinks) 1L else 0L val hasTargetLinksInt = if (hasTargetLinks) 1L else 0L @@ -2246,7 +2247,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * @param bookId The ID of the book to update * @param hasSourceLinks Whether the book has source links (true) or not (false) */ - suspend fun updateBookSourceLinks(bookId: Long, hasSourceLinks: Boolean) = withContext(Dispatchers.IO) { + suspend fun updateBookSourceLinks(bookId: Long, hasSourceLinks: Boolean) = withContext(ioDispatcher) { logger.d { "Updating source links for book $bookId: hasSourceLinks=$hasSourceLinks" } val hasSourceLinksInt = if (hasSourceLinks) 1L else 0L @@ -2262,7 +2263,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * @param bookId The ID of the book to update * @param hasTargetLinks Whether the book has target links (true) or not (false) */ - suspend fun updateBookTargetLinks(bookId: Long, hasTargetLinks: Boolean) = withContext(Dispatchers.IO) { + suspend fun updateBookTargetLinks(bookId: Long, hasTargetLinks: Boolean) = withContext(ioDispatcher) { logger.d { "Updating target links for book $bookId: hasTargetLinks=$hasTargetLinks" } val hasTargetLinksInt = if (hasTargetLinks) 1L else 0L @@ -2274,11 +2275,11 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L // --- Connection type specific helpers --- - suspend fun countLinksBySourceBookAndType(bookId: Long, typeName: String): Long = withContext(Dispatchers.IO) { + suspend fun countLinksBySourceBookAndType(bookId: Long, typeName: String): Long = withContext(ioDispatcher) { database.linkQueriesQueries.countLinksBySourceBookAndType(bookId, typeName).executeAsOne() } - suspend fun countLinksByTargetBookAndType(bookId: Long, typeName: String): Long = withContext(Dispatchers.IO) { + suspend fun countLinksByTargetBookAndType(bookId: Long, typeName: String): Long = withContext(ioDispatcher) { database.linkQueriesQueries.countLinksByTargetBookAndType(bookId, typeName).executeAsOne() } @@ -2289,7 +2290,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L hasSource: Boolean, hasCommentary: Boolean, hasOther: Boolean - ) = withContext(Dispatchers.IO) { + ) = withContext(ioDispatcher) { val t = if (hasTargum) 1L else 0L val r = if (hasReference) 1L else 0L val s = if (hasSource) 1L else 0L @@ -2304,7 +2305,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * @param bookId The ID of the book to check * @return True if the book has any links, false otherwise */ - suspend fun bookHasAnyLinks(bookId: Long): Boolean = withContext(Dispatchers.IO) { + suspend fun bookHasAnyLinks(bookId: Long): Boolean = withContext(ioDispatcher) { logger.d { "Checking if book $bookId has any links" } // Check if the book has any links as source or target @@ -2322,7 +2323,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * @param bookId The ID of the book to check * @return True if the book has source links, false otherwise */ - suspend fun bookHasSourceLinks(bookId: Long): Boolean = withContext(Dispatchers.IO) { + suspend fun bookHasSourceLinks(bookId: Long): Boolean = withContext(ioDispatcher) { logger.d { "Checking if book $bookId has source links" } val count = countLinksBySourceBook(bookId) val result = count > 0 @@ -2336,7 +2337,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * @param bookId The ID of the book to check * @return True if the book has target links, false otherwise */ - suspend fun bookHasTargetLinks(bookId: Long): Boolean = withContext(Dispatchers.IO) { + suspend fun bookHasTargetLinks(bookId: Long): Boolean = withContext(ioDispatcher) { logger.d { "Checking if book $bookId has target links" } val count = countLinksByTargetBook(bookId) val result = count > 0 @@ -2347,7 +2348,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Checks if a book has OTHER type comments. */ - suspend fun bookHasOtherComments(bookId: Long): Boolean = withContext(Dispatchers.IO) { + suspend fun bookHasOtherComments(bookId: Long): Boolean = withContext(ioDispatcher) { logger.d { "Checking if book $bookId has OTHER comments" } val book = database.bookQueriesQueries.selectById(bookId).executeAsOneOrNull() val result = book?.hasOtherConnection == 1L @@ -2358,7 +2359,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Checks if a book has COMMENTARY type comments. */ - suspend fun bookHasCommentaryComments(bookId: Long): Boolean = withContext(Dispatchers.IO) { + suspend fun bookHasCommentaryComments(bookId: Long): Boolean = withContext(ioDispatcher) { logger.d { "Checking if book $bookId has COMMENTARY comments" } val book = database.bookQueriesQueries.selectById(bookId).executeAsOneOrNull() val result = book?.hasCommentaryConnection == 1L @@ -2369,7 +2370,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Checks if a book has REFERENCE type comments. */ - suspend fun bookHasReferenceComments(bookId: Long): Boolean = withContext(Dispatchers.IO) { + suspend fun bookHasReferenceComments(bookId: Long): Boolean = withContext(ioDispatcher) { logger.d { "Checking if book $bookId has REFERENCE comments" } val book = database.bookQueriesQueries.selectById(bookId).executeAsOneOrNull() val result = book?.hasReferenceConnection == 1L @@ -2380,7 +2381,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Checks if a book has TARGUM type comments. */ - suspend fun bookHasTargumComments(bookId: Long): Boolean = withContext(Dispatchers.IO) { + suspend fun bookHasTargumComments(bookId: Long): Boolean = withContext(ioDispatcher) { logger.d { "Checking if book $bookId has TARGUM comments" } val book = database.bookQueriesQueries.selectById(bookId).executeAsOneOrNull() val result = book?.hasTargumConnection == 1L @@ -2393,7 +2394,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * * @return A list of books that have any links */ - suspend fun getBooksWithAnyLinks(): List<Book> = withContext(Dispatchers.IO) { + suspend fun getBooksWithAnyLinks(): List<Book> = withContext(ioDispatcher) { logger.d { "Getting all books with any links" } val books = database.bookHasLinksQueriesQueries.selectBooksWithAnyLinks().executeAsList() logger.d { "Found ${books.size} books with any links" } @@ -2413,7 +2414,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * * @return A list of books that have source links */ - suspend fun getBooksWithSourceLinks(): List<Book> = withContext(Dispatchers.IO) { + suspend fun getBooksWithSourceLinks(): List<Book> = withContext(ioDispatcher) { logger.d { "Getting all books with source links" } val books = database.bookHasLinksQueriesQueries.selectBooksWithSourceLinks().executeAsList() logger.d { "Found ${books.size} books with source links" } @@ -2433,7 +2434,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * * @return A list of books that have target links */ - suspend fun getBooksWithTargetLinks(): List<Book> = withContext(Dispatchers.IO) { + suspend fun getBooksWithTargetLinks(): List<Book> = withContext(ioDispatcher) { logger.d { "Getting all books with target links" } val books = database.bookHasLinksQueriesQueries.selectBooksWithTargetLinks().executeAsList() logger.d { "Found ${books.size} books with target links" } @@ -2453,7 +2454,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * * @return The number of books that have any links */ - suspend fun countBooksWithAnyLinks(): Long = withContext(Dispatchers.IO) { + suspend fun countBooksWithAnyLinks(): Long = withContext(ioDispatcher) { logger.d { "Counting books with any links" } val count = database.bookHasLinksQueriesQueries.countBooksWithAnyLinks().executeAsOne() logger.d { "Found $count books with any links" } @@ -2465,7 +2466,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * * @return The number of books that have source links */ - suspend fun countBooksWithSourceLinks(): Long = withContext(Dispatchers.IO) { + suspend fun countBooksWithSourceLinks(): Long = withContext(ioDispatcher) { logger.d { "Counting books with source links" } val count = database.bookHasLinksQueriesQueries.countBooksWithSourceLinks().executeAsOne() logger.d { "Found $count books with source links" } @@ -2477,7 +2478,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * * @return The number of books that have target links */ - suspend fun countBooksWithTargetLinks(): Long = withContext(Dispatchers.IO) { + suspend fun countBooksWithTargetLinks(): Long = withContext(ioDispatcher) { logger.d { "Counting books with target links" } val count = database.bookHasLinksQueriesQueries.countBooksWithTargetLinks().executeAsOne() logger.d { "Found $count books with target links" } @@ -2490,7 +2491,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * * @return A list of all books */ - suspend fun getAllBooks(): List<Book> = withContext(Dispatchers.IO) { + suspend fun getAllBooks(): List<Book> = withContext(ioDispatcher) { logger.d { "Getting all books" } val books = database.bookQueriesQueries.selectAll().executeAsList() logger.d { "Found ${books.size} books" } @@ -2508,7 +2509,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Returns the IDs of all base books (isBaseBook = 1). */ - suspend fun getBaseBookIds(): List<Long> = withContext(Dispatchers.IO) { + suspend fun getBaseBookIds(): List<Long> = withContext(ioDispatcher) { database.bookQueriesQueries.selectBaseIds().executeAsList() } @@ -2518,7 +2519,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * @param bookId The ID of the book to count links for * @return The number of links where the book is the source */ - suspend fun countLinksBySourceBook(bookId: Long): Long = withContext(Dispatchers.IO) { + suspend fun countLinksBySourceBook(bookId: Long): Long = withContext(ioDispatcher) { logger.d { "Counting links where book $bookId is the source" } val count = database.linkQueriesQueries.countLinksBySourceBook(bookId).executeAsOne() logger.d { "Found $count links where book $bookId is the source" } @@ -2531,7 +2532,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * @param bookId The ID of the book to count links for * @return The number of links where the book is the target */ - suspend fun countLinksByTargetBook(bookId: Long): Long = withContext(Dispatchers.IO) { + suspend fun countLinksByTargetBook(bookId: Long): Long = withContext(ioDispatcher) { logger.d { "Counting links where book $bookId is the target" } val count = database.linkQueriesQueries.countLinksByTargetBook(bookId).executeAsOne() logger.d { "Found $count links where book $bookId is the target" } @@ -2543,14 +2544,14 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Returns the list of commentator book IDs configured as defaults for the given book. */ - suspend fun getDefaultCommentatorIdsForBook(bookId: Long): List<Long> = withContext(Dispatchers.IO) { + suspend fun getDefaultCommentatorIdsForBook(bookId: Long): List<Long> = withContext(ioDispatcher) { database.defaultCommentatorQueriesQueries.selectByBookId(bookId).executeAsList() } /** * Replaces the default commentators list for a given book with the provided ordered IDs. */ - suspend fun setDefaultCommentatorsForBook(bookId: Long, commentatorBookIds: List<Long>) = withContext(Dispatchers.IO) { + suspend fun setDefaultCommentatorsForBook(bookId: Long, commentatorBookIds: List<Long>) = withContext(ioDispatcher) { database.defaultCommentatorQueriesQueries.deleteByBookId(bookId) commentatorBookIds.forEachIndexed { index, commentatorBookId -> database.defaultCommentatorQueriesQueries.insert( @@ -2565,7 +2566,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Deletes all default commentator mappings from the database. * Intended for use during full database regeneration. */ - suspend fun clearAllDefaultCommentators() = withContext(Dispatchers.IO) { + suspend fun clearAllDefaultCommentators() = withContext(ioDispatcher) { database.defaultCommentatorQueriesQueries.deleteAll() } @@ -2574,14 +2575,14 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Returns the list of targum book IDs configured as defaults for the given book. */ - suspend fun getDefaultTargumIdsForBook(bookId: Long): List<Long> = withContext(Dispatchers.IO) { + suspend fun getDefaultTargumIdsForBook(bookId: Long): List<Long> = withContext(ioDispatcher) { database.defaultTargumQueriesQueries.selectByBookId(bookId).executeAsList() } /** * Replaces the default targum list for a given book with the provided ordered IDs. */ - suspend fun setDefaultTargumForBook(bookId: Long, targumBookIds: List<Long>) = withContext(Dispatchers.IO) { + suspend fun setDefaultTargumForBook(bookId: Long, targumBookIds: List<Long>) = withContext(ioDispatcher) { database.defaultTargumQueriesQueries.deleteByBookId(bookId) targumBookIds.forEachIndexed { index, targumBookId -> database.defaultTargumQueriesQueries.insert( @@ -2596,7 +2597,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Deletes all default targum mappings from the database. * Intended for use during full database regeneration. */ - suspend fun clearAllDefaultTargum() = withContext(Dispatchers.IO) { + suspend fun clearAllDefaultTargum() = withContext(ioDispatcher) { database.defaultTargumQueriesQueries.deleteAll() } @@ -2605,14 +2606,14 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Inserts a single acronym term for a book (ignores duplicates). */ - suspend fun insertBookAcronym(bookId: Long, term: String) = withContext(Dispatchers.IO) { + suspend fun insertBookAcronym(bookId: Long, term: String) = withContext(ioDispatcher) { database.acronymQueriesQueries.insert(bookId, term) } /** * Bulk inserts acronym terms for a given bookId. Ignores duplicates. */ - suspend fun bulkInsertBookAcronyms(bookId: Long, terms: Collection<String>) = withContext(Dispatchers.IO) { + suspend fun bulkInsertBookAcronyms(bookId: Long, terms: Collection<String>) = withContext(ioDispatcher) { if (terms.isEmpty()) return@withContext for (t in terms) database.acronymQueriesQueries.insert(bookId, t) } @@ -2620,21 +2621,21 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Returns all acronym terms for a given book. */ - suspend fun getAcronymsForBook(bookId: Long): List<String> = withContext(Dispatchers.IO) { + suspend fun getAcronymsForBook(bookId: Long): List<String> = withContext(ioDispatcher) { database.acronymQueriesQueries.selectTermsByBookId(bookId).executeAsList() } /** * Finds all book IDs whose acronym list contains exactly the given term. */ - suspend fun findBookIdsByAcronym(term: String): List<Long> = withContext(Dispatchers.IO) { + suspend fun findBookIdsByAcronym(term: String): List<Long> = withContext(ioDispatcher) { database.acronymQueriesQueries.selectBookIdsByTerm(term).executeAsList() } /** * Finds books by acronym LIKE pattern. Use %term% or term% for prefix. */ - suspend fun findBooksByAcronymLike(pattern: String, limit: Int = 20): List<Book> = withContext(Dispatchers.IO) { + suspend fun findBooksByAcronymLike(pattern: String, limit: Int = 20): List<Book> = withContext(ioDispatcher) { val ids = database.acronymQueriesQueries.selectBookIdsByTermLike(pattern, limit.toLong()).executeAsList() ids.distinct().mapNotNull { id -> getBook(id) } } @@ -2642,7 +2643,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Finds books by exact acronym term. */ - suspend fun findBooksByAcronymExact(term: String, limit: Int = 20): List<Book> = withContext(Dispatchers.IO) { + suspend fun findBooksByAcronymExact(term: String, limit: Int = 20): List<Book> = withContext(ioDispatcher) { val ids = database.acronymQueriesQueries.selectBookIdsByTerm(term).executeAsList() ids.take(limit).mapNotNull { id -> getBook(id) } } @@ -2651,7 +2652,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Returns the hierarchical depth for a category using the closure table. * Depth = number of ancestors (including self) - 1. Falls back to stored level if closure empty. */ - suspend fun getCategoryDepth(categoryId: Long): Int = withContext(Dispatchers.IO) { + suspend fun getCategoryDepth(categoryId: Long): Int = withContext(ioDispatcher) { val count = database.categoryClosureQueriesQueries.countAncestorsByDescendant(categoryId).executeAsOne() if (count > 0) (count - 1).toInt() else { // Fallback to category.level if closure not built @@ -2664,7 +2665,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Deletes all acronym rows for a book. */ - suspend fun deleteAcronymsForBook(bookId: Long) = withContext(Dispatchers.IO) { + suspend fun deleteAcronymsForBook(bookId: Long) = withContext(ioDispatcher) { database.acronymQueriesQueries.deleteByBookId(bookId) } @@ -2672,7 +2673,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Returns all line IDs that are heading lines (content starts with <h1, <h2, <h3, or <h4). * Used during link processing to filter out links to heading lines. */ - suspend fun getHeadingLineIds(): Set<Long> = withContext(Dispatchers.IO) { + suspend fun getHeadingLineIds(): Set<Long> = withContext(ioDispatcher) { val result = mutableSetOf<Long>() driver.executeQuery( identifier = null, @@ -2700,27 +2701,27 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L // idempotent (ON CONFLICT DO NOTHING for lookup tables): calling them twice // with the same id is safe. See DELTA_UPDATE_PLAN.md §3.3. - suspend fun insertSourceWithId(id: Long, name: String) = withContext(Dispatchers.IO) { + suspend fun insertSourceWithId(id: Long, name: String) = withContext(ioDispatcher) { database.sourceQueriesQueries.insertWithId(id, name) } - suspend fun insertAuthorWithId(id: Long, name: String) = withContext(Dispatchers.IO) { + suspend fun insertAuthorWithId(id: Long, name: String) = withContext(ioDispatcher) { database.authorQueriesQueries.insertWithId(id, name) } - suspend fun insertTopicWithId(id: Long, name: String) = withContext(Dispatchers.IO) { + suspend fun insertTopicWithId(id: Long, name: String) = withContext(ioDispatcher) { database.topicQueriesQueries.insertWithId(id, name) } - suspend fun insertPubPlaceWithId(id: Long, name: String) = withContext(Dispatchers.IO) { + suspend fun insertPubPlaceWithId(id: Long, name: String) = withContext(ioDispatcher) { database.pubPlaceQueriesQueries.insertWithId(id, name) } - suspend fun insertPubDateWithId(id: Long, date: String) = withContext(Dispatchers.IO) { + suspend fun insertPubDateWithId(id: Long, date: String) = withContext(ioDispatcher) { database.pubDateQueriesQueries.insertWithId(id, date) } - suspend fun insertConnectionTypeWithId(id: Long, name: String) = withContext(Dispatchers.IO) { + suspend fun insertConnectionTypeWithId(id: Long, name: String) = withContext(ioDispatcher) { database.connectionTypeQueriesQueries.insertWithId(id, name) } @@ -2730,7 +2731,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L title: String, level: Int, orderIndex: Int, - ) = withContext(Dispatchers.IO) { + ) = withContext(ioDispatcher) { database.categoryQueriesQueries.insertWithId( id = id, parentId = parentId, @@ -2740,7 +2741,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L ) } - suspend fun insertTocTextWithId(id: Long, text: String) = withContext(Dispatchers.IO) { + suspend fun insertTocTextWithId(id: Long, text: String) = withContext(ioDispatcher) { database.tocTextQueriesQueries.insertWithId(id, text) } @@ -2753,7 +2754,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L targetLineIndex: Long, connectionTypeId: Long, isDeclaredBase: Boolean = false, - ) = withContext(Dispatchers.IO) { + ) = withContext(ioDispatcher) { database.linkQueriesQueries.insertWithId( id = id, sourceBookId = sourceBookId, @@ -2769,11 +2770,11 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L // ─── schema_meta accessors ──────────────────────────────────────────────── - suspend fun getSchemaMeta(key: String): String? = withContext(Dispatchers.IO) { + suspend fun getSchemaMeta(key: String): String? = withContext(ioDispatcher) { database.schemaMetaQueriesQueries.getSchemaMeta(key).executeAsOneOrNull() } - suspend fun setSchemaMeta(key: String, value: String) = withContext(Dispatchers.IO) { + suspend fun setSchemaMeta(key: String, value: String) = withContext(ioDispatcher) { database.schemaMetaQueriesQueries.upsertSchemaMeta(key, value) } diff --git a/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/BookQueries.sq b/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/BookQueries.sq index 56e9908d..8ea79d0a 100644 --- a/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/BookQueries.sq +++ b/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/BookQueries.sq @@ -44,12 +44,12 @@ selectBaseIds: SELECT id FROM book WHERE isBaseBook = 1 ORDER BY orderIndex, title; insert: -INSERT INTO book (categoryId, sourceId, title, heRef, heShortDesc, notesContent, orderIndex, totalLines, isBaseBook, hasSourceConnection, hasAltStructures, hasTeamim, hasNekudot) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +INSERT INTO book (categoryId, sourceId, title, heRef, heShortDesc, heDesc, notesContent, orderIndex, totalLines, isBaseBook, hasSourceConnection, hasAltStructures, hasTeamim, hasNekudot) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); insertWithId: -INSERT OR IGNORE INTO book (id, categoryId, sourceId, title, heRef, heShortDesc, notesContent, orderIndex, totalLines, isBaseBook, hasSourceConnection, hasAltStructures, hasTeamim, hasNekudot) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +INSERT OR IGNORE INTO book (id, categoryId, sourceId, title, heRef, heShortDesc, heDesc, notesContent, orderIndex, totalLines, isBaseBook, hasSourceConnection, hasAltStructures, hasTeamim, hasNekudot) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); selectByHeRef: SELECT * FROM book WHERE heRef = ? LIMIT 1; diff --git a/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/Database.sq b/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/Database.sq index dc83bedf..63d25541 100644 --- a/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/Database.sq +++ b/dao/src/commonMain/sqldelight/io/github/kdroidfilter/seforimlibrary/db/Database.sq @@ -28,6 +28,8 @@ -- book_topic.topicId -> topic(id) ON DELETE CASCADE -- book_author.bookId -> book(id) ON DELETE CASCADE -- book_author.authorId -> author(id) ON DELETE CASCADE +-- book_generation.bookId -> book(id) ON DELETE CASCADE +-- book_generation.generationId -> generation(id) ON DELETE CASCADE -- line.bookId -> book(id) ON DELETE CASCADE -- line.tocEntryId -> tocEntry(id) ON DELETE SET NULL -- tocEntry.bookId -> book(id) ON DELETE CASCADE @@ -136,6 +138,8 @@ CREATE TABLE IF NOT EXISTS book ( title TEXT NOT NULL, heRef TEXT, heShortDesc TEXT, + -- Full (long) description in Hebrew. heShortDesc holds the one-line summary. + heDesc TEXT, -- Optional raw notes attached to the base book (when a companion file 'הערות על <title>' exists) notesContent TEXT, orderIndex INTEGER NOT NULL DEFAULT 999, @@ -207,6 +211,35 @@ CREATE TABLE IF NOT EXISTS book_author ( CREATE INDEX IF NOT EXISTS idx_book_author_book ON book_author(bookId); CREATE INDEX IF NOT EXISTS idx_book_author_author ON book_author(authorId); +-- Generation (era / period). Seeded from otzaria-library/ForDB/סדר הדורות.csv +-- by the dedicated :sefariasqlite:seedGenerations task (runs after appendOtzaria +-- so both Sefaria- and Otzaria-stage books are linkable). Registered in +-- PatchTables / LogicalContentHasher so generation/book_generation changes ride +-- the delta pipeline like any other table. Safe on client DBs that predate this +-- table even though the delta applier does not run Schema.create: the app calls +-- SeforimDb.Schema.create() at every repository init, and CREATE TABLE IF NOT +-- EXISTS materialises the (empty) tables before any patch is applied, so the +-- patch's INSERTs find a table to write into. IDs are SQLite rowids (not +-- IdAllocator-stable) — only `name` is meaningful as an external key, but ids +-- are deterministic across builds as long as the CSV and seeding code are +-- unchanged, keeping the delta minimal. +CREATE TABLE IF NOT EXISTS generation ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS book_generation ( + bookId INTEGER NOT NULL, + generationId INTEGER NOT NULL, + PRIMARY KEY (bookId, generationId), + FOREIGN KEY (bookId) REFERENCES book(id) ON DELETE CASCADE, + FOREIGN KEY (generationId) REFERENCES generation(id) ON DELETE CASCADE +); + +-- Composite PK already indexes (bookId, generationId), which covers bookId +-- leftmost-prefix lookups. Only generationId needs an explicit index. +CREATE INDEX IF NOT EXISTS idx_book_generation_generation ON book_generation(generationId); + -- Lines table CREATE TABLE IF NOT EXISTS line ( id INTEGER PRIMARY KEY NOT NULL, diff --git a/dao/src/iosMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/PlatformSupport.ios.kt b/dao/src/iosMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/PlatformSupport.ios.kt new file mode 100644 index 00000000..c76dede7 --- /dev/null +++ b/dao/src/iosMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/PlatformSupport.ios.kt @@ -0,0 +1,31 @@ +package io.github.kdroidfilter.seforimlibrary.dao.repository + +import platform.Foundation.NSLock + +// Thread-safe map for native: backing map by delegation, with direct get/put operations +// guarded by an NSLock. +private class NsLockMutableMap<K, V>( + private val backing: MutableMap<K, V> = LinkedHashMap(), +) : MutableMap<K, V> by backing { + private val lock = NSLock() + + override fun get(key: K): V? { + lock.lock() + try { + return backing[key] + } finally { + lock.unlock() + } + } + + override fun put(key: K, value: V): V? { + lock.lock() + try { + return backing.put(key, value) + } finally { + lock.unlock() + } + } +} + +internal actual fun <K, V> newConcurrentMap(): MutableMap<K, V> = NsLockMutableMap() diff --git a/dao/src/iosMain/kotlin/io/github/kdroidfilter/seforimlibrary/env/EnvIos.kt b/dao/src/iosMain/kotlin/io/github/kdroidfilter/seforimlibrary/env/EnvIos.kt new file mode 100644 index 00000000..c4af909b --- /dev/null +++ b/dao/src/iosMain/kotlin/io/github/kdroidfilter/seforimlibrary/env/EnvIos.kt @@ -0,0 +1,4 @@ +package io.github.kdroidfilter.seforimlibrary.env + +// ponytail: stub — env vars are not meaningful on iOS for this app. Returns null. +actual fun getEnvironmentVariable(name: String): String? = null diff --git a/dao/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/env/EnvJvm.kt b/dao/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/env/EnvJvm.kt deleted file mode 100644 index f632acb0..00000000 --- a/dao/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/env/EnvJvm.kt +++ /dev/null @@ -1,4 +0,0 @@ -package io.github.kdroidfilter.seforimlibrary.env - -actual fun getEnvironmentVariable(name: String): String? = System.getenv(name) - diff --git a/generator/common/build.gradle.kts b/generator/common/build.gradle.kts index 8cd67370..ef5c336e 100644 --- a/generator/common/build.gradle.kts +++ b/generator/common/build.gradle.kts @@ -80,7 +80,7 @@ tasks.register<JavaExec>("producePatchAndVerify") { listOf( "releaseMeta", "fullBundleUrl", "fullBundleSha", "fullBundleSize", "manifestBaseUrl", "fromSchemaVersion", "toSchemaVersion", - "catalogPb", + "catalogPb", "zstdLevel", ).forEach { key -> project.findProperty(key)?.let { systemProperty(key, it as String) } } diff --git a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalContentHasher.kt b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalContentHasher.kt index 32eabf76..0367e242 100644 --- a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalContentHasher.kt +++ b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalContentHasher.kt @@ -79,6 +79,7 @@ class LogicalContentHasher( "pub_place", "pub_date", "connection_type", + "generation", "category", "category_closure", "tocText", @@ -87,6 +88,7 @@ class LogicalContentHasher( "book_author", "book_pub_place", "book_pub_date", + "book_generation", "tocEntry", "line", "line_toc", diff --git a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchDbProducer.kt b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchDbProducer.kt index a3de1b2c..d8055e9d 100644 --- a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchDbProducer.kt +++ b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchDbProducer.kt @@ -52,10 +52,10 @@ class PatchDbProducer( conn.autoCommit = false applyBaseDdl(conn) writeMetadata(conn, fromVersion, toVersion) - writeMigrations(conn, migrations) - attach(conn, "prev", prevDb) attach(conn, "new", newDb) + val nextMigrationVersion = (migrations.maxOfOrNull { it.first } ?: 0) + 1 + writeMigrations(conn, migrations + inferCreateTableMigrations(conn, nextMigrationVersion)) // Materialise upsert_/delete_ tables based on the new DB's actual // schema. The producer is generic — every table in our config list @@ -126,6 +126,53 @@ class PatchDbProducer( } } + private fun inferCreateTableMigrations(conn: Connection, firstVersion: Int): List<Pair<Int, String>> { + val out = ArrayList<Pair<Int, String>>() + var version = firstVersion + for (table in PATCH_TABLES_IN_FK_ORDER) { + if (!tableExists(conn, "new", table.name)) continue + if (tableExists(conn, "prev", table.name)) continue + + readCreateSql(conn, "new", "table", table.name)?.let { sql -> + out += version++ to sql + } + readIndexSqlForTable(conn, "new", table.name).forEach { sql -> + out += version++ to sql + } + } + return out + } + + private fun readCreateSql(conn: Connection, schemaAlias: String, type: String, name: String): String? { + conn.prepareStatement( + "SELECT sql FROM $schemaAlias.sqlite_master WHERE type=? AND name=? AND sql IS NOT NULL", + ).use { ps -> + ps.setString(1, type) + ps.setString(2, name) + ps.executeQuery().use { rs -> + return if (rs.next()) rs.getString(1) else null + } + } + } + + private fun readIndexSqlForTable(conn: Connection, schemaAlias: String, table: String): List<String> { + val out = ArrayList<String>() + conn.prepareStatement( + """ + SELECT sql + FROM $schemaAlias.sqlite_master + WHERE type='index' AND tbl_name=? AND sql IS NOT NULL + ORDER BY name + """.trimIndent(), + ).use { ps -> + ps.setString(1, table) + ps.executeQuery().use { rs -> + while (rs.next()) out += rs.getString(1) + } + } + return out + } + private fun attach(conn: Connection, alias: String, path: Path) { conn.prepareStatement("ATTACH DATABASE ? AS $alias").use { ps -> ps.setString(1, path.toAbsolutePath().toString()) @@ -141,6 +188,14 @@ class PatchDbProducer( val cols = PatchDbSchema.readTableInfo(conn, "new", table.name).map { it.name } if (cols.isEmpty()) return 0 val colsCsv = cols.joinToString(",") { "\"$it\"" } + if (!tableExists(conn, "prev", table.name)) { + val sql = """ + INSERT INTO "upsert_${table.name}" ($colsCsv) + SELECT $colsCsv + FROM new."${table.name}" + """.trimIndent() + return conn.createStatement().use { it.executeUpdate(sql) } + } val joinCond = table.primaryKey.joinToString(" AND ") { "new.\"$it\" = prev.\"$it\"" } val nonPkCols = cols.filter { it !in table.primaryKey } // Use SQLite's `IS NOT` so NULL is treated as a distinct value from @@ -193,6 +248,7 @@ class PatchDbProducer( private fun assertNoSecondaryUniqueCollisions(conn: Connection) { for (table in PATCH_TABLES_IN_FK_ORDER) { if (!tableExists(conn, "new", table.name)) continue + if (!tableExists(conn, "prev", table.name)) continue if (!tableExists(conn, "main", "upsert_${table.name}")) continue val pkCols = table.primaryKey if (pkCols.isEmpty()) continue diff --git a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchPipelineCli.kt b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchPipelineCli.kt index bf1c8686..8e101f30 100644 --- a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchPipelineCli.kt +++ b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchPipelineCli.kt @@ -62,11 +62,13 @@ fun main(args: Array<String>) { } DriverManager.getConnection("jdbc:sqlite:${target.toAbsolutePath()}").use { conn -> conn.createStatement().use { it.execute("PRAGMA foreign_keys = ON") } - // We don't pass expectedToContentHash yet: the producer ships upserts - // for the canonical FK-tracked tables but does NOT yet handle the - // junction / derived tables (line_toc except special-cased, alt_toc_*, - // book_has_links, book_*pub_place / book_*pub_date / book_topic / book_author, - // schema_meta). Those are tracked as a Phase 4.5 follow-up. + // The producer ships upserts/deletes for every table in + // PATCH_TABLES_IN_FK_ORDER (including the book_* junctions and + // book_generation). We still don't pass expectedToContentHash into + // apply(): the hash equality is checked below as a soft gate (warn, not + // throw) so a mismatch on a not-yet-fully-covered derived table doesn't + // fail the whole pipeline. Tighten to a hard assert once every tracked + // table is confirmed to round-trip. PatchApplier(logger).apply(conn = conn, patchDb = outPath) val appliedHash = LogicalContentHasher().compute(conn) if (appliedHash == newHash) { @@ -75,8 +77,8 @@ fun main(args: Array<String>) { logger.w { "Patch applied without errors but logical content hash differs " + "(applied=$appliedHash, expected=$newHash). " + - "Phase 4 MVP scope: producer/applier cover single-id tables + line_toc. " + - "Junction / derived tables are Phase 4.5 follow-up." + "Soft gate: a tracked table did not round-trip exactly — " + + "inspect with diagnoseHashMismatch before trusting this patch." } } } diff --git a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTables.kt b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTables.kt index 38568714..7906b491 100644 --- a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTables.kt +++ b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchTables.kt @@ -36,6 +36,7 @@ internal val PATCH_TABLES_IN_FK_ORDER: List<PatchTable> = listOf( PatchTable("pub_date", listOf("id"), updatable = true), PatchTable("connection_type", listOf("id"), updatable = true), PatchTable("tocText", listOf("id"), updatable = true), + PatchTable("generation", listOf("id"), updatable = true), // Self-ref tree — categories. parentId FK is self → same table, OK. PatchTable("category", listOf("id"), updatable = true), @@ -44,12 +45,13 @@ internal val PATCH_TABLES_IN_FK_ORDER: List<PatchTable> = listOf( // Book — depends on category + source. PatchTable("book", listOf("id"), updatable = true), - // Book-attribute junctions — depend on book + author/topic/pubPlace/pubDate. - PatchTable("book_author", listOf("bookId", "authorId"), updatable = false), - PatchTable("book_topic", listOf("bookId", "topicId"), updatable = false), - PatchTable("book_pub_place", listOf("bookId", "pubPlaceId"), updatable = false), - PatchTable("book_pub_date", listOf("bookId", "pubDateId"), updatable = false), - PatchTable("book_acronym", listOf("bookId", "term"), updatable = false), + // Book-attribute junctions — depend on book + author/topic/pubPlace/pubDate/generation. + PatchTable("book_author", listOf("bookId", "authorId"), updatable = false), + PatchTable("book_topic", listOf("bookId", "topicId"), updatable = false), + PatchTable("book_pub_place", listOf("bookId", "pubPlaceId"), updatable = false), + PatchTable("book_pub_date", listOf("bookId", "pubDateId"), updatable = false), + PatchTable("book_acronym", listOf("bookId", "term"), updatable = false), + PatchTable("book_generation", listOf("bookId", "generationId"), updatable = false), // TOC. tocEntry FK to line (lineId) and line FK to tocEntry (tocEntryId) // form a cycle, broken at apply time with PRAGMA defer_foreign_keys = ON. diff --git a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchSchemaEvolutionTest.kt b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchSchemaEvolutionTest.kt new file mode 100644 index 00000000..21d43465 --- /dev/null +++ b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchSchemaEvolutionTest.kt @@ -0,0 +1,90 @@ +package io.github.kdroidfilter.seforimlibrary.common.patch + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.nio.file.Files +import java.nio.file.Path +import java.sql.Connection +import java.sql.DriverManager +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PatchSchemaEvolutionTest { + @JvmField @Rule + val tmp = TemporaryFolder() + + @Test + fun `producer emits migrations and upserts when target adds a tracked table`() { + val prev = tmp.newFolder().toPath().resolve("prev.db") + val next = tmp.newFolder().toPath().resolve("next.db") + val patch = tmp.newFolder().toPath().resolve("patch.db") + val target = tmp.newFolder().toPath().resolve("target.db") + + buildPreviousDb(prev) + buildNextDb(next) + + val produced = PatchDbProducer().produce(prev, next, patch, fromVersion = 1, toVersion = 2) + assertEquals(2, produced.upsertCounts.getValue("generation")) + assertEquals(2, produced.upsertCounts.getValue("book_generation")) + + Files.copy(prev, target) + DriverManager.getConnection("jdbc:sqlite:${target.toAbsolutePath()}").use { conn -> + conn.createStatement().use { it.execute("PRAGMA foreign_keys = ON") } + val applied = PatchApplier().apply(conn, patch) + + assertTrue(applied.migrationsApplied >= 2, "expected table-create migrations to be applied") + assertEquals(2, countRows(conn, "generation")) + assertEquals(2, countRows(conn, "book_generation")) + assertEquals(logicalHash(next), LogicalContentHasher().compute(conn)) + } + } + + private fun buildPreviousDb(path: Path) { + DriverManager.getConnection("jdbc:sqlite:${path.toAbsolutePath()}").use { conn -> + conn.createStatement().use { st -> + st.executeUpdate("CREATE TABLE schema_meta (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL)") + st.executeUpdate("INSERT INTO schema_meta(key, value) VALUES ('db_version', '1')") + st.executeUpdate("CREATE TABLE book (id INTEGER PRIMARY KEY NOT NULL, title TEXT NOT NULL)") + st.executeUpdate("INSERT INTO book(id, title) VALUES (10, 'Genesis'), (11, 'Exodus')") + } + } + } + + private fun buildNextDb(path: Path) { + DriverManager.getConnection("jdbc:sqlite:${path.toAbsolutePath()}").use { conn -> + conn.createStatement().use { st -> + st.executeUpdate("CREATE TABLE schema_meta (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL)") + st.executeUpdate("INSERT INTO schema_meta(key, value) VALUES ('db_version', '2')") + st.executeUpdate("CREATE TABLE book (id INTEGER PRIMARY KEY NOT NULL, title TEXT NOT NULL)") + st.executeUpdate("INSERT INTO book(id, title) VALUES (10, 'Genesis'), (11, 'Exodus')") + st.executeUpdate("CREATE TABLE generation (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL UNIQUE)") + st.executeUpdate(""" + CREATE TABLE book_generation ( + bookId INTEGER NOT NULL, + generationId INTEGER NOT NULL, + PRIMARY KEY (bookId, generationId), + FOREIGN KEY (bookId) REFERENCES book(id) ON DELETE CASCADE, + FOREIGN KEY (generationId) REFERENCES generation(id) ON DELETE CASCADE + ) + """.trimIndent()) + st.executeUpdate("CREATE INDEX idx_book_generation_generation ON book_generation(generationId)") + st.executeUpdate("INSERT INTO generation(id, name) VALUES (1, 'Rishonim'), (2, 'Acharonim')") + st.executeUpdate("INSERT INTO book_generation(bookId, generationId) VALUES (10, 1), (11, 2)") + } + } + } + + private fun countRows(conn: Connection, table: String): Long = + conn.createStatement().use { st -> + st.executeQuery("SELECT COUNT(*) FROM \"$table\"").use { rs -> + rs.next() + rs.getLong(1) + } + } + + private fun logicalHash(path: Path): String = + DriverManager.getConnection("jdbc:sqlite:${path.toAbsolutePath()}").use { + LogicalContentHasher().compute(it) + } +} diff --git a/generator/otzariasqlite/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/otzariasqlite/Generator.kt b/generator/otzariasqlite/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/otzariasqlite/Generator.kt index 97c4bc00..2745d874 100644 --- a/generator/otzariasqlite/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/otzariasqlite/Generator.kt +++ b/generator/otzariasqlite/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/otzariasqlite/Generator.kt @@ -154,8 +154,8 @@ class DatabaseGenerator( private fun normalizeCategorySegments(rawTitle: String): List<String> { val cleaned = normalizeHebrewLabel(rawTitle) return when (cleaned) { - "תלמוד בבלי" -> listOf("תלמוד", "בבלי") - "תלמוד ירושלמי", "תלמוד ירושלים" -> listOf("תלמוד", "ירושלמי") + "תלמוד בבלי" -> listOf("תלמוד בבלי") + "תלמוד ירושלמי", "תלמוד ירושלים" -> listOf("תלמוד ירושלמי") "תנך", "תנ\"ך", "תנ״ך" -> listOf("תנ״ך") "שות", "שו\"ת", "שו״ת" -> listOf("שו״ת") else -> listOf(cleaned) diff --git a/generator/sefariasqlite/build.gradle.kts b/generator/sefariasqlite/build.gradle.kts index 354705eb..352c1c46 100644 --- a/generator/sefariasqlite/build.gradle.kts +++ b/generator/sefariasqlite/build.gradle.kts @@ -74,13 +74,37 @@ tasks.register<JavaExec>("generateSefariaSqlite") { ) } +// Generation seeding — runs after appendOtzaria so Otzaria books are linked too. +// Usage: +// ./gradlew :sefariasqlite:seedGenerations +// ./gradlew :sefariasqlite:seedGenerations -PseforimDb=/path/to/seforim.db +tasks.register<JavaExec>("seedGenerations") { + group = "application" + description = "Seed generation table and book_generation links from otzaria-library/ForDB/סדר הדורות.csv." + + dependsOn("jvmJar") + mainClass.set("io.github.kdroidfilter.seforimlibrary.sefariasqlite.SeedGenerationsPostProcessKt") + classpath = files(tasks.named("jvmJar")) + configurations.getByName("jvmRuntimeClasspath") + + if (project.hasProperty("seforimDb")) { + systemProperty("seforimDb", project.property("seforimDb") as String) + } else if (System.getenv("SEFORIM_DB") != null) { + systemProperty("seforimDb", System.getenv("SEFORIM_DB")) + } else { + val defaultDbPath = rootProject.layout.buildDirectory.file("seforim.db").get().asFile.absolutePath + systemProperty("seforimDb", defaultDbPath) + } + + jvmArgs = listOf("-Xmx512m") +} + // Post-processing step to rename categories after all generation is complete // Usage: // ./gradlew :sefariasqlite:renameCategories // ./gradlew :sefariasqlite:renameCategories -PseforimDb=/path/to/seforim.db tasks.register<JavaExec>("renameCategories") { group = "application" - description = "Rename 'פירושים מודרניים' categories to 'מחברי זמננו' after generation." + description = "Apply category renames, book renames, and book moves from otzaria-library/ForDB/." dependsOn("jvmJar") mainClass.set("io.github.kdroidfilter.seforimlibrary.sefariasqlite.RenameCategoriesPostProcessKt") diff --git a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/RenameCategoriesPostProcess.kt b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/RenameCategoriesPostProcess.kt index c0b63979..4591c04a 100644 --- a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/RenameCategoriesPostProcess.kt +++ b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/RenameCategoriesPostProcess.kt @@ -2,27 +2,64 @@ package io.github.kdroidfilter.seforimlibrary.sefariasqlite import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity +import io.github.kdroidfilter.seforimlibrary.common.OptimizedHttpClient +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.nio.charset.StandardCharsets import java.nio.file.Paths import java.sql.Connection import java.sql.DriverManager +import java.util.zip.ZipInputStream import kotlin.io.path.exists import kotlin.system.exitProcess /** - * Post-processing step to rename and merge categories in the database. - * This runs after Sefaria import but before Otzaria, so categories are unified - * before additional books are added. + * Post-processing step to rename and merge categories, rename books, and + * relocate books between categories. Runs after Sefaria import but before + * Otzaria, so naming is unified before additional books are added. * - * Handles two cases: + * Rules are downloaded from otzaria-library/ForDB/ on GitHub (UTF-8): + * - category_renames.csv `old,new` — category renames (exact match, explicit prefix rules) + * - book_renames.csv `old,new` — book title renames (exact match) + * - book_moves.csv `name,sourcePath,destPath` (simple CSV; embedded newlines in quoted fields are not supported) + * + * Rules are fetched from the fixed `fordb-latest` GitHub release asset. + * Download failures are fatal because silently skipping these rules can produce + * an invalid DB delta. + * + * Category renames handle two cases: * 1. Simple rename: When no target category exists under the same parent * 2. Merge: When a target category already exists, books are moved and source is deleted * + * Book moves require the source and destination category paths to already exist; + * missing paths fail the task (no auto-creation). + * + * Order of operations: category renames → book renames → book moves. Paths in + * book_moves.csv must therefore reference the POST-rename category names. + * If a move references a pre-rename destPath, the task fails with a clear error. + * * Usage: * ./gradlew -p SeforimLibrary :sefariasqlite:renameCategories -PseforimDb=/path/to/seforim.db * * Env alternatives: * SEFORIM_DB */ +private const val FOR_DB_RELEASE_API = + "https://api.github.com/repos/Otzaria/otzaria-library/releases/tags/fordb-latest" +private const val FOR_DB_ARCHIVE_NAME = "fordb_latest.zip" +private const val FOR_DB_USER_AGENT = "SeforimLibrary-ForDBFetcher/1.0" +internal val FOR_DB_CSV_FILES = mapOf( + "categoryRenames" to "category_renames.csv", + "bookRenames" to "book_renames.csv", + "bookMoves" to "book_moves.csv", + "generations" to "generations.csv", +) + fun main(args: Array<String>) { Logger.setMinSeverity(Severity.Info) val logger = Logger.withTag("RenameCategories") @@ -40,24 +77,17 @@ fun main(args: Array<String>) { logger.i { "Renaming/merging categories in $dbPath" } - // Category renames: old name -> new name - // If a category with the new name already exists under the same parent, - // books will be moved and the old category deleted (merge). - val categoryRenames = mapOf( - // Modern commentaries -> מחברי זמננו - "פירושים מודרניים על התנ״ך" to "מחברי זמננו", - "פירושים מודרניים על התלמוד" to "מחברי זמננו", - "פירושים מודרניים על המשנה" to "מחברי זמננו", - // Modern literature -> מחברי זמננו - "ספרות מודרנית" to "מחברי זמננו", - // Sefaria-specific categories to Otzaria-style - "ראשונים על התנ״ך" to "ראשונים", - "אחרונים על התנ״ך" to "אחרונים", - "ראשונים על התלמוד" to "ראשונים", - "אחרונים על התלמוד" to "אחרונים", - "ראשונים על המשנה" to "ראשונים", - "אחרונים על המשנה" to "אחרונים" - ) + // Rules downloaded from otzaria-library/ForDB/ at startup. + // category_renames.csv and book_renames.csv have no header; book_moves.csv has one. + val categoryRenames: List<CategoryRename> = + parseCategoryRenames(downloadRequiredForDbCsv(FOR_DB_CSV_FILES.getValue("categoryRenames"), logger)) + val bookRenames: List<Pair<String, String>> = + parsePairs( + downloadRequiredForDbCsv(FOR_DB_CSV_FILES.getValue("bookRenames"), logger), + FOR_DB_CSV_FILES.getValue("bookRenames") + ) + val bookMoves: List<BookMove> = + parseBookMoves(downloadRequiredForDbCsv(FOR_DB_CSV_FILES.getValue("bookMoves"), logger), logger) try { DriverManager.getConnection("jdbc:sqlite:$dbPath").use { conn -> @@ -65,29 +95,147 @@ fun main(args: Array<String>) { var totalRenamed = 0 var totalMerged = 0 - - for ((oldName, newName) in categoryRenames) { - val result = renameOrMergeCategory(conn, oldName, newName, logger) + runSection("Category renames", categoryRenames, logger) { rule -> + val result = renameOrMergeCategory(conn, rule, logger) when (result) { is RenameResult.Renamed -> totalRenamed += result.count is RenameResult.Merged -> totalMerged += result.booksMoved - is RenameResult.NotFound -> { /* skip */ } + is RenameResult.NotFound -> logger.i { + "Category rename: '${rule.oldName}' not found; no rows changed" + } } + result.rows() } + val booksRenamed = runSection("Book renames", bookRenames, logger) { (oldTitle, newTitle) -> + renameBookTitle(conn, oldTitle, newTitle, logger) + } + + val booksMoved = runSection("Book moves", bookMoves, logger) { move -> applyBookMove(conn, move, logger) } + conn.commit() - logger.i { "Category processing complete. Renamed: $totalRenamed, Merged: $totalMerged books" } + logger.i { + "Post-process done: categories renamed=$totalRenamed merged=$totalMerged; " + + "books renamed=$booksRenamed; books moved=$booksMoved" + } } } catch (e: Exception) { - logger.e(e) { "Failed to process categories" } + logger.e(e) { "Failed to open or commit DB; aborting" } exitProcess(1) } } +/** `old,new` rows — ignores blank rows and fails on malformed non-blank rows. */ +internal fun parsePairs(lines: List<String>, sourceName: String = "pairs CSV"): List<Pair<String, String>> = + parseRequiredCsvRows(lines, sourceName, minFields = 2).map { f -> f[0] to f[1] } + +private enum class CategoryMatchMode { Exact, Prefix } + +private data class CategoryRename( + val oldName: String, + val newName: String, + val matchMode: CategoryMatchMode, +) + +private val EXPLICIT_CATEGORY_PREFIX_RULES = setOf( + "ראשונים על", + "אחרונים על", + "פירושים מודרניים", +) + +private fun parseCategoryRenames(lines: List<String>): List<CategoryRename> = + parseRequiredCsvRows(lines, FOR_DB_CSV_FILES.getValue("categoryRenames"), minFields = 2).map { f -> + val matchMode = if (f[0] in EXPLICIT_CATEGORY_PREFIX_RULES) { + CategoryMatchMode.Prefix + } else { + CategoryMatchMode.Exact + } + CategoryRename(oldName = f[0], newName = f[1], matchMode = matchMode) + }.distinct() + +// Fields are kept verbatim (no trim) so rules can exact-match DB values that +// carry edge spaces. +internal fun parseRequiredCsvRows(lines: List<String>, sourceName: String, minFields: Int): List<List<String>> = + lines.mapIndexedNotNull { index, line -> + val fields = parseForDbCsvLine(line) + if (fields.all { it.isBlank() }) return@mapIndexedNotNull null + require(fields.size >= minFields && fields.take(minFields).none { it.isBlank() }) { + "$sourceName row ${index + 1} is malformed: $line" + } + fields + } + +internal fun parseForDbCsvLine(line: String): List<String> { + val result = mutableListOf<String>() + val sb = StringBuilder() + var inQuotes = false + var i = 0 + while (i < line.length) { + val c = line[i] + if (inQuotes) { + if (c == '"') { + if (i + 1 < line.length && line[i + 1] == '"') { + sb.append('"') + i++ + } else { + inQuotes = false + } + } else { + sb.append(c) + } + } else { + when (c) { + '"' -> if (sb.isEmpty()) inQuotes = true else sb.append(c) + ',' -> { + result += sb.toString() + sb.setLength(0) + } + else -> sb.append(c) + } + } + i++ + } + result += sb.toString() + return result +} + +/** + * `name,Source path,Destination path` rows. Drops the header row if present; + * detection requires both `source path` and `destination path` tokens so a + * stray book title containing the word "path" can't be mistaken for a header. + * Missing or malformed headers fail the task so release data issues are not + * hidden. + */ +private fun parseBookMoves(lines: List<String>, logger: Logger): List<BookMove> { + val firstLower = lines.firstOrNull()?.lowercase() + val isHeader = firstLower != null && "source path" in firstLower && "destination path" in firstLower + require(isHeader) { + "${FOR_DB_CSV_FILES.getValue("bookMoves")} must start with a header containing Source path and Destination path" + } + return parseRequiredCsvRows(lines.drop(1), FOR_DB_CSV_FILES.getValue("bookMoves"), minFields = 3) + .map { f -> BookMove(f[0], f[1], f[2]) } + .also { logger.i { "Loaded ${it.size} book move rule(s)" } } +} + +private fun <T> runSection(name: String, items: List<T>, logger: Logger, apply: (T) -> Int): Int { + var applied = 0 + for (item in items) { + applied += apply(item) + } + logger.i { "$name: applied=$applied" } + return applied +} + private sealed class RenameResult { data class Renamed(val count: Int) : RenameResult() data class Merged(val booksMoved: Int) : RenameResult() data object NotFound : RenameResult() + + fun rows(): Int = when (this) { + is Renamed -> count + is Merged -> booksMoved + is NotFound -> 0 + } } /** @@ -97,22 +245,12 @@ private sealed class RenameResult { */ private fun renameOrMergeCategory( conn: Connection, - oldName: String, - newName: String, + rule: CategoryRename, logger: Logger ): RenameResult { - // Find all source categories with oldName - val sourceCats = mutableListOf<Pair<Long, Long?>>() // (id, parentId) - conn.prepareStatement("SELECT id, parentId FROM category WHERE title = ?").use { stmt -> - stmt.setString(1, oldName) - stmt.executeQuery().use { rs -> - while (rs.next()) { - val id = rs.getLong(1) - val parentId = rs.getObject(2) as? Long - sourceCats.add(id to parentId) - } - } - } + val oldName = rule.oldName + val newName = rule.newName + val sourceCats = findSourceCategories(conn, rule) if (sourceCats.isEmpty()) { return RenameResult.NotFound @@ -190,3 +328,183 @@ private fun deleteCategory(conn: Connection, categoryId: Long) { stmt.executeUpdate() } } + +/** + * Category prefix rules are intentionally limited to [EXPLICIT_CATEGORY_PREFIX_RULES]. + * All other rows are exact-match only. + */ +private fun findSourceCategories(conn: Connection, rule: CategoryRename): List<Pair<Long, Long?>> { + fun query(sql: String, param: String): List<Pair<Long, Long?>> { + val rows = mutableListOf<Pair<Long, Long?>>() + conn.prepareStatement(sql).use { stmt -> + stmt.setString(1, param) + stmt.executeQuery().use { rs -> + while (rs.next()) { + val parent = rs.getLong(2).let { if (rs.wasNull()) null else it } + rows.add(rs.getLong(1) to parent) + } + } + } + return rows + } + + return when (rule.matchMode) { + CategoryMatchMode.Exact -> + query("SELECT id, parentId FROM category WHERE title = ?", rule.oldName) + CategoryMatchMode.Prefix -> { + val likePattern = rule.oldName.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%" + query("SELECT id, parentId FROM category WHERE title LIKE ? ESCAPE '\\'", likePattern) + } + } +} + +private fun renameBookTitle(conn: Connection, oldTitle: String, newTitle: String, logger: Logger): Int { + val ids = findBookIdsByTitle(conn, oldTitle) + if (ids.isEmpty()) { + val alreadyRenamed = findBookIdsByTitle(conn, newTitle) + if (alreadyRenamed.isNotEmpty()) { + logger.i { "Book rename '$oldTitle' -> '$newTitle' already applied (${alreadyRenamed.size} row(s))" } + return 0 + } + logger.w { "Book rename: '$oldTitle' not found; no rows changed" } + return 0 + } + require(ids.size == 1) { + "Book rename '$oldTitle' -> '$newTitle' matched ${ids.size} books" + } + val n = conn.prepareStatement("UPDATE book SET title = ? WHERE id = ?").use { stmt -> + stmt.setString(1, newTitle) + stmt.setLong(2, ids.single()) + stmt.executeUpdate() + } + logger.i { "Renamed book '$oldTitle' -> '$newTitle' ($n rows)" } + return n +} + +private fun findBookIdsByTitle(conn: Connection, title: String): List<Long> = + conn.prepareStatement("SELECT id FROM book WHERE title = ?").use { stmt -> + stmt.setString(1, title) + stmt.executeQuery().use { rs -> + buildList { + while (rs.next()) add(rs.getLong(1)) + } + } + } + +private data class BookMove(val name: String, val sourcePath: String, val destPath: String) + +/** + * Resolves sourcePath/destPath against the existing category tree and updates + * the matching book's categoryId. Missing paths or ambiguous books fail the task. + */ +private fun applyBookMove(conn: Connection, move: BookMove, logger: Logger): Int { + val sourceCatId = resolveCategoryPath(conn, move.sourcePath) + ?: error("Book move source '${move.sourcePath}' not found for '${move.name}'") + val destCatId = resolveCategoryPath(conn, move.destPath) + ?: error("Book move destination '${move.destPath}' not found for '${move.name}'") + + val candidates = mutableListOf<Pair<Long, Long>>() // (bookId, categoryId) + conn.prepareStatement("SELECT id, categoryId FROM book WHERE title = ?").use { stmt -> + stmt.setString(1, move.name) + stmt.executeQuery().use { rs -> + while (rs.next()) candidates.add(rs.getLong(1) to rs.getLong(2)) + } + } + require(candidates.isNotEmpty()) { "Book move: '${move.name}' not found" } + + candidates.singleOrNull { it.second == destCatId }?.let { (bookId, _) -> + logger.i { "Book move '${move.name}' already applied (id=$bookId, dest=${move.destPath})" } + return 0 + } + val sourceMatches = candidates.filter { it.second == sourceCatId } + require(sourceMatches.size == 1) { + "Book move '${move.name}' has ${candidates.size} candidate(s); source '${move.sourcePath}' matched ${sourceMatches.size}" + } + val bookId = sourceMatches.single().first + + conn.prepareStatement("UPDATE book SET categoryId = ? WHERE id = ?").use { stmt -> + stmt.setLong(1, destCatId) + stmt.setLong(2, bookId) + stmt.executeUpdate() + } + logger.i { "Moved book '${move.name}' (id=$bookId) -> '${move.destPath}' (catId=$destCatId)" } + return 1 +} + +/** Walks `a/b/c` from category roots; returns null on the first missing segment. */ +private fun resolveCategoryPath(conn: Connection, path: String): Long? { + val segments = path.split('/').map { it.trim() }.filter { it.isNotEmpty() } + if (segments.isEmpty()) return null + var parentId: Long? = null + for (segment in segments) { + parentId = findCategoryByNameAndParent(conn, segment, parentId) ?: return null + } + return parentId +} + +internal fun downloadRequiredForDbCsv(fileName: String, logger: Logger): List<String> { + return forDbReleaseCsvs(logger).getValue(fileName) +} + +private var cachedForDbCsvs: Map<String, List<String>>? = null + +private fun forDbReleaseCsvs(logger: Logger): Map<String, List<String>> { + cachedForDbCsvs?.let { return it } + + val archiveUrl = forDbReleaseArchiveUrl(logger) + logger.i { "Downloading ForDB release archive from $archiveUrl" } + val csvs = try { + OptimizedHttpClient.downloadStream( + url = archiveUrl, + userAgent = FOR_DB_USER_AGENT, + logger = logger + ).stream.use { stream -> + readForDbZip(stream) + } + } catch (e: Exception) { + logger.e(e) { "Failed to download or read required ForDB release archive; aborting" } + throw IllegalStateException("Failed to load required ForDB release archive", e) + } + + val missing = FOR_DB_CSV_FILES.values.filterNot { it in csvs } + check(missing.isEmpty()) { + "ForDB release archive is missing required file(s): ${missing.joinToString()}" + } + + cachedForDbCsvs = csvs + return csvs +} + +private fun forDbReleaseArchiveUrl(logger: Logger): String { + val body = OptimizedHttpClient.fetchJson(FOR_DB_RELEASE_API, FOR_DB_USER_AGENT, logger) + val assets = Json.parseToJsonElement(body).jsonObject.getValue("assets").jsonArray + val asset = assets.firstOrNull { + it.jsonObject["name"]?.jsonPrimitive?.content == FOR_DB_ARCHIVE_NAME + } ?: throw IllegalStateException("No $FOR_DB_ARCHIVE_NAME asset found in fordb-latest release") + return asset.jsonObject.getValue("url").jsonPrimitive.content +} + +private fun readForDbZip(stream: InputStream): Map<String, List<String>> { + val csvs = mutableMapOf<String, List<String>>() + ZipInputStream(stream).use { zip -> + var entry = zip.nextEntry + while (entry != null) { + if (!entry.isDirectory) { + val normalizedName = entry.name.replace('\\', '/') + if (normalizedName.startsWith("ForDB/")) { + val fileName = normalizedName.removePrefix("ForDB/") + if ('/' !in fileName && fileName.endsWith(".csv")) { + val bytes = ByteArrayOutputStream() + zip.copyTo(bytes) + csvs[fileName] = ByteArrayInputStream(bytes.toByteArray()) + .bufferedReader(StandardCharsets.UTF_8) + .readLines() + } + } + } + zip.closeEntry() + entry = zip.nextEntry + } + } + return csvs +} diff --git a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SeedGenerationsPostProcess.kt b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SeedGenerationsPostProcess.kt new file mode 100644 index 00000000..8c1f740b --- /dev/null +++ b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SeedGenerationsPostProcess.kt @@ -0,0 +1,176 @@ +package io.github.kdroidfilter.seforimlibrary.sefariasqlite + +import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity +import java.nio.file.Paths +import java.sql.Connection +import java.sql.DriverManager +import kotlin.io.path.exists +import kotlin.system.exitProcess + +/** + * Seeds the `generation` table and links books to their generation, driven by + * otzaria-library/ForDB/generations.csv (`שם ספר,קבוצת דור` with header). + * + * Runs AFTER appendOtzaria so both Sefaria- and Otzaria-stage books are + * considered. Linking is purely by book title — no transitive author-level + * propagation — so per-book generations in the CSV are preserved (e.g. the + * empty-author bucket where books legitimately span eras, אברבנאל's + * ראשונים/אחרונים split). + * + * Download failures are fatal because silently skipping generation seeding can + * produce an invalid DB delta. + * + * Usage: + * ./gradlew :sefariasqlite:seedGenerations -PseforimDb=/path/to/seforim.db + * + * Env alternatives: + * SEFORIM_DB + */ +private val GENERATIONS_FILE = FOR_DB_CSV_FILES.getValue("generations") + +fun main(args: Array<String>) { + Logger.setMinSeverity(Severity.Info) + val logger = Logger.withTag("SeedGenerations") + + val dbPathStr = args.getOrNull(0) + ?: System.getProperty("seforimDb") + ?: System.getenv("SEFORIM_DB") + ?: Paths.get("build", "seforim.db").toString() + val dbPath = Paths.get(dbPathStr) + + if (!dbPath.exists()) { + logger.e { "DB not found at $dbPath" } + exitProcess(1) + } + + logger.i { "Seeding generations in $dbPath" } + + val rows = parseGenerations(downloadRequiredForDbCsv(GENERATIONS_FILE, logger), logger) + + try { + DriverManager.getConnection("jdbc:sqlite:$dbPath").use { conn -> + conn.autoCommit = false + + val result = try { + applyGenerations(conn, rows, logger).also { + conn.commit() + } + } catch (e: Exception) { + runCatching { conn.rollback() }.onFailure { logger.w(it) { "Rollback failed" } } + throw e + } + + logger.i { + "Generations done: seeded=${result.generationsCreated} " + + "book links=${result.linksCreated} unmatched=${result.unmatched}" + } + } + } catch (e: Exception) { + logger.e(e) { "Failed to seed generations; aborting" } + exitProcess(1) + } +} + +/** + * `שם ספר,קבוצת דור` rows. Requires the header row, ignores blank rows with a + * visible warning, and fails on malformed non-blank data rows. + */ +private fun parseGenerations(lines: List<String>, logger: Logger): List<Pair<String, String>> { + val sourceName = GENERATIONS_FILE + val blankRows = lines.count { parseForDbCsvLine(it).all { field -> field.trim().isEmpty() } } + if (blankRows > 0) { + logger.w { "$sourceName: ignoring $blankRows blank row(s)" } + } + val nonBlank = lines.filter { it.isNotBlank() } + require(nonBlank.isNotEmpty()) { "$sourceName is empty" } + require("שם ספר" in nonBlank.first()) { "$sourceName must start with a שם ספר header" } + val duplicateHeader = nonBlank.drop(1).indexOfFirst { "שם ספר" in parseForDbCsvLine(it).firstOrNull().orEmpty() } + require(duplicateHeader < 0) { + "$sourceName contains a duplicate header at non-blank row ${duplicateHeader + 2}" + } + return parseRequiredCsvRows(nonBlank.drop(1), sourceName, minFields = 2) + .map { f -> f[0] to f[1] } +} + +private data class GenerationApplyResult( + val generationsCreated: Int, + val linksCreated: Int, + val unmatched: Int, +) + +/** + * Seeds the `generation` table with distinct names and links each book to its + * generation via `book_generation`. Matching is exact by book title; data + * mismatches fail the task so the CSV can be corrected instead of guessed. + * INSERT OR IGNORE keeps re-runs idempotent. + */ +private fun applyGenerations( + conn: Connection, + rows: List<Pair<String, String>>, + logger: Logger, +): GenerationApplyResult { + if (rows.isEmpty()) return GenerationApplyResult(0, 0, 0) + + var generationsCreated = 0 + conn.prepareStatement("INSERT OR IGNORE INTO generation(name) VALUES (?)").use { stmt -> + for (name in rows.map { it.second }.distinct()) { + stmt.setString(1, name) + generationsCreated += stmt.executeUpdate() + } + } + + val nameToId = HashMap<String, Long>() + conn.createStatement().use { st -> + st.executeQuery("SELECT id, name FROM generation").use { rs -> + while (rs.next()) nameToId[rs.getString(2)] = rs.getLong(1) + } + } + + val books = ArrayList<Pair<Long, String>>() + conn.createStatement().use { st -> + st.executeQuery("SELECT id, title FROM book").use { rs -> + while (rs.next()) books += rs.getLong(1) to rs.getString(2) + } + } + val exactMap = books.groupBy({ it.second }, { it.first }) + + var linksCreated = 0 + val unmatchedTitles = mutableListOf<String>() + conn.prepareStatement("INSERT OR IGNORE INTO book_generation(bookId, generationId) VALUES (?, ?)").use { linkStmt -> + for ((bookTitle, genName) in rows) { + val genId = nameToId[genName] ?: continue + val bookId = findBookIdForGeneration(exactMap, bookTitle, logger) + if (bookId == null) { + unmatchedTitles += bookTitle + continue + } + linkStmt.setLong(1, bookId) + linkStmt.setLong(2, genId) + linksCreated += linkStmt.executeUpdate() + } + } + require(unmatchedTitles.isEmpty()) { + "Generation CSV has ${unmatchedTitles.size} unmatched book title(s): " + + unmatchedTitles.take(20).joinToString() + } + return GenerationApplyResult(generationsCreated, linksCreated, 0) +} + +// `book.title` is not UNIQUE in the schema, so even an exact match can return +// more than one row. Fail rather than arbitrarily picking one. +private fun findBookIdForGeneration( + exactMap: Map<String, List<Long>>, + title: String, + logger: Logger, +): Long? { + exactMap[title]?.let { return pickOne(it, title, "exact", logger) } + return null +} + +private fun pickOne(matches: List<Long>, title: String, tier: String, logger: Logger): Long? = + if (matches.size == 1) matches.single() + else { + logger.e { "Generation link: '$title' has multiple $tier matches" } + error("Generation link '$title' has ${matches.size} $tier matches") + } diff --git a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaBookPayloadReader.kt b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaBookPayloadReader.kt index 457070f8..1362d0c0 100644 --- a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaBookPayloadReader.kt +++ b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaBookPayloadReader.kt @@ -117,6 +117,7 @@ internal class SefariaBookPayloadReader( authors = authors ) val description = extractDescription(schemaJson, schemaObj) + val heShortDesc = extractShortDescription(schemaJson, schemaObj) val pubDates = extractPubDates(schemaJson, schemaObj) val altStructures = parseAltStructures(schemaJson) val dependence = extractDependence(schemaJson, schemaObj) @@ -138,12 +139,13 @@ internal class SefariaBookPayloadReader( BookPayload( heTitle = hebrewTitle, enTitle = englishTitle, - categoriesHe = categories.map { sanitizeFolder(it) }, + categoriesHe = flattenTalmudCategories(categories.map { sanitizeFolder(it) }), lines = lines, refEntries = refs, headings = headings, authors = authors, description = description, + heShortDesc = heShortDesc, pubDates = pubDates, altStructures = altStructures, dependence = dependence, @@ -268,6 +270,17 @@ internal class SefariaBookPayloadReader( ?: schemaObj["heDescription"]?.stringOrNull() } + /** + * Sefaria's real one-line summary (`heShortDesc`), distinct from the long + * `heDesc` read by [extractDescription]. Kept separate so `book.heShortDesc` + * holds the summary and `book.heDesc` the full text — previously the long + * text was stored under `heShortDesc` and this field went unused. + */ + private fun extractShortDescription(schemaJson: JsonObject, schemaObj: JsonObject): String? { + return schemaJson["heShortDesc"]?.stringOrNull() + ?: schemaObj["heShortDesc"]?.stringOrNull() + } + private fun extractPubDates(schemaJson: JsonObject, schemaObj: JsonObject): List<PubDate> { val dates = mutableListOf<String>() fun collect(key: String, obj: JsonObject) { diff --git a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaDirectImporter.kt b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaDirectImporter.kt index e8057d99..0d2ee0b3 100644 --- a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaDirectImporter.kt +++ b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaDirectImporter.kt @@ -243,7 +243,8 @@ class SefariaDirectImporter( authors = resolvedAuthors, pubPlaces = emptyList(), pubDates = resolvedPubDates, - heShortDesc = payload.description, + heShortDesc = payload.heShortDesc, + heDesc = payload.description, notesContent = null, order = bookOrder, topics = emptyList(), diff --git a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportModels.kt b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportModels.kt index 035f899b..6418d8e5 100644 --- a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportModels.kt +++ b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportModels.kt @@ -51,7 +51,10 @@ internal data class BookPayload( val refEntries: List<RefEntry>, val headings: List<Heading>, val authors: List<String>, + // Long description (Sefaria heDesc) → book.heDesc val description: String?, + // Short one-line summary (Sefaria heShortDesc) → book.heShortDesc + val heShortDesc: String?, val pubDates: List<PubDate>, val altStructures: List<AltStructurePayload>, // Schema metadata used for link orientation. baseTextTitleKeys holds the diff --git a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportOrdering.kt b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportOrdering.kt index bfbdbcc0..cad8e7c1 100644 --- a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportOrdering.kt +++ b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportOrdering.kt @@ -55,12 +55,16 @@ internal fun parseTableOfContentsOrders( val heCategory = item["heCategory"]?.jsonPrimitive?.contentOrNull if (order != null && categoryPath.isNotEmpty()) { if (category != null) { - val fullPath = (categoryPath + category).joinToString("/") + val fullPath = flattenTalmudCategories( + categoryPath.map { sanitizeFolder(it) } + sanitizeFolder(category) + ).joinToString("/") categoryOrders[fullPath] = order categoryOrders[sanitizeFolder(fullPath)] = order } if (heCategory != null) { - val fullPath = (categoryPath + heCategory).joinToString("/") + val fullPath = flattenTalmudCategories( + categoryPath.map { sanitizeFolder(it) } + sanitizeFolder(heCategory) + ).joinToString("/") categoryOrders[fullPath] = order categoryOrders[sanitizeFolder(fullPath)] = order } @@ -108,7 +112,35 @@ internal fun parseTableOfContentsOrders( internal fun normalizePriorityEntry(raw: String): String { var entry = raw.trim().replace('\\', '/') if (entry.startsWith("/")) entry = entry.removePrefix("/") - return entry.split('/').filter { it.isNotBlank() }.joinToString("/") { sanitizeFolder(it) } + val parts = entry.split('/').filter { it.isNotBlank() }.map { sanitizeFolder(it) } + return flattenTalmudCategories(parts).joinToString("/") +} + +internal fun flattenTalmudCategories(parts: List<String>): List<String> { + if (parts.isEmpty()) return parts + val flattened = ArrayList<String>(parts.size) + var idx = 0 + while (idx < parts.size) { + val part = parts[idx] + if (part == "תלמוד" && idx + 1 < parts.size) { + val next = parts[idx + 1] + when (next) { + "בבלי" -> { + flattened += "תלמוד בבלי" + idx += 2 + continue + } + "ירושלמי" -> { + flattened += "תלמוד ירושלמי" + idx += 2 + continue + } + } + } + flattened += part + idx += 1 + } + return flattened } internal fun normalizedBookPath(categories: List<String>, heTitle: String): String = diff --git a/generator/sefariasqlite/src/jvmMain/resources/books_blacklist.txt b/generator/sefariasqlite/src/jvmMain/resources/books_blacklist.txt index 64c7973e..11fd128e 100644 --- a/generator/sefariasqlite/src/jvmMain/resources/books_blacklist.txt +++ b/generator/sefariasqlite/src/jvmMain/resources/books_blacklist.txt @@ -203,6 +203,7 @@ השותפות הגדולה; הדת, המדע והחיפוש אחר משמעות אם הבנים שמחה משפטי עוזיאל + נוסח הכתובה סידור אשכנז ספר היובלים diff --git a/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json b/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json index 6efe80cc..dde53cbf 100644 --- a/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json +++ b/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json @@ -57,14 +57,15 @@ { "book": "במדבר", "commentators": [ - "רשי על במדבר" + "רשי על במדבר", + "רמבן על במדבר" ] }, { "book": "בראשית", "commentators": [ "רשי על בראשית", - "רדק על בראשית" + "רמבן על בראשית" ] }, { @@ -85,24 +86,25 @@ "book": "דברי הימים א", "commentators": [ "רשי על דברי הימים א", + "רדק על דברי הימים א׳", "מצודת דוד על דברי הימים א", - "מצודת ציון על דברי הימים א", - "רדק על דברי הימים א׳" + "מצודת ציון על דברי הימים א" ] }, { "book": "דברי הימים ב", "commentators": [ "רשי על דברי הימים ב", + "רדק על דברי הימים ב", "מצודת דוד על דברי הימים ב", - "מצודת ציון על דברי הימים ב", - "רדק על דברי הימים ב" + "מצודת ציון על דברי הימים ב" ] }, { "book": "דברים", "commentators": [ - "רשי על דברים" + "רשי על דברים", + "רמבן על דברים" ] }, { @@ -124,15 +126,16 @@ "book": "הושע", "commentators": [ "רשי על הושע", + "רדק על הושע", "מצודת דוד על הושע", - "מצודת ציון על הושע", - "רדק על הושע" + "מצודת ציון על הושע" ] }, { "book": "ויקרא", "commentators": [ - "רשי על ויקרא" + "רשי על ויקרא", + "רמבן על ויקרא" ] }, { @@ -146,27 +149,27 @@ "book": "זכריה", "commentators": [ "רשי על זכריה", + "רדק על זכריה", "מצודת דוד על זכריה", - "מצודת ציון על זכריה", - "רדק על זכריה" + "מצודת ציון על זכריה" ] }, { "book": "חבקוק", "commentators": [ "רשי על חבקוק", + "רדק על חבקוק", "מצודת דוד על חבקוק", - "מצודת ציון על חבקוק", - "רדק על חבקוק" + "מצודת ציון על חבקוק" ] }, { "book": "חגי", "commentators": [ "רשי על חגי", + "רדק על חגי", "מצודת דוד על חגי", - "מצודת ציון על חגי", - "רדק על חגי" + "מצודת ציון על חגי" ] }, { @@ -203,18 +206,18 @@ "book": "יהושע", "commentators": [ "רשי על יהושע", + "רדק על יהושע", "מצודת דוד על יהושע", - "מצודת ציון על יהושע", - "רדק על יהושע" + "מצודת ציון על יהושע" ] }, { "book": "יואל", "commentators": [ "רשי על יואל", + "רדק על יואל", "מצודת דוד על יואל", - "מצודת ציון על יואל", - "רדק על יואל" + "מצודת ציון על יואל" ] }, { @@ -228,36 +231,36 @@ "book": "יונה", "commentators": [ "רשי על יונה", + "רדק על יונה", "מצודת דוד על יונה", - "מצודת ציון על יונה", - "רדק על יונה" + "מצודת ציון על יונה" ] }, { "book": "יחזקאל", "commentators": [ "רשי על יחזקאל", + "רדק על יחזקאל", "מצודת דוד על יחזקאל", - "מצודת ציון על יחזקאל", - "רדק על יחזקאל" + "מצודת ציון על יחזקאל" ] }, { "book": "ירמיהו", "commentators": [ "רשי על ירמיהו", + "רדק על ירמי׳", "מצודת דוד על ירמיהו", - "מצודת ציון על ירמיהו", - "רדק על ירמי׳" + "מצודת ציון על ירמיהו" ] }, { "book": "ישעיהו", "commentators": [ "רשי על ישעיהו", + "רדק על ישעי׳", "מצודת דוד על ישעיהו", - "מצודת ציון על ישעיהו", - "רדק על ישעי׳" + "מצודת ציון על ישעיהו" ] }, { @@ -292,9 +295,9 @@ "book": "מיכה", "commentators": [ "רשי על מיכה", + "רדק על מיכה", "מצודת דוד על מיכה", - "מצודת ציון על מיכה", - "רדק על מיכה" + "מצודת ציון על מיכה" ] }, { @@ -308,27 +311,27 @@ "book": "מלאכי", "commentators": [ "רשי על מלאכי", + "רדק על מלאכי", "מצודת דוד על מלאכי", - "מצודת ציון על מלאכי", - "רדק על מלאכי" + "מצודת ציון על מלאכי" ] }, { "book": "מלכים א", "commentators": [ "רשי על מלכים א", + "רדק על מלכים א׳", "מצודת דוד על מלכים א", - "מצודת ציון על מלכים א", - "רדק על מלכים א׳" + "מצודת ציון על מלכים א" ] }, { "book": "מלכים ב", "commentators": [ "רשי על מלכים ב", + "רדק על מלכים ב", "מצודת דוד על מלכים ב", - "מצודת ציון על מלכים ב", - "רדק על מלכים ב" + "מצודת ציון על מלכים ב" ] }, { @@ -378,9 +381,9 @@ "book": "נחום", "commentators": [ "רשי על נחום", + "רדק על נחום", "מצודת דוד על נחום", - "מצודת ציון על נחום", - "רדק על נחום" + "מצודת ציון על נחום" ] }, { @@ -423,9 +426,9 @@ "book": "עובדיה", "commentators": [ "רשי על עובדיה", + "רדק על עובדיה", "מצודת דוד על עובדיה", - "מצודת ציון על עובדיה", - "רדק על עובדיה" + "מצודת ציון על עובדיה" ] }, { @@ -447,9 +450,9 @@ "book": "עמוס", "commentators": [ "רשי על עמוס", + "רדק על עמוס", "מצודת דוד על עמוס", - "מצודת ציון על עמוס", - "רדק על עמוס" + "מצודת ציון על עמוס" ] }, { @@ -470,9 +473,9 @@ "book": "צפניה", "commentators": [ "רשי על צפניה", + "רדק על צפניה", "מצודת דוד על צפניה", - "מצודת ציון על צפניה", - "רדק על צפניה" + "מצודת ציון על צפניה" ] }, { @@ -514,44 +517,50 @@ "book": "שבת", "commentators": [ "רשי על שבת", - "תוספות על בבא בתרא" + "תוספות על שבת" ] }, { "book": "שולחן ערוך, אבן העזר", "commentators": [ "חלקת מחוקק", - "בית שמואל" + "בית שמואל", + "באר הגולה על שולחן ערוך אבן העזר" ] }, { "book": "שולחן ערוך, אורח חיים", "commentators": [ "מגן אברהם", - "משנה ברורה" + "טורי זהב על שולחן ערוך אורח חיים", + "משנה ברורה", + "שער הציון", + "באר הגולה על שולחן ערוך אורח חיים" ] }, { "book": "שולחן ערוך, חושן משפט", "commentators": [ "שפתי כהן על שולחן ערוך חושן משפט", - "מאירת עיניים על שולחן ערוך חושן משפט" + "מאירת עיניים על שולחן ערוך חושן משפט", + "באר הגולה על שולחן ערוך חושן משפט" ] }, { "book": "שולחן ערוך, יורה דעה", "commentators": [ "שפתי כהן על שולחן ערוך יורה דעה", - "טורי זהב על שולחן ערוך יורה דעה" + "טורי זהב על שולחן ערוך יורה דעה", + "באר הגולה על שולחן ערוך יורה דעה" ] }, { "book": "שופטים", "commentators": [ "רשי על שופטים", + "רדק על שופטים", "מצודת דוד על שופטים", - "מצודת ציון על שופטים", - "רדק על שופטים" + "מצודת ציון על שופטים" ] }, { @@ -566,33 +575,34 @@ "book": "שמואל א", "commentators": [ "רשי על שמואל א", + "רדק על שמואל א", "מצודת דוד על שמואל א", - "מצודת ציון על שמואל א", - "רדק על שמואל א" + "מצודת ציון על שמואל א" ] }, { "book": "שמואל ב", "commentators": [ "רשי על שמואל ב", + "רדק על שמואל ב", "מצודת דוד על שמואל ב", - "מצודת ציון על שמואל ב", - "רדק על שמואל ב" + "מצודת ציון על שמואל ב" ] }, { "book": "שמות", "commentators": [ - "רשי על שמות" + "רשי על שמות", + "רמבן על שמות" ] }, { "book": "תהילים", "commentators": [ "רשי על תהילים", + "רדק על תהילים", "מצודת דוד על תהילים", - "מצודת ציון על תהילים", - "רדק על תהילים" + "מצודת ציון על תהילים" ] }, { @@ -613,939 +623,1111 @@ "book": "משנה אבות", "commentators": [ "ברטנורא על משנה אבות", - "עיקר תוספות יום טוב על משנה אבות" + "תוספות יום טוב על משנה אבות", + "יכין אבות" ] }, { "book": "משנה אהלות", "commentators": [ "ברטנורא על משנה אהלות", - "עיקר תוספות יום טוב על משנה אהלות" + "תוספות יום טוב על משנה אהלות", + "יכין אהלות" ] }, { "book": "משנה בבא בתרא", "commentators": [ "ברטנורא על משנה בבא בתרא", - "עיקר תוספות יום טוב על משנה בבא בתרא" + "תוספות יום טוב על משנה בבא בתרא", + "יכין בבא בתרא" ] }, { "book": "משנה בבא מציעא", "commentators": [ "ברטנורא על משנה בבא מציעא", - "עיקר תוספות יום טוב על משנה בבא מציעא" + "תוספות יום טוב על משנה בבא מציעא", + "יכין בבא מציעא" ] }, { "book": "משנה בבא קמא", "commentators": [ "ברטנורא על משנה בבא קמא", - "עיקר תוספות יום טוב על משנה בבא קמא" + "תוספות יום טוב על משנה בבא קמא", + "יכין בבא קמא" ] }, { "book": "משנה ביכורים", "commentators": [ "ברטנורא על משנה ביכורים", - "עיקר תוספות יום טוב על משנה ביכורים" + "תוספות יום טוב על משנה ביכורים", + "יכין ביכורים" ] }, { "book": "משנה ביצה", "commentators": [ "ברטנורא על משנה ביצה", - "עיקר תוספות יום טוב על משנה ביצה" + "תוספות יום טוב על משנה ביצה", + "יכין ביצה" ] }, { "book": "משנה בכורות", "commentators": [ "ברטנורא על משנה בכורות", - "עיקר תוספות יום טוב על משנה בכורות" + "תוספות יום טוב על משנה בכורות", + "יכין בכורות" ] }, { "book": "משנה ברכות", "commentators": [ "ברטנורא על משנה ברכות", - "עיקר תוספות יום טוב על משנה ברכות" + "תוספות יום טוב על משנה ברכות", + "יכין ברכות" ] }, { "book": "משנה גיטין", "commentators": [ "ברטנורא על משנה גיטין", - "עיקר תוספות יום טוב על משנה גיטין" + "תוספות יום טוב על משנה גיטין", + "יכין גיטין" ] }, { "book": "משנה דמאי", "commentators": [ "ברטנורא על משנה דמאי", - "עיקר תוספות יום טוב על משנה דמאי" + "תוספות יום טוב על משנה דמאי", + "יכין דמאי" ] }, { "book": "משנה הוריות", "commentators": [ "ברטנורא על משנה הוריות", - "עיקר תוספות יום טוב על משנה הוריות" + "תוספות יום טוב על משנה הוריות", + "יכין הוריות" ] }, { "book": "משנה זבחים", "commentators": [ "ברטנורא על משנה זבחים", - "עיקר תוספות יום טוב על משנה זבחים" + "תוספות יום טוב על משנה זבחים", + "יכין זבחים" ] }, { "book": "משנה זבים", "commentators": [ "ברטנורא על משנה זבים", - "עיקר תוספות יום טוב על משנה זבים" + "תוספות יום טוב על משנה זבים", + "יכין זבים" ] }, { "book": "משנה חגיגה", "commentators": [ "ברטנורא על משנה חגיגה", - "עיקר תוספות יום טוב על משנה חגיגה" + "תוספות יום טוב על משנה חגיגה", + "יכין חגיגה" ] }, { "book": "משנה חולין", "commentators": [ "ברטנורא על משנה חולין", - "עיקר תוספות יום טוב על משנה חולין" + "תוספות יום טוב על משנה חולין", + "יכין חולין" ] }, { "book": "משנה חלה", "commentators": [ "ברטנורא על משנה חלה", - "עיקר תוספות יום טוב על משנה חלה" + "תוספות יום טוב על משנה חלה", + "יכין חלה" ] }, { "book": "משנה טבול יום", "commentators": [ "ברטנורא על משנה טבול יום", - "עיקר תוספות יום טוב על משנה טבול יום" + "תוספות יום טוב על משנה טבול יום", + "יכין טבול יום" ] }, { "book": "משנה טהרות", "commentators": [ "ברטנורא על משנה טהרות", - "עיקר תוספות יום טוב על משנה טהרות" + "תוספות יום טוב על משנה טהרות", + "יכין טהרות" ] }, { "book": "משנה יבמות", "commentators": [ "ברטנורא על משנה יבמות", - "עיקר תוספות יום טוב על משנה יבמות" + "תוספות יום טוב על משנה יבמות", + "יכין יבמות" ] }, { "book": "משנה ידים", "commentators": [ "ברטנורא על משנה ידים", - "עיקר תוספות יום טוב על משנה ידים" + "תוספות יום טוב על משנה ידים", + "יכין ידים" ] }, { "book": "משנה יומא", "commentators": [ "ברטנורא על משנה יומא", - "עיקר תוספות יום טוב על משנה יומא" + "תוספות יום טוב על משנה יומא", + "יכין יומא" ] }, { "book": "משנה כלאים", "commentators": [ "ברטנורא על משנה כלאים", - "עיקר תוספות יום טוב על משנה כלאים" + "תוספות יום טוב על משנה כלאים", + "יכין כלאים" ] }, { "book": "משנה כלים", "commentators": [ "ברטנורא על משנה כלים", - "עיקר תוספות יום טוב על משנה כלים" + "תוספות יום טוב על משנה כלים", + "יכין כלים" ] }, { "book": "משנה כריתות", "commentators": [ "ברטנורא על משנה כריתות", - "עיקר תוספות יום טוב על משנה כריתות" + "תוספות יום טוב על משנה כריתות", + "יכין כריתות" ] }, { "book": "משנה כתובות", "commentators": [ "ברטנורא על משנה כתובות", - "עיקר תוספות יום טוב על משנה כתובות" + "תוספות יום טוב על משנה כתובות", + "יכין כתובות" ] }, { "book": "משנה מגילה", "commentators": [ "ברטנורא על משנה מגילה", - "עיקר תוספות יום טוב על משנה מגילה" + "תוספות יום טוב על משנה מגילה", + "יכין מגילה" ] }, { "book": "משנה מדות", "commentators": [ "ברטנורא על משנה מדות", - "עיקר תוספות יום טוב על משנה מדות" + "תוספות יום טוב על משנה מדות", + "יכין מדות" ] }, { "book": "משנה מועד קטן", "commentators": [ "ברטנורא על משנה מועד קטן", - "עיקר תוספות יום טוב על משנה מועד קטן" + "תוספות יום טוב על משנה מועד קטן", + "יכין מועד קטן" ] }, { "book": "משנה מכות", "commentators": [ "ברטנורא על משנה מכות", - "עיקר תוספות יום טוב על משנה מכות" + "תוספות יום טוב על משנה מכות", + "יכין מכות" ] }, { "book": "משנה מכשירין", "commentators": [ "ברטנורא על משנה מכשירין", - "עיקר תוספות יום טוב על משנה מכשירין" + "תוספות יום טוב על משנה מכשירין", + "יכין מכשירין" ] }, { "book": "משנה מנחות", "commentators": [ "ברטנורא על משנה מנחות", - "עיקר תוספות יום טוב על משנה מנחות" + "תוספות יום טוב על משנה מנחות", + "יכין מנחות" ] }, { "book": "משנה מעילה", "commentators": [ "ברטנורא על משנה מעילה", - "עיקר תוספות יום טוב על משנה מעילה" + "תוספות יום טוב על משנה מעילה", + "יכין מעילה" ] }, { "book": "משנה מעשר שני", "commentators": [ "ברטנורא על משנה מעשר שני", - "עיקר תוספות יום טוב על משנה מעשר שני" + "תוספות יום טוב על משנה מעשר שני", + "יכין מעשר שני" ] }, { "book": "משנה מעשרות", "commentators": [ "ברטנורא על משנה מעשרות", - "עיקר תוספות יום טוב על משנה מעשרות" + "תוספות יום טוב על משנה מעשרות", + "יכין מעשרות" ] }, { "book": "משנה מקואות", "commentators": [ "ברטנורא על משנה מקואות", - "עיקר תוספות יום טוב על משנה מקואות" + "תוספות יום טוב על משנה מקואות", + "יכין מקואות" ] }, { "book": "משנה נגעים", "commentators": [ "ברטנורא על משנה נגעים", - "עיקר תוספות יום טוב על משנה נגעים" + "תוספות יום טוב על משנה נגעים", + "יכין נגעים" ] }, { "book": "משנה נדה", "commentators": [ "ברטנורא על משנה נדה", - "עיקר תוספות יום טוב על משנה נדה" + "תוספות יום טוב על משנה נדה", + "יכין נדה" ] }, { "book": "משנה נדרים", "commentators": [ "ברטנורא על משנה נדרים", - "עיקר תוספות יום טוב על משנה נדרים" + "תוספות יום טוב על משנה נדרים", + "יכין נדרים" ] }, { "book": "משנה נזיר", "commentators": [ "ברטנורא על משנה נזיר", - "עיקר תוספות יום טוב על משנה נזיר" + "תוספות יום טוב על משנה נזיר", + "יכין נזיר" ] }, { "book": "משנה סוטה", "commentators": [ "ברטנורא על משנה סוטה", - "עיקר תוספות יום טוב על משנה סוטה" + "תוספות יום טוב על משנה סוטה", + "יכין סוטה" ] }, { "book": "משנה סוכה", "commentators": [ "ברטנורא על משנה סוכה", - "עיקר תוספות יום טוב על משנה סוכה" + "תוספות יום טוב על משנה סוכה", + "יכין סוכה" ] }, { "book": "משנה סנהדרין", "commentators": [ "ברטנורא על משנה סנהדרין", - "עיקר תוספות יום טוב על משנה סנהדרין" + "תוספות יום טוב על משנה סנהדרין", + "יכין סנהדרין" ] }, { "book": "משנה עבודה זרה", "commentators": [ "ברטנורא על משנה עבודה זרה", - "עיקר תוספות יום טוב על משנה עבודה זרה" + "תוספות יום טוב על משנה עבודה זרה", + "יכין עבודה זרה" ] }, { "book": "משנה עדיות", "commentators": [ "ברטנורא על משנה עדיות", - "עיקר תוספות יום טוב על משנה עדיות" + "תוספות יום טוב על משנה עדיות", + "יכין עדיות" ] }, { "book": "משנה עוקצים", "commentators": [ "ברטנורא על משנה עוקצים", - "עיקר תוספות יום טוב על משנה עוקצים" + "תוספות יום טוב על משנה עוקצים", + "יכין עוקצים" ] }, { "book": "משנה עירובין", "commentators": [ "ברטנורא על משנה עירובין", - "עיקר תוספות יום טוב על משנה עירובין" + "תוספות יום טוב על משנה עירובין", + "יכין עירובין" ] }, { "book": "משנה ערכין", "commentators": [ "ברטנורא על משנה ערכין", - "עיקר תוספות יום טוב על משנה ערכין" + "תוספות יום טוב על משנה ערכין", + "יכין ערכין" ] }, { "book": "משנה ערלה", "commentators": [ "ברטנורא על משנה ערלה", - "עיקר תוספות יום טוב על משנה ערלה" + "תוספות יום טוב על משנה ערלה", + "יכין ערלה" ] }, { "book": "משנה פאה", "commentators": [ "ברטנורא על משנה פאה", - "עיקר תוספות יום טוב על משנה פאה" + "תוספות יום טוב על משנה פאה", + "יכין פאה" ] }, { "book": "משנה פסחים", "commentators": [ "ברטנורא על משנה פסחים", - "עיקר תוספות יום טוב על משנה פסחים" + "תוספות יום טוב על משנה פסחים", + "יכין פסחים" ] }, { "book": "משנה פרה", "commentators": [ "ברטנורא על משנה פרה", - "עיקר תוספות יום טוב על משנה פרה" + "תוספות יום טוב על משנה פרה", + "יכין פרה" ] }, { "book": "משנה קידושין", "commentators": [ "ברטנורא על משנה קידושין", - "עיקר תוספות יום טוב על משנה קידושין" + "תוספות יום טוב על משנה קידושין", + "יכין קידושין" ] }, { "book": "משנה קינים", "commentators": [ "ברטנורא על משנה קינים", - "עיקר תוספות יום טוב על משנה קינים" + "תוספות יום טוב על משנה קינים", + "יכין קינים" ] }, { "book": "משנה ראש השנה", "commentators": [ "ברטנורא על משנה ראש השנה", - "עיקר תוספות יום טוב על משנה ראש השנה" + "תוספות יום טוב על משנה ראש השנה", + "יכין ראש השנה" ] }, { "book": "משנה שבועות", "commentators": [ "ברטנורא על משנה שבועות", - "עיקר תוספות יום טוב על משנה שבועות" + "תוספות יום טוב על משנה שבועות", + "יכין שבועות" ] }, { "book": "משנה שביעית", "commentators": [ "ברטנורא על משנה שביעית", - "עיקר תוספות יום טוב על משנה שביעית" + "תוספות יום טוב על משנה שביעית", + "יכין שביעית" ] }, { "book": "משנה שבת", "commentators": [ "ברטנורא על משנה שבת", - "עיקר תוספות יום טוב על משנה שבת" + "תוספות יום טוב על משנה שבת", + "יכין שבת" ] }, { "book": "משנה שקלים", "commentators": [ "ברטנורא על משנה שקלים", - "עיקר תוספות יום טוב על משנה שקלים" + "תוספות יום טוב על משנה שקלים", + "יכין שקלים" ] }, { "book": "משנה תמורה", "commentators": [ "ברטנורא על משנה תמורה", - "עיקר תוספות יום טוב על משנה תמורה" + "תוספות יום טוב על משנה תמורה", + "יכין תמורה" ] }, { "book": "משנה תמיד", "commentators": [ "ברטנורא על משנה תמיד", - "עיקר תוספות יום טוב על משנה תמיד" + "תוספות יום טוב על משנה תמיד", + "יכין תמיד" ] }, { "book": "משנה תענית", "commentators": [ "ברטנורא על משנה תענית", - "עיקר תוספות יום טוב על משנה תענית" + "תוספות יום טוב על משנה תענית", + "יכין תענית" ] }, { "book": "משנה תרומות", "commentators": [ "ברטנורא על משנה תרומות", - "עיקר תוספות יום טוב על משנה תרומות" + "תוספות יום טוב על משנה תרומות", + "יכין תרומות" ] }, { "book": "משנה תורה, הלכות יסודי התורה", "commentators": [ - "כסף משנה על משנה תורה, הלכות יסודי התורה" + "כסף משנה על משנה תורה, הלכות יסודי התורה", + "השגות הראבד על משנה תורה, הלכות יסודי התורה" ] }, { "book": "משנה תורה, הלכות דעות", "commentators": [ - "כסף משנה על משנה תורה, הלכות דעות" + "כסף משנה על משנה תורה, הלכות דעות", + "השגות הראבד על משנה תורה, הלכות דעות" ] }, { "book": "משנה תורה, הלכות תלמוד תורה", "commentators": [ - "כסף משנה על משנה תורה, הלכות תלמוד תורה" + "כסף משנה על משנה תורה, הלכות תלמוד תורה", + "השגות הראבד על משנה תורה, הלכות תלמוד תורה" ] }, { "book": "משנה תורה, הלכות עבודה זרה וחוקות הגויים", "commentators": [ - "כסף משנה על משנה תורה, הלכות עבודה זרה וחוקות הגויים" + "כסף משנה על משנה תורה, הלכות עבודה זרה וחוקות הגויים", + "השגות הראבד על משנה תורה, הלכות עבודה זרה וחוקות הגויים" ] }, { "book": "משנה תורה, הלכות תשובה", "commentators": [ - "כסף משנה על משנה תורה, הלכות תשובה" + "כסף משנה על משנה תורה, הלכות תשובה", + "השגות הראבד על משנה תורה, הלכות תשובה" ] }, { "book": "משנה תורה, הלכות קריאת שמע", "commentators": [ - "כסף משנה על משנה תורה, הלכות קריאת שמע" + "כסף משנה על משנה תורה, הלכות קריאת שמע", + "השגות הראבד על משנה תורה, הלכות קריאת שמע" ] }, { "book": "משנה תורה, הלכות תפילה וברכת כהנים", "commentators": [ - "כסף משנה על משנה תורה, הלכות תפילה וברכת כהנים" + "כסף משנה על משנה תורה, הלכות תפילה וברכת כהנים", + "השגות הראבד על משנה תורה, הלכות תפילה וברכת כהנים" ] }, { "book": "משנה תורה, הלכות תפילין ומזוזה וספר תורה", "commentators": [ - "כסף משנה על משנה תורה, הלכות תפילין ומזוזה וספר תורה" + "כסף משנה על משנה תורה, הלכות תפילין ומזוזה וספר תורה", + "השגות הראבד על משנה תורה, הלכות תפילין ומזוזה וספר תורה" ] }, { "book": "משנה תורה, הלכות ציצית", "commentators": [ - "כסף משנה על משנה תורה, הלכות ציצית" + "כסף משנה על משנה תורה, הלכות ציצית", + "השגות הראבד על משנה תורה, הלכות ציצית" ] }, { "book": "משנה תורה, הלכות ברכות", "commentators": [ - "כסף משנה על משנה תורה, הלכות ברכות" + "כסף משנה על משנה תורה, הלכות ברכות", + "השגות הראבד על משנה תורה, הלכות ברכות" ] }, { "book": "משנה תורה, הלכות מילה", "commentators": [ - "כסף משנה על משנה תורה, הלכות מילה" + "כסף משנה על משנה תורה, הלכות מילה", + "השגות הראבד על משנה תורה, הלכות מילה" ] }, { "book": "משנה תורה, הלכות שבת", "commentators": [ - "כסף משנה על משנה תורה, הלכות שבת" + "כסף משנה על משנה תורה, הלכות שבת", + "מגיד משנה על משנה תורה, הלכות שבת", + "השגות הראבד על משנה תורה, הלכות שבת" ] }, { "book": "משנה תורה, הלכות עירובין", "commentators": [ - "כסף משנה על משנה תורה, הלכות עירובין" + "כסף משנה על משנה תורה, הלכות עירובין", + "מגיד משנה על משנה תורה, הלכות עירובין", + "השגות הראבד על משנה תורה, הלכות עירובין" ] }, { "book": "משנה תורה, הלכות שקלים", "commentators": [ - "כסף משנה על משנה תורה, הלכות שקלים" + "כסף משנה על משנה תורה, הלכות שקלים", + "השגות הראבד על משנה תורה, הלכות שקלים" ] }, { "book": "משנה תורה, הלכות קידוש החודש", "commentators": [ - "כסף משנה על משנה תורה, הלכות קידוש החודש" + "כסף משנה על משנה תורה, הלכות קידוש החודש", + "השגות הראבד על משנה תורה, הלכות קידוש החודש" ] }, { "book": "משנה תורה, הלכות תעניות", "commentators": [ - "כסף משנה על משנה תורה, הלכות תעניות" + "כסף משנה על משנה תורה, הלכות תעניות", + "מגיד משנה על משנה תורה, הלכות תעניות", + "השגות הראבד על משנה תורה, הלכות תעניות" ] }, { "book": "משנה תורה, הלכות מגילה וחנוכה", "commentators": [ - "כסף משנה על משנה תורה, הלכות מגילה וחנוכה" + "כסף משנה על משנה תורה, הלכות מגילה וחנוכה", + "מגיד משנה על משנה תורה, הלכות מגילה וחנוכה", + "השגות הראבד על משנה תורה, הלכות מגילה וחנוכה" ] }, { "book": "משנה תורה, הלכות שופר וסוכה ולולב", "commentators": [ - "כסף משנה על משנה תורה, הלכות שופר וסוכה ולולב" + "כסף משנה על משנה תורה, הלכות שופר וסוכה ולולב", + "מגיד משנה על משנה תורה, הלכות שופר וסוכה ולולב", + "השגות הראבד על משנה תורה, הלכות שופר וסוכה ולולב" ] }, { "book": "משנה תורה, הלכות שביתת יום טוב", "commentators": [ - "כסף משנה על משנה תורה, הלכות שביתת יום טוב" + "כסף משנה על משנה תורה, הלכות שביתת יום טוב", + "מגיד משנה על משנה תורה, הלכות שביתת יום טוב", + "השגות הראבד על משנה תורה, הלכות שביתת יום טוב" ] }, { "book": "משנה תורה, הלכות שביתת עשור", "commentators": [ - "כסף משנה על משנה תורה, הלכות שביתת עשור" + "כסף משנה על משנה תורה, הלכות שביתת עשור", + "מגיד משנה על משנה תורה, הלכות שביתת עשור", + "השגות הראבד על משנה תורה, הלכות שביתת עשור" ] }, { "book": "משנה תורה, הלכות חמץ ומצה", "commentators": [ - "כסף משנה על משנה תורה, הלכות חמץ ומצה" + "כסף משנה על משנה תורה, הלכות חמץ ומצה", + "מגיד משנה על משנה תורה, הלכות חמץ ומצה", + "השגות הראבד על משנה תורה, הלכות חמץ ומצה" ] }, { "book": "משנה תורה, הלכות אישות", "commentators": [ - "כסף משנה על משנה תורה, הלכות אישות" + "כסף משנה על משנה תורה, הלכות אישות", + "מגיד משנה על משנה תורה, הלכות אישות", + "השגות הראבד על משנה תורה, הלכות אישות" ] }, { "book": "משנה תורה, הלכות גירושין", "commentators": [ - "כסף משנה על משנה תורה, הלכות גירושין" + "כסף משנה על משנה תורה, הלכות גירושין", + "מגיד משנה על משנה תורה, הלכות גירושין", + "השגות הראבד על משנה תורה, הלכות גירושין" ] }, { "book": "משנה תורה, הלכות יבום וחליצה", "commentators": [ - "כסף משנה על משנה תורה, הלכות יבום וחליצה" + "כסף משנה על משנה תורה, הלכות יבום וחליצה", + "מגיד משנה על משנה תורה, הלכות יבום וחליצה", + "השגות הראבד על משנה תורה, הלכות יבום וחליצה" ] }, { "book": "משנה תורה, הלכות סוטה", "commentators": [ - "כסף משנה על משנה תורה, הלכות סוטה" + "כסף משנה על משנה תורה, הלכות סוטה", + "השגות הראבד על משנה תורה, הלכות סוטה" ] }, { "book": "משנה תורה, הלכות נערה בתולה", "commentators": [ - "כסף משנה על משנה תורה, הלכות נערה בתולה" + "כסף משנה על משנה תורה, הלכות נערה בתולה", + "השגות הראבד על משנה תורה, הלכות נערה בתולה" ] }, { "book": "משנה תורה, הלכות מאכלות אסורות", "commentators": [ - "כסף משנה על משנה תורה, הלכות מאכלות אסורות" + "כסף משנה על משנה תורה, הלכות מאכלות אסורות", + "מגיד משנה על משנה תורה, הלכות מאכלות אסורות", + "השגות הראבד על משנה תורה, הלכות מאכלות אסורות" ] }, { "book": "משנה תורה, הלכות שחיטה", "commentators": [ - "כסף משנה על משנה תורה, הלכות שחיטה" + "כסף משנה על משנה תורה, הלכות שחיטה", + "מגיד משנה על משנה תורה, הלכות שחיטה", + "השגות הראבד על משנה תורה, הלכות שחיטה" ] }, { "book": "משנה תורה, הלכות איסורי ביאה", "commentators": [ - "כסף משנה על משנה תורה, הלכות איסורי ביאה" + "כסף משנה על משנה תורה, הלכות איסורי ביאה", + "מגיד משנה על משנה תורה, הלכות איסורי ביאה", + "השגות הראבד על משנה תורה, הלכות איסורי ביאה" ] }, { "book": "משנה תורה, הלכות נדרים", "commentators": [ - "כסף משנה על משנה תורה, הלכות נדרים" + "כסף משנה על משנה תורה, הלכות נדרים", + "השגות הראבד על משנה תורה, הלכות נדרים" ] }, { "book": "משנה תורה, הלכות נזירות", "commentators": [ - "כסף משנה על משנה תורה, הלכות נזירות" + "כסף משנה על משנה תורה, הלכות נזירות", + "השגות הראבד על משנה תורה, הלכות נזירות" ] }, { "book": "משנה תורה, הלכות ערכים וחרמין", "commentators": [ - "כסף משנה על משנה תורה, הלכות ערכים וחרמין" + "כסף משנה על משנה תורה, הלכות ערכים וחרמין", + "השגות הראבד על משנה תורה, הלכות ערכים וחרמין" ] }, { "book": "משנה תורה, הלכות שבועות", "commentators": [ - "כסף משנה על משנה תורה, הלכות שבועות" + "כסף משנה על משנה תורה, הלכות שבועות", + "השגות הראבד על משנה תורה, הלכות שבועות" ] }, { "book": "משנה תורה, הלכות תרומות", "commentators": [ - "כסף משנה על משנה תורה, הלכות תרומות" + "כסף משנה על משנה תורה, הלכות תרומות", + "השגות הראבד על משנה תורה, הלכות תרומות" ] }, { "book": "משנה תורה, הלכות מעשרות", "commentators": [ - "כסף משנה על משנה תורה, הלכות מעשרות" + "כסף משנה על משנה תורה, הלכות מעשרות", + "השגות הראבד על משנה תורה, הלכות מעשרות" ] }, { "book": "משנה תורה, הלכות מעשר שני ונטע רבעי", "commentators": [ - "כסף משנה על משנה תורה, הלכות מעשר שני ונטע רבעי" + "כסף משנה על משנה תורה, הלכות מעשר שני ונטע רבעי", + "השגות הראבד על משנה תורה, הלכות מעשר שני ונטע רבעי" ] }, { "book": "משנה תורה, הלכות שמיטה ויובל", "commentators": [ - "כסף משנה על משנה תורה, הלכות שמיטה ויובל" + "כסף משנה על משנה תורה, הלכות שמיטה ויובל", + "השגות הראבד על משנה תורה, הלכות שמיטה ויובל" ] }, { "book": "משנה תורה, הלכות מתנות עניים", "commentators": [ - "כסף משנה על משנה תורה, הלכות מתנות עניים" + "כסף משנה על משנה תורה, הלכות מתנות עניים", + "השגות הראבד על משנה תורה, הלכות מתנות עניים" ] }, { "book": "משנה תורה, הלכות כלאים", "commentators": [ - "כסף משנה על משנה תורה, הלכות כלאים" + "כסף משנה על משנה תורה, הלכות כלאים", + "השגות הראבד על משנה תורה, הלכות כלאים" ] }, { "book": "משנה תורה, הלכות ביכורים ושאר מתנות כהונה שבגבולין", "commentators": [ - "כסף משנה על משנה תורה, הלכות ביכורים ושאר מתנות כהונה שבגבולין" + "כסף משנה על משנה תורה, הלכות ביכורים ושאר מתנות כהונה שבגבולין", + "השגות הראבד על משנה תורה, הלכות ביכורים ושאר מתנות כהונה שבגבולין" ] }, { "book": "משנה תורה, הלכות בית הבחירה", "commentators": [ - "כסף משנה על משנה תורה, הלכות בית הבחירה" + "כסף משנה על משנה תורה, הלכות בית הבחירה", + "השגות הראבד על משנה תורה, הלכות בית הבחירה" ] }, { "book": "משנה תורה, הלכות כלי המקדש והעובדין בו", "commentators": [ - "כסף משנה על משנה תורה, הלכות כלי המקדש והעובדין בו" + "כסף משנה על משנה תורה, הלכות כלי המקדש והעובדין בו", + "השגות הראבד על משנה תורה, הלכות כלי המקדש והעובדין בו" ] }, { "book": "משנה תורה, הלכות איסורי המזבח", "commentators": [ - "כסף משנה על משנה תורה, הלכות איסורי המזבח" + "כסף משנה על משנה תורה, הלכות איסורי המזבח", + "השגות הראבד על משנה תורה, הלכות איסורי המזבח" ] }, { "book": "משנה תורה, הלכות ביאת מקדש", "commentators": [ - "כסף משנה על משנה תורה, הלכות ביאת מקדש" + "כסף משנה על משנה תורה, הלכות ביאת מקדש", + "השגות הראבד על משנה תורה, הלכות ביאת מקדש" ] }, { "book": "משנה תורה, הלכות מעשה הקרבנות", "commentators": [ - "כסף משנה על משנה תורה, הלכות מעשה הקרבנות" + "כסף משנה על משנה תורה, הלכות מעשה הקרבנות", + "השגות הראבד על משנה תורה, הלכות מעשה הקרבנות" ] }, { "book": "משנה תורה, הלכות עבודת יום הכפורים", "commentators": [ - "כסף משנה על משנה תורה, הלכות עבודת יום הכפורים" + "כסף משנה על משנה תורה, הלכות עבודת יום הכפורים", + "השגות הראבד על משנה תורה, הלכות עבודת יום הכפורים" ] }, { "book": "משנה תורה, הלכות פסולי המוקדשין", "commentators": [ - "כסף משנה על משנה תורה, הלכות פסולי המוקדשין" + "כסף משנה על משנה תורה, הלכות פסולי המוקדשין", + "השגות הראבד על משנה תורה, הלכות פסולי המוקדשין" ] }, { "book": "משנה תורה, הלכות תמידים ומוספין", "commentators": [ - "כסף משנה על משנה תורה, הלכות תמידים ומוספין" + "כסף משנה על משנה תורה, הלכות תמידים ומוספין", + "השגות הראבד על משנה תורה, הלכות תמידים ומוספין" ] }, { "book": "משנה תורה, הלכות מעילה", "commentators": [ - "כסף משנה על משנה תורה, הלכות מעילה" + "כסף משנה על משנה תורה, הלכות מעילה", + "השגות הראבד על משנה תורה, הלכות מעילה" ] }, { "book": "משנה תורה, הלכות בכורות", "commentators": [ - "כסף משנה על משנה תורה, הלכות בכורות" + "כסף משנה על משנה תורה, הלכות בכורות", + "השגות הראבד על משנה תורה, הלכות בכורות" ] }, { "book": "משנה תורה, הלכות שגגות", "commentators": [ - "כסף משנה על משנה תורה, הלכות שגגות" + "כסף משנה על משנה תורה, הלכות שגגות", + "השגות הראבד על משנה תורה, הלכות שגגות" ] }, { "book": "משנה תורה, הלכות מחוסרי כפרה", "commentators": [ - "כסף משנה על משנה תורה, הלכות מחוסרי כפרה" + "כסף משנה על משנה תורה, הלכות מחוסרי כפרה", + "השגות הראבד על משנה תורה, הלכות מחוסרי כפרה" ] }, { "book": "משנה תורה, הלכות תמורה", "commentators": [ - "כסף משנה על משנה תורה, הלכות תמורה" + "כסף משנה על משנה תורה, הלכות תמורה", + "השגות הראבד על משנה תורה, הלכות תמורה" ] }, { "book": "משנה תורה, הלכות קרבן פסח", "commentators": [ - "כסף משנה על משנה תורה, הלכות קרבן פסח" + "כסף משנה על משנה תורה, הלכות קרבן פסח", + "השגות הראבד על משנה תורה, הלכות קרבן פסח" ] }, { "book": "משנה תורה, הלכות חגיגה", "commentators": [ - "כסף משנה על משנה תורה, הלכות חגיגה" + "כסף משנה על משנה תורה, הלכות חגיגה", + "השגות הראבד על משנה תורה, הלכות חגיגה" ] }, { "book": "משנה תורה, הלכות נזקי ממון", "commentators": [ - "כסף משנה על משנה תורה, הלכות נזקי ממון" + "כסף משנה על משנה תורה, הלכות נזקי ממון", + "מגיד משנה על משנה תורה, הלכות נזקי ממון", + "השגות הראבד על משנה תורה, הלכות נזקי ממון" ] }, { "book": "משנה תורה, הלכות גזילה ואבידה", "commentators": [ - "כסף משנה על משנה תורה, הלכות גזילה ואבידה" + "כסף משנה על משנה תורה, הלכות גזילה ואבידה", + "מגיד משנה על משנה תורה, הלכות גזילה ואבידה", + "השגות הראבד על משנה תורה, הלכות גזילה ואבידה" ] }, { "book": "משנה תורה, הלכות גניבה", "commentators": [ - "כסף משנה על משנה תורה, הלכות גניבה" + "כסף משנה על משנה תורה, הלכות גניבה", + "מגיד משנה על משנה תורה, הלכות גניבה", + "השגות הראבד על משנה תורה, הלכות גניבה" ] }, { "book": "משנה תורה, הלכות חובל ומזיק", "commentators": [ - "כסף משנה על משנה תורה, הלכות חובל ומזיק" + "כסף משנה על משנה תורה, הלכות חובל ומזיק", + "מגיד משנה על משנה תורה, הלכות חובל ומזיק", + "השגות הראבד על משנה תורה, הלכות חובל ומזיק" ] }, { "book": "משנה תורה, הלכות רוצח ושמירת נפש", "commentators": [ - "כסף משנה על משנה תורה, הלכות רוצח ושמירת נפש" + "כסף משנה על משנה תורה, הלכות רוצח ושמירת נפש", + "השגות הראבד על משנה תורה, הלכות רוצח ושמירת נפש" ] }, { "book": "משנה תורה, הלכות מכירה", "commentators": [ - "כסף משנה על משנה תורה, הלכות מכירה" + "כסף משנה על משנה תורה, הלכות מכירה", + "מגיד משנה על משנה תורה, הלכות מכירה", + "השגות הראבד על משנה תורה, הלכות מכירה" ] }, { "book": "משנה תורה, הלכות זכייה ומתנה", "commentators": [ - "כסף משנה על משנה תורה, הלכות זכייה ומתנה" + "כסף משנה על משנה תורה, הלכות זכייה ומתנה", + "מגיד משנה על משנה תורה, הלכות זכייה ומתנה", + "השגות הראבד על משנה תורה, הלכות זכייה ומתנה" ] }, { "book": "משנה תורה, הלכות שלוחין ושותפין", "commentators": [ - "כסף משנה על משנה תורה, הלכות שלוחין ושותפין" + "כסף משנה על משנה תורה, הלכות שלוחין ושותפין", + "השגות הראבד על משנה תורה, הלכות שלוחין ושותפין" ] }, { "book": "משנה תורה, הלכות עבדים", "commentators": [ - "כסף משנה על משנה תורה, הלכות עבדים" + "כסף משנה על משנה תורה, הלכות עבדים", + "השגות הראבד על משנה תורה, הלכות עבדים" ] }, { "book": "משנה תורה, הלכות שכנים", "commentators": [ - "כסף משנה על משנה תורה, הלכות שכנים" + "כסף משנה על משנה תורה, הלכות שכנים", + "מגיד משנה על משנה תורה, הלכות שכנים", + "השגות הראבד על משנה תורה, הלכות שכנים" ] }, { "book": "משנה תורה, הלכות מלווה ולווה", "commentators": [ - "כסף משנה על משנה תורה, הלכות מלווה ולווה" + "כסף משנה על משנה תורה, הלכות מלווה ולווה", + "מגיד משנה על משנה תורה, הלכות מלווה ולווה", + "השגות הראבד על משנה תורה, הלכות מלווה ולווה" ] }, { "book": "משנה תורה, הלכות שכירות", "commentators": [ - "כסף משנה על משנה תורה, הלכות שכירות" + "כסף משנה על משנה תורה, הלכות שכירות", + "מגיד משנה על משנה תורה, הלכות שכירות", + "השגות הראבד על משנה תורה, הלכות שכירות" ] }, { "book": "משנה תורה, הלכות נחלות", "commentators": [ - "כסף משנה על משנה תורה, הלכות נחלות" + "כסף משנה על משנה תורה, הלכות נחלות", + "מגיד משנה על משנה תורה, הלכות נחלות", + "השגות הראבד על משנה תורה, הלכות נחלות" ] }, { "book": "משנה תורה, הלכות טוען ונטען", "commentators": [ - "כסף משנה על משנה תורה, הלכות טוען ונטען" + "כסף משנה על משנה תורה, הלכות טוען ונטען", + "מגיד משנה על משנה תורה, הלכות טוען ונטען", + "השגות הראבד על משנה תורה, הלכות טוען ונטען" ] }, { "book": "משנה תורה, הלכות שאלה ופיקדון", "commentators": [ - "כסף משנה על משנה תורה, הלכות שאלה ופיקדון" + "כסף משנה על משנה תורה, הלכות שאלה ופיקדון", + "מגיד משנה על משנה תורה, הלכות שאלה ופיקדון", + "השגות הראבד על משנה תורה, הלכות שאלה ופיקדון" ] }, { "book": "משנה תורה, הלכות שאר אבות הטומאות", "commentators": [ - "כסף משנה על משנה תורה, הלכות שאר אבות הטומאות" + "כסף משנה על משנה תורה, הלכות שאר אבות הטומאות", + "השגות הראבד על משנה תורה, הלכות שאר אבות הטומאות" ] }, { "book": "משנה תורה, הלכות טומאת מת", "commentators": [ - "כסף משנה על משנה תורה, הלכות טומאת מת" + "כסף משנה על משנה תורה, הלכות טומאת מת", + "השגות הראבד על משנה תורה, הלכות טומאת מת" ] }, { "book": "משנה תורה, הלכות טומאת צרעת", "commentators": [ - "כסף משנה על משנה תורה, הלכות טומאת צרעת" + "כסף משנה על משנה תורה, הלכות טומאת צרעת", + "השגות הראבד על משנה תורה, הלכות טומאת צרעת" ] }, { "book": "משנה תורה, הלכות מטמאי משכב ומושב", "commentators": [ - "כסף משנה על משנה תורה, הלכות מטמאי משכב ומושב" + "כסף משנה על משנה תורה, הלכות מטמאי משכב ומושב", + "השגות הראבד על משנה תורה, הלכות מטמאי משכב ומושב" ] }, { "book": "משנה תורה, הלכות טומאת אוכלים", "commentators": [ - "כסף משנה על משנה תורה, הלכות טומאת אוכלים" + "כסף משנה על משנה תורה, הלכות טומאת אוכלים", + "השגות הראבד על משנה תורה, הלכות טומאת אוכלים" ] }, { "book": "משנה תורה, הלכות פרה אדומה", "commentators": [ - "כסף משנה על משנה תורה, הלכות פרה אדומה" + "כסף משנה על משנה תורה, הלכות פרה אדומה", + "השגות הראבד על משנה תורה, הלכות פרה אדומה" ] }, { "book": "משנה תורה, הלכות מקואות", "commentators": [ - "כסף משנה על משנה תורה, הלכות מקואות" + "כסף משנה על משנה תורה, הלכות מקואות", + "השגות הראבד על משנה תורה, הלכות מקואות" ] }, { "book": "משנה תורה, הלכות כלים", "commentators": [ - "כסף משנה על משנה תורה, הלכות כלים" + "כסף משנה על משנה תורה, הלכות כלים", + "השגות הראבד על משנה תורה, הלכות כלים" ] }, { "book": "משנה תורה, הלכות סנהדרין והעונשין המסורין להם", "commentators": [ - "כסף משנה על משנה תורה, הלכות סנהדרין והעונשין המסורין להם" + "כסף משנה על משנה תורה, הלכות סנהדרין והעונשין המסורין להם", + "השגות הראבד על משנה תורה, הלכות סנהדרין והעונשין המסורין להם" ] }, { "book": "משנה תורה, הלכות עדות", "commentators": [ - "כסף משנה על משנה תורה, הלכות עדות" + "כסף משנה על משנה תורה, הלכות עדות", + "השגות הראבד על משנה תורה, הלכות עדות" ] }, { "book": "משנה תורה, הלכות ממרים", "commentators": [ - "כסף משנה על משנה תורה, הלכות ממרים" + "כסף משנה על משנה תורה, הלכות ממרים", + "השגות הראבד על משנה תורה, הלכות ממרים" ] }, { "book": "משנה תורה, הלכות אבל", "commentators": [ - "כסף משנה על משנה תורה, הלכות אבל" + "כסף משנה על משנה תורה, הלכות אבל", + "השגות הראבד על משנה תורה, הלכות אבל" ] }, { "book": "משנה תורה, הלכות מלכים ומלחמות", "commentators": [ - "כסף משנה על משנה תורה, הלכות מלכים ומלחמות" + "כסף משנה על משנה תורה, הלכות מלכים ומלחמות", + "השגות הראבד על משנה תורה, הלכות מלכים ומלחמות" ] }, { @@ -1573,7 +1755,9 @@ "book": "תלמוד ירושלמי ביצה", "commentators": [ "פני משה על תלמוד ירושלמי ביצה", - "מראה הפנים על תלמוד ירושלמי ביצה" + "קרבן העדה על תלמוד ירושלמי ביצה", + "מראה הפנים על תלמוד ירושלמי ביצה", + "שיירי קרבן על תלמוד ירושלמי ביצה" ] }, { @@ -1594,7 +1778,9 @@ "book": "תלמוד ירושלמי גיטין", "commentators": [ "פני משה על תלמוד ירושלמי גיטין", - "מראה הפנים על תלמוד ירושלמי גיטין" + "קרבן העדה על תלמוד ירושלמי גיטין", + "מראה הפנים על תלמוד ירושלמי גיטין", + "שיירי קרבן על תלמוד ירושלמי גיטין" ] }, { @@ -1615,7 +1801,9 @@ "book": "תלמוד ירושלמי חגיגה", "commentators": [ "פני משה על תלמוד ירושלמי חגיגה", - "מראה הפנים על תלמוד ירושלמי חגיגה" + "קרבן העדה על תלמוד ירושלמי חגיגה", + "מראה הפנים על תלמוד ירושלמי חגיגה", + "שיירי קרבן על תלמוד ירושלמי חגיגה" ] }, { @@ -1629,14 +1817,18 @@ "book": "תלמוד ירושלמי יבמות", "commentators": [ "פני משה על תלמוד ירושלמי יבמות", - "מראה הפנים על תלמוד ירושלמי יבמות" + "קרבן העדה על תלמוד ירושלמי יבמות", + "מראה הפנים על תלמוד ירושלמי יבמות", + "שיירי קרבן על תלמוד ירושלמי יבמות" ] }, { "book": "תלמוד ירושלמי יומא", "commentators": [ "פני משה על תלמוד ירושלמי יומא", - "מראה הפנים על תלמוד ירושלמי יומא" + "קרבן העדה על תלמוד ירושלמי יומא", + "מראה הפנים על תלמוד ירושלמי יומא", + "שיירי קרבן על תלמוד ירושלמי יומא" ] }, { @@ -1650,28 +1842,36 @@ "book": "תלמוד ירושלמי כתובות", "commentators": [ "פני משה על תלמוד ירושלמי כתובות", - "מראה הפנים על תלמוד ירושלמי כתובות" + "קרבן העדה על תלמוד ירושלמי כתובות", + "מראה הפנים על תלמוד ירושלמי כתובות", + "שיירי קרבן על תלמוד ירושלמי כתובות" ] }, { "book": "תלמוד ירושלמי מגילה", "commentators": [ "פני משה על תלמוד ירושלמי מגילה", - "מראה הפנים על תלמוד ירושלמי מגילה" + "קרבן העדה על תלמוד ירושלמי מגילה", + "מראה הפנים על תלמוד ירושלמי מגילה", + "שיירי קרבן על תלמוד ירושלמי מגילה" ] }, { "book": "תלמוד ירושלמי מועד קטן", "commentators": [ "פני משה על תלמוד ירושלמי מועד קטן", - "מראה הפנים על תלמוד ירושלמי מועד קטן" + "קרבן העדה על תלמוד ירושלמי מועד קטן", + "מראה הפנים על תלמוד ירושלמי מועד קטן", + "שיירי קרבן על תלמוד ירושלמי מועד קטן" ] }, { "book": "תלמוד ירושלמי מכות", "commentators": [ "פני משה על תלמוד ירושלמי מכות", - "מראה הפנים על תלמוד ירושלמי מכות" + "קרבן העדה על תלמוד ירושלמי מכות", + "מראה הפנים על תלמוד ירושלמי מכות", + "שיירי קרבן על תלמוד ירושלמי מכות" ] }, { @@ -1699,35 +1899,45 @@ "book": "תלמוד ירושלמי נדרים", "commentators": [ "פני משה על תלמוד ירושלמי נדרים", - "מראה הפנים על תלמוד ירושלמי נדרים" + "קרבן העדה על תלמוד ירושלמי נדרים", + "מראה הפנים על תלמוד ירושלמי נדרים", + "שיירי קרבן על תלמוד ירושלמי נדרים" ] }, { "book": "תלמוד ירושלמי נזיר", "commentators": [ "פני משה על תלמוד ירושלמי נזיר", - "מראה הפנים על תלמוד ירושלמי נזיר" + "קרבן העדה על תלמוד ירושלמי נזיר", + "מראה הפנים על תלמוד ירושלמי נזיר", + "שיירי קרבן על תלמוד ירושלמי נזיר" ] }, { "book": "תלמוד ירושלמי סוטה", "commentators": [ "פני משה על תלמוד ירושלמי סוטה", - "מראה הפנים על תלמוד ירושלמי סוטה" + "קרבן העדה על תלמוד ירושלמי סוטה", + "מראה הפנים על תלמוד ירושלמי סוטה", + "שיירי קרבן על תלמוד ירושלמי סוטה" ] }, { "book": "תלמוד ירושלמי סוכה", "commentators": [ "פני משה על תלמוד ירושלמי סוכה", - "מראה הפנים על תלמוד ירושלמי סוכה" + "קרבן העדה על תלמוד ירושלמי סוכה", + "מראה הפנים על תלמוד ירושלמי סוכה", + "שיירי קרבן על תלמוד ירושלמי סוכה" ] }, { "book": "תלמוד ירושלמי סנהדרין", "commentators": [ "פני משה על תלמוד ירושלמי סנהדרין", - "מראה הפנים על תלמוד ירושלמי סנהדרין" + "קרבן העדה על תלמוד ירושלמי סנהדרין", + "מראה הפנים על תלמוד ירושלמי סנהדרין", + "שיירי קרבן על תלמוד ירושלמי סנהדרין" ] }, { @@ -1741,7 +1951,9 @@ "book": "תלמוד ירושלמי עירובין", "commentators": [ "פני משה על תלמוד ירושלמי עירובין", - "מראה הפנים על תלמוד ירושלמי עירובין" + "קרבן העדה על תלמוד ירושלמי עירובין", + "מראה הפנים על תלמוד ירושלמי עירובין", + "שיירי קרבן על תלמוד ירושלמי עירובין" ] }, { @@ -1762,28 +1974,36 @@ "book": "תלמוד ירושלמי פסחים", "commentators": [ "פני משה על תלמוד ירושלמי פסחים", - "מראה הפנים על תלמוד ירושלמי פסחים" + "קרבן העדה על תלמוד ירושלמי פסחים", + "מראה הפנים על תלמוד ירושלמי פסחים", + "שיירי קרבן על תלמוד ירושלמי פסחים" ] }, { "book": "תלמוד ירושלמי קידושין", "commentators": [ "פני משה על תלמוד ירושלמי קידושין", - "מראה הפנים על תלמוד ירושלמי קידושין" + "קרבן העדה על תלמוד ירושלמי קידושין", + "מראה הפנים על תלמוד ירושלמי קידושין", + "שיירי קרבן על תלמוד ירושלמי קידושין" ] }, { "book": "תלמוד ירושלמי ראש השנה", "commentators": [ "פני משה על תלמוד ירושלמי ראש השנה", - "מראה הפנים על תלמוד ירושלמי ראש השנה" + "קרבן העדה על תלמוד ירושלמי ראש השנה", + "מראה הפנים על תלמוד ירושלמי ראש השנה", + "שיירי קרבן על תלמוד ירושלמי ראש השנה" ] }, { "book": "תלמוד ירושלמי שבועות", "commentators": [ "פני משה על תלמוד ירושלמי שבועות", - "מראה הפנים על תלמוד ירושלמי שבועות" + "קרבן העדה על תלמוד ירושלמי שבועות", + "מראה הפנים על תלמוד ירושלמי שבועות", + "שיירי קרבן על תלמוד ירושלמי שבועות" ] }, { @@ -1797,21 +2017,27 @@ "book": "תלמוד ירושלמי שבת", "commentators": [ "פני משה על תלמוד ירושלמי שבת", - "מראה הפנים על תלמוד ירושלמי שבת" + "קרבן העדה על תלמוד ירושלמי שבת", + "מראה הפנים על תלמוד ירושלמי שבת", + "שיירי קרבן על תלמוד ירושלמי שבת" ] }, { "book": "תלמוד ירושלמי שקלים", "commentators": [ "פני משה על תלמוד ירושלמי שקלים", - "מראה הפנים על תלמוד ירושלמי שקלים" + "קרבן העדה על תלמוד ירושלמי שקלים", + "מראה הפנים על תלמוד ירושלמי שקלים", + "שיירי קרבן על תלמוד ירושלמי שקלים" ] }, { "book": "תלמוד ירושלמי תענית", "commentators": [ "פני משה על תלמוד ירושלמי תענית", - "מראה הפנים על תלמוד ירושלמי תענית" + "קרבן העדה על תלמוד ירושלמי תענית", + "מראה הפנים על תלמוד ירושלמי תענית", + "שיירי קרבן על תלמוד ירושלמי תענית" ] }, { diff --git a/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/ForDbCsvParsingTest.kt b/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/ForDbCsvParsingTest.kt new file mode 100644 index 00000000..93f2f656 --- /dev/null +++ b/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/ForDbCsvParsingTest.kt @@ -0,0 +1,47 @@ +package io.github.kdroidfilter.seforimlibrary.sefariasqlite + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** ForDB CSV parsing: fields must be kept verbatim, including edge spaces. */ +class ForDbCsvParsingTest { + @Test + fun fieldValuesAreKeptVerbatim_includingEdgeSpaces() { + val rows = parseRequiredCsvRows( + listOf( + """ דברי חמודות על ברכות,ראשונים""", + """טעבעלה באנדי על הגדה של פסח ,אחרונים""", + """" קרבן נתנאל על ביצה",ראשונים""", + ), + sourceName = "test.csv", + minFields = 2, + ) + + assertEquals( + listOf( + listOf(" דברי חמודות על ברכות", "ראשונים"), + listOf("טעבעלה באנדי על הגדה של פסח ", "אחרונים"), + listOf(" קרבן נתנאל על ביצה", "ראשונים"), + ), + rows, + ) + } + + @Test + fun blankRowsAreSkipped_evenWhenMadeOfSpacesAndCommas() { + val rows = parseRequiredCsvRows( + listOf("", " ", " , ", "בראשית,תנך"), + sourceName = "test.csv", + minFields = 2, + ) + assertEquals(listOf(listOf("בראשית", "תנך")), rows) + } + + @Test + fun rowWithMissingRequiredFieldFails() { + assertFailsWith<IllegalArgumentException> { + parseRequiredCsvRows(listOf("בראשית, "), sourceName = "test.csv", minFields = 2) + } + } +} diff --git a/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaAltTocBuilderTest.kt b/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaAltTocBuilderTest.kt index f459ca7e..fd0442bb 100644 --- a/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaAltTocBuilderTest.kt +++ b/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaAltTocBuilderTest.kt @@ -178,6 +178,7 @@ class SefariaAltTocBuilderTest { headings = emptyList(), authors = emptyList(), description = null, + heShortDesc = null, pubDates = emptyList(), altStructures = altStructures ) diff --git a/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaBookPayloadReaderTest.kt b/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaBookPayloadReaderTest.kt index c6f7bc5e..46dda08b 100644 --- a/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaBookPayloadReaderTest.kt +++ b/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaBookPayloadReaderTest.kt @@ -38,6 +38,29 @@ class SefariaBookPayloadReaderTest { assertTrue(payload.lines.none { it == "<h4>סימן א</h4>" }) } + @Test + fun separatesShortDescFromLongDesc() = runBlocking { + val tempDir = Files.createTempDirectory("seforim-test") + val schemaDir = Files.createDirectories(tempDir.resolve("schemas")) + val jsonDir = Files.createDirectories(tempDir.resolve("json")) + val bookDir = Files.createDirectories(jsonDir.resolve("Tur")) + + Files.writeString(schemaDir.resolve("Tur.json"), schemaJson) + Files.writeString(bookDir.resolve("merged.json"), mergedJson) + + val reader = SefariaBookPayloadReader( + Json { ignoreUnknownKeys = true; coerceInputValues = true }, + Logger.withTag("SefariaBookPayloadReaderTest") + ) + val schemaLookup = reader.buildSchemaLookup(schemaDir) + val payload = reader.readBooksInParallel(jsonDir, schemaDir, schemaLookup).single() + + // heShortDesc must hold Sefaria's real one-line summary; the long heDesc + // text must land in `description` (→ book.heDesc), not under heShortDesc. + assertEquals("תקציר קצר של הספר", payload.heShortDesc) + assertEquals("תיאור ארוך ומפורט של הספר וכל ענייניו", payload.description) + } + companion object { private val schemaJson = """ { @@ -46,6 +69,8 @@ class SefariaBookPayloadReaderTest { "schema": { "title": "Tur", "heTitle": "טור", + "heShortDesc": "תקציר קצר של הספר", + "heDesc": "תיאור ארוך ומפורט של הספר וכל ענייניו", "nodes": [ { "nodeType": "SchemaNode", diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc3ebdfc..09ed0fb6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] -filekitCore = "0.13.0" -kotlin = "2.3.20" -agp = "8.12.3" +filekitCore = "0.14.1" +kotlin = "2.3.21" +agp = "9.1.1" jvmToolchain = "25" lucene = "10.4.0" maven-publish = "0.36.0" diff --git a/release-manifest.json b/release-manifest.json index d992f7c9..8af65093 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,116 +1,45 @@ { - "generatedAt": "2026-06-18T12:30:17Z", + "generatedAt": "2026-06-22T22:26:54Z", "latest": { "assets": [ { - "apiUrl": "https://api.github.com/repos/kdroidFilter/SeforimLibrary/releases/assets/449999980", - "contentType": "application/octet-stream", - "createdAt": "2026-06-17T07:35:31Z", - "digest": "sha256:1f03be43f9444c991ac4f0f2ef1669cf1a4014f3a5decb404fea9713cb522e5e", - "downloadCount": 0, - "id": "RA_kwDOPFZ3eM4a0nRs", - "label": "", - "name": "catalog.pb", - "size": 671639, - "state": "uploaded", - "updatedAt": "2026-06-17T07:35:31Z", - "url": "https://github.com/kdroidFilter/SeforimLibrary/releases/download/v2-20260617051715/catalog.pb" - }, - { - "apiUrl": "https://api.github.com/repos/kdroidFilter/SeforimLibrary/releases/assets/449999979", - "contentType": "application/octet-stream", - "createdAt": "2026-06-17T07:35:31Z", - "digest": "sha256:61cc7dc15db430989b560250490481590c7ae80f6dfe724b967f835e87ac0f58", - "downloadCount": 5, - "id": "RA_kwDOPFZ3eM4a0nRr", - "label": "", - "name": "patch-v1-v2.db.zst", - "size": 13425868, - "state": "uploaded", - "updatedAt": "2026-06-17T07:35:32Z", - "url": "https://github.com/kdroidFilter/SeforimLibrary/releases/download/v2-20260617051715/patch-v1-v2.db.zst" - }, - { - "apiUrl": "https://api.github.com/repos/kdroidFilter/SeforimLibrary/releases/assets/449999975", + "apiUrl": "https://api.github.com/repos/Otzaria/SeforimLibrary/releases/assets/397700465", "contentType": "application/json", - "createdAt": "2026-06-17T07:35:31Z", - "digest": "sha256:480336d19a0b8c4365b648d4a633a555c33e932c7021b6ea7687981e0c08080a", - "downloadCount": 6, - "id": "RA_kwDOPFZ3eM4a0nRn", + "createdAt": "2026-04-16T10:26:18Z", + "digest": "sha256:9bbf17911551718bfe8ce541835da8c2952af1258ff1051cc388ba018c772b17", + "downloadCount": 22, + "id": "RA_kwDOQ6kTXM4XtG1x", "label": "", - "name": "patch-v1-v2.db.zst.manifest.json", - "size": 641, + "name": "seforim-manifest.json", + "size": 70, "state": "uploaded", - "updatedAt": "2026-06-17T07:35:31Z", - "url": "https://github.com/kdroidFilter/SeforimLibrary/releases/download/v2-20260617051715/patch-v1-v2.db.zst.manifest.json" + "updatedAt": "2026-04-16T10:26:18Z", + "url": "https://github.com/Otzaria/SeforimLibrary/releases/download/db-v1/seforim-manifest.json" }, { - "apiUrl": "https://api.github.com/repos/kdroidFilter/SeforimLibrary/releases/assets/449999976", - "contentType": "application/octet-stream", - "createdAt": "2026-06-17T07:35:31Z", - "digest": "sha256:8cf5a5fc2382bb2da411ba2eea91d890f889a012fbe579134472def896c3109c", - "downloadCount": 1, - "id": "RA_kwDOPFZ3eM4a0nRo", + "apiUrl": "https://api.github.com/repos/Otzaria/SeforimLibrary/releases/assets/397700463", + "contentType": "application/zstd", + "createdAt": "2026-04-16T10:26:18Z", + "digest": "sha256:f99f8e2041abe7273c512a6cdd1d57ab356f2439285954b6da113760bdfe2dc9", + "downloadCount": 10303, + "id": "RA_kwDOQ6kTXM4XtG1v", "label": "", - "name": "seforim.db.buildstate", - "size": 940724224, + "name": "seforim.db.zst", + "size": 1080895219, "state": "uploaded", - "updatedAt": "2026-06-17T07:36:15Z", - "url": "https://github.com/kdroidFilter/SeforimLibrary/releases/download/v2-20260617051715/seforim.db.buildstate" - }, - { - "apiUrl": "https://api.github.com/repos/kdroidFilter/SeforimLibrary/releases/assets/449999981", - "contentType": "application/octet-stream", - "createdAt": "2026-06-17T07:35:31Z", - "digest": "sha256:777aeca9feb5f80f03b5cee84922dfec3e4aa6e51c3430cf68b6409dabbd3af3", - "downloadCount": 52, - "id": "RA_kwDOPFZ3eM4a0nRt", - "label": "", - "name": "seforim_bundle.tar.zst.part01", - "size": 2040109465, - "state": "uploaded", - "updatedAt": "2026-06-17T07:37:08Z", - "url": "https://github.com/kdroidFilter/SeforimLibrary/releases/download/v2-20260617051715/seforim_bundle.tar.zst.part01" - }, - { - "apiUrl": "https://api.github.com/repos/kdroidFilter/SeforimLibrary/releases/assets/449999977", - "contentType": "application/octet-stream", - "createdAt": "2026-06-17T07:35:31Z", - "digest": "sha256:99a7e032c15bf1b93a4aa340ba59a3af821adc38a67ee4a72a211b07110a3fae", - "downloadCount": 35, - "id": "RA_kwDOPFZ3eM4a0nRp", - "label": "", - "name": "seforim_bundle.tar.zst.part02", - "size": 1440094676, - "state": "uploaded", - "updatedAt": "2026-06-17T07:36:37Z", - "url": "https://github.com/kdroidFilter/SeforimLibrary/releases/download/v2-20260617051715/seforim_bundle.tar.zst.part02" + "updatedAt": "2026-04-16T10:27:11Z", + "url": "https://github.com/Otzaria/SeforimLibrary/releases/download/db-v1/seforim.db.zst" } ], - "name": "v2-20260617051715", - "publishedAt": "2026-06-17T07:35:29Z", - "tagName": "v2-20260617051715" + "name": "db-v1", + "publishedAt": "2026-04-16T10:27:12Z", + "tagName": "db-v1" }, "releases": [ { - "name": "v2-20260617051715", - "publishedAt": "2026-06-17T07:35:29Z", - "tagName": "v2-20260617051715" - }, - { - "name": "v1-20260601094341", - "publishedAt": "2026-06-01T11:50:22Z", - "tagName": "v1-20260601094341" - }, - { - "name": "20260114122617", - "publishedAt": "2026-01-15T05:41:46Z", - "tagName": "20260114122617" - }, - { - "name": "20251116085948", - "publishedAt": "2025-11-16T09:34:24Z", - "tagName": "20251116085948" + "name": "db-v1", + "publishedAt": "2026-04-16T10:27:12Z", + "tagName": "db-v1" } ] } diff --git a/search/build.gradle.kts b/search/build.gradle.kts index 52c283fb..cef595ed 100644 --- a/search/build.gradle.kts +++ b/search/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.multiplatform) + alias(libs.plugins.android.library) alias(libs.plugins.kotlinx.serialization) } @@ -8,13 +9,24 @@ group = "io.github.kdroidfilter.seforimlibrary" kotlin { jvmToolchain(libs.versions.jvmToolchain.get().toInt()) + androidLibrary { + namespace = "io.github.kdroidfilter.seforimlibrary.search" + compileSdk = 36 + minSdk = 21 + } jvm() + iosArm64() + iosSimulatorArm64() sourceSets { - jvmMain.dependencies { + commonMain.dependencies { api(project(":core")) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) + } + + jvmMain.dependencies { + // Lucene-backed search engine + dictionary index are JVM-only. implementation(libs.lucene.core) implementation(libs.lucene.analysis.common) implementation(libs.sqlDelight.driver.sqlite) diff --git a/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/HebrewTextUtils.kt b/search/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/HebrewTextUtils.kt similarity index 100% rename from search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/HebrewTextUtils.kt rename to search/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/HebrewTextUtils.kt diff --git a/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchEngine.kt b/search/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchEngine.kt similarity index 98% rename from search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchEngine.kt rename to search/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchEngine.kt index 827b639b..596275e1 100644 --- a/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchEngine.kt +++ b/search/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchEngine.kt @@ -1,6 +1,5 @@ package io.github.kdroidfilter.seforimlibrary.search -import java.io.Closeable /** * Main interface for full-text search operations on Hebrew religious texts. @@ -31,7 +30,7 @@ import java.io.Closeable * @see SearchSession for paginated result access * @see LineHit for individual search result structure */ -interface SearchEngine : Closeable { +interface SearchEngine : AutoCloseable { /** * Opens a search session for the given query with optional filters. diff --git a/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchSession.kt b/search/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchSession.kt similarity index 97% rename from search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchSession.kt rename to search/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchSession.kt index 0f6f7e79..6305df0d 100644 --- a/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchSession.kt +++ b/search/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchSession.kt @@ -1,6 +1,5 @@ package io.github.kdroidfilter.seforimlibrary.search -import java.io.Closeable /** * A stateful search session providing paginated access to search results. @@ -26,7 +25,7 @@ import java.io.Closeable * @see SearchEngine.openSession to create a session * @see SearchPage for the structure of returned pages */ -interface SearchSession : Closeable { +interface SearchSession : AutoCloseable { /** * Retrieves the next page of search results. * diff --git a/search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SnippetProvider.kt b/search/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SnippetProvider.kt similarity index 100% rename from search/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SnippetProvider.kt rename to search/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/SnippetProvider.kt diff --git a/search/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/StubSearchEngine.kt b/search/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/StubSearchEngine.kt new file mode 100644 index 00000000..94499de0 --- /dev/null +++ b/search/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/StubSearchEngine.kt @@ -0,0 +1,38 @@ +package io.github.kdroidfilter.seforimlibrary.search + +/** + * No-op [SearchEngine] for platforms without a native full-text backend. + * + * The JVM "actual" of the search engine is [LuceneSearchEngine]; Android/iOS use this stub + * (wired through DI) until a mobile search backend is implemented. It returns no results so the + * app launches and the search UI degrades gracefully rather than crashing. + */ +class StubSearchEngine : SearchEngine { + override fun openSession( + query: String, + near: Int, + bookFilter: Long?, + categoryFilter: Long?, + bookIds: Collection<Long>?, + lineIds: Collection<Long>?, + baseBookOnly: Boolean, + ): SearchSession? = null + + override fun searchBooksByTitlePrefix(query: String, limit: Int): List<Long> = emptyList() + + override fun buildSnippet(rawText: String, query: String, near: Int): String = rawText + + override fun buildHighlightTerms(query: String): List<String> = emptyList() + + override fun computeFacets( + query: String, + near: Int, + bookFilter: Long?, + categoryFilter: Long?, + bookIds: Collection<Long>?, + lineIds: Collection<Long>?, + baseBookOnly: Boolean, + ): SearchFacets? = null + + override fun close() {} +}